Compare commits
14 Commits
6250164656
...
new_ui
| Author | SHA1 | Date | |
|---|---|---|---|
| d61472f069 | |||
| 83617f75e4 | |||
| 901b342871 | |||
| b0cb8ca0a2 | |||
| 4605b251be | |||
| 073c8a6c5d | |||
| 9a72ba3064 | |||
| 34bb4a27be | |||
| e5cdd6b383 | |||
| 63c5d0a7c0 | |||
| 432416cfc5 | |||
| a407b57006 | |||
| b57b22c30b | |||
| a6d153ac1e |
+4
-2
@@ -3,8 +3,10 @@
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
|
||||
# Backend API URL
|
||||
VITE_API_URL="http://192.168.1.102:6400"
|
||||
# Backend API URL (Omnyx Backend)
|
||||
# Default: http://localhost:3001 for local dev
|
||||
# Change this if backend runs on different host/port
|
||||
VITE_API_URL="http://localhost:3001"
|
||||
|
||||
# Importer Configurations
|
||||
# XBVR Importer
|
||||
|
||||
@@ -6,3 +6,5 @@ coverage/
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
/docs
|
||||
/.windsurf
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: react
|
||||
description: Modern React patterns and principles. Hooks, composition, performance, TypeScript best practices.
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep
|
||||
---
|
||||
|
||||
# React Patterns
|
||||
|
||||
> Principles for building production-ready React applications.
|
||||
|
||||
---
|
||||
|
||||
## 1. Component Design Principles
|
||||
|
||||
### Component Types
|
||||
|
||||
| Type | Use | State |
|
||||
|------|-----|-------|
|
||||
| **Server** | Data fetching, static | None |
|
||||
| **Client** | Interactivity | useState, effects |
|
||||
| **Presentational** | UI display | Props only |
|
||||
| **Container** | Logic/state | Heavy state |
|
||||
|
||||
### Design Rules
|
||||
|
||||
- One responsibility per component
|
||||
- Props down, events up
|
||||
- Composition over inheritance
|
||||
- Prefer small, focused components
|
||||
|
||||
---
|
||||
|
||||
## 2. Hook Patterns
|
||||
|
||||
### When to Extract Hooks
|
||||
|
||||
| Pattern | Extract When |
|
||||
|---------|-------------|
|
||||
| **useLocalStorage** | Same storage logic needed |
|
||||
| **useDebounce** | Multiple debounced values |
|
||||
| **useFetch** | Repeated fetch patterns |
|
||||
| **useForm** | Complex form state |
|
||||
|
||||
### Hook Rules
|
||||
|
||||
- Hooks at top level only
|
||||
- Same order every render
|
||||
- Custom hooks start with "use"
|
||||
- Clean up effects on unmount
|
||||
|
||||
---
|
||||
|
||||
## 3. State Management Selection
|
||||
|
||||
| Complexity | Solution |
|
||||
|------------|----------|
|
||||
| Simple | useState, useReducer |
|
||||
| Shared local | Context |
|
||||
| Server state | React Query, SWR |
|
||||
| Complex global | Zustand, Redux Toolkit |
|
||||
|
||||
### State Placement
|
||||
|
||||
| Scope | Where |
|
||||
|-------|-------|
|
||||
| Single component | useState |
|
||||
| Parent-child | Lift state up |
|
||||
| Subtree | Context |
|
||||
| App-wide | Global store |
|
||||
|
||||
---
|
||||
|
||||
## 4. React 19 Patterns
|
||||
|
||||
### New Hooks
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| **useActionState** | Form submission state |
|
||||
| **useOptimistic** | Optimistic UI updates |
|
||||
| **use** | Read resources in render |
|
||||
|
||||
### Compiler Benefits
|
||||
|
||||
- Automatic memoization
|
||||
- Less manual useMemo/useCallback
|
||||
- Focus on pure components
|
||||
|
||||
---
|
||||
|
||||
## 5. Composition Patterns
|
||||
|
||||
### Compound Components
|
||||
|
||||
- Parent provides context
|
||||
- Children consume context
|
||||
- Flexible slot-based composition
|
||||
- Example: Tabs, Accordion, Dropdown
|
||||
|
||||
### Render Props vs Hooks
|
||||
|
||||
| Use Case | Prefer |
|
||||
|----------|--------|
|
||||
| Reusable logic | Custom hook |
|
||||
| Render flexibility | Render props |
|
||||
| Cross-cutting | Higher-order component |
|
||||
|
||||
---
|
||||
|
||||
## 6. Performance Principles
|
||||
|
||||
### When to Optimize
|
||||
|
||||
| Signal | Action |
|
||||
|--------|--------|
|
||||
| Slow renders | Profile first |
|
||||
| Large lists | Virtualize |
|
||||
| Expensive calc | useMemo |
|
||||
| Stable callbacks | useCallback |
|
||||
|
||||
### Optimization Order
|
||||
|
||||
1. Check if actually slow
|
||||
2. Profile with DevTools
|
||||
3. Identify bottleneck
|
||||
4. Apply targeted fix
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Handling
|
||||
|
||||
### Error Boundary Usage
|
||||
|
||||
| Scope | Placement |
|
||||
|-------|-----------|
|
||||
| App-wide | Root level |
|
||||
| Feature | Route/feature level |
|
||||
| Component | Around risky component |
|
||||
|
||||
### Error Recovery
|
||||
|
||||
- Show fallback UI
|
||||
- Log error
|
||||
- Offer retry option
|
||||
- Preserve user data
|
||||
|
||||
---
|
||||
|
||||
## 8. TypeScript Patterns
|
||||
|
||||
### Props Typing
|
||||
|
||||
| Pattern | Use |
|
||||
|---------|-----|
|
||||
| Interface | Component props |
|
||||
| Type | Unions, complex |
|
||||
| Generic | Reusable components |
|
||||
|
||||
### Common Types
|
||||
|
||||
| Need | Type |
|
||||
|------|------|
|
||||
| Children | ReactNode |
|
||||
| Event handler | MouseEventHandler |
|
||||
| Ref | RefObject<Element> |
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing Principles
|
||||
|
||||
| Level | Focus |
|
||||
|-------|-------|
|
||||
| Unit | Pure functions, hooks |
|
||||
| Integration | Component behavior |
|
||||
| E2E | User flows |
|
||||
|
||||
### Test Priorities
|
||||
|
||||
- User-visible behavior
|
||||
- Edge cases
|
||||
- Error states
|
||||
- Accessibility
|
||||
|
||||
---
|
||||
|
||||
## 10. Anti-Patterns
|
||||
|
||||
| ❌ Don't | ✅ Do |
|
||||
|----------|-------|
|
||||
| Prop drilling deep | Use context |
|
||||
| Giant components | Split smaller |
|
||||
| useEffect for everything | Server components |
|
||||
| Premature optimization | Profile first |
|
||||
| Index as key | Stable unique ID |
|
||||
|
||||
---
|
||||
|
||||
> **Remember:** React is about composition. Build small, combine thoughtfully.
|
||||
@@ -0,0 +1,404 @@
|
||||
---
|
||||
name: "Modern React Project Template"
|
||||
description: "A comprehensive development guide for modern frontend projects based on React 18 + TypeScript + Vite, including complete development standards and best practices"
|
||||
category: "Frontend Framework"
|
||||
author: "Agents.md Collection"
|
||||
authorUrl: "https://github.com/gakeez/agents_md_collection"
|
||||
tags: ["react", "typescript", "vite", "frontend", "spa"]
|
||||
lastUpdated: "2024-12-19"
|
||||
---
|
||||
|
||||
# Modern React Project Development Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a modern frontend project template based on React 18, TypeScript, and Vite. It's suitable for building high-performance Single Page Applications (SPA) with integrated modern development toolchain and best practices.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend Framework**: React 18 + TypeScript
|
||||
- **Build Tool**: Vite
|
||||
- **State Management**: Zustand / Redux Toolkit
|
||||
- **Routing**: React Router v6
|
||||
- **UI Components**: Ant Design / Material-UI
|
||||
- **Styling**: Tailwind CSS 4 with shadcn/ui component library
|
||||
- **Testing Framework**: Vitest + React Testing Library
|
||||
- **Code Quality**: ESLint + Prettier + Husky
|
||||
- **UI Components**: Complete shadcn/ui component set (New York style) with Lucide icons
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
react-project/
|
||||
├── public/ # Static assets
|
||||
│ ├── favicon.ico
|
||||
│ └── index.html
|
||||
├── src/
|
||||
│ ├── components/ # Reusable components
|
||||
│ │ ├── common/ # Common components
|
||||
│ │ └── ui/ # UI components
|
||||
│ ├── pages/ # Page components
|
||||
│ ├── hooks/ # Custom Hooks
|
||||
│ ├── store/ # State management
|
||||
│ ├── services/ # API services
|
||||
│ ├── utils/ # Utility functions
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ ├── styles/ # Global styles
|
||||
│ ├── constants/ # Constants
|
||||
│ ├── App.tsx
|
||||
│ └── main.tsx
|
||||
├── tests/ # Test files
|
||||
├── docs/ # Project documentation
|
||||
├── .env.example # Environment variables example
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── vite.config.ts
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Component Development Standards
|
||||
|
||||
1. **Function Components First**: Use function components and Hooks
|
||||
2. **TypeScript Types**: Define interfaces for all props
|
||||
3. **Component Naming**: Use PascalCase, file name matches component name
|
||||
4. **Single Responsibility**: Each component handles only one functionality
|
||||
|
||||
```tsx
|
||||
// Example: Button Component
|
||||
interface ButtonProps {
|
||||
variant: 'primary' | 'secondary' | 'danger';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant,
|
||||
size = 'medium',
|
||||
disabled = false,
|
||||
onClick,
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`btn btn-${variant} btn-${size}`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### State Management Standards
|
||||
|
||||
Using Zustand for state management:
|
||||
|
||||
```tsx
|
||||
// store/userStore.ts
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
setUser: (user: User) => void;
|
||||
clearUser: () => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>((set) => ({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
setUser: (user) => set({ user }),
|
||||
clearUser: () => set({ user: null }),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
}));
|
||||
```
|
||||
|
||||
### API Service Standards
|
||||
|
||||
```tsx
|
||||
// services/api.ts
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
console.error('API Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
### Development Requirements
|
||||
- Node.js >= 18.0.0
|
||||
- npm >= 8.0.0 or yarn >= 1.22.0
|
||||
|
||||
### Installation Steps
|
||||
```bash
|
||||
# 1. Create project
|
||||
npm create vite@latest my-react-app -- --template react-ts
|
||||
|
||||
# 2. Navigate to project directory
|
||||
cd my-react-app
|
||||
|
||||
# 3. Install dependencies
|
||||
npm install
|
||||
|
||||
# 4. Install additional dependencies
|
||||
npm install zustand react-router-dom axios
|
||||
npm install -D @types/node
|
||||
|
||||
# 5. Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Environment Variables Configuration
|
||||
```env
|
||||
# .env.local
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
VITE_APP_TITLE=My React App
|
||||
VITE_ENABLE_MOCK=false
|
||||
```
|
||||
|
||||
## Routing Configuration
|
||||
|
||||
```tsx
|
||||
// App.tsx
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { AboutPage } from './pages/AboutPage';
|
||||
import { NotFoundPage } from './pages/NotFoundPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Testing Example
|
||||
```tsx
|
||||
// tests/components/Button.test.tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Button } from '../src/components/Button';
|
||||
|
||||
describe('Button Component', () => {
|
||||
test('renders button with text', () => {
|
||||
render(<Button variant="primary">Click me</Button>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onClick when clicked', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Button variant="primary" onClick={handleClick}>
|
||||
Click me
|
||||
</Button>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Click me'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Code Splitting
|
||||
```tsx
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const LazyComponent = lazy(() => import('./LazyComponent'));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<LazyComponent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Memory Optimization
|
||||
```tsx
|
||||
import { memo, useMemo, useCallback } from 'react';
|
||||
|
||||
const ExpensiveComponent = memo(({ data, onUpdate }) => {
|
||||
const processedData = useMemo(() => {
|
||||
return data.map(item => ({ ...item, processed: true }));
|
||||
}, [data]);
|
||||
|
||||
const handleUpdate = useCallback((id) => {
|
||||
onUpdate(id);
|
||||
}, [onUpdate]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{processedData.map(item => (
|
||||
<div key={item.id} onClick={() => handleUpdate(item.id)}>
|
||||
{item.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Deployment Configuration
|
||||
|
||||
### Build Production Version
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Vite Configuration Optimization
|
||||
```ts
|
||||
// vite.config.ts
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom'],
|
||||
router: ['react-router-dom'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## Styling
|
||||
|
||||
1. Use the shadcn/ui library unless the user specifies otherwise.
|
||||
2. Avoid using indigo or blue colors unless specified in the user's request.
|
||||
3. MUST generate responsive designs.
|
||||
4. The Code Project is rendered on top of a white background. If a different background color is needed, use a wrapper element with a background color Tailwind class.
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Design Standards
|
||||
|
||||
### Visual Design
|
||||
|
||||
- **Color System**: Use Tailwind CSS built-in variables (`bg-primary`, `text-primary-foreground`, `bg-background`).
|
||||
- **Color Restriction**: NO indigo or blue colors unless explicitly requested.
|
||||
- **Theme Support**: Implement light/dark mode with `next-themes`.
|
||||
- **Typography**: Consistent hierarchy with proper font weights and sizes.
|
||||
|
||||
### Responsive Design (MANDATORY)
|
||||
|
||||
- **Mobile-First**: Design for mobile, then enhance for desktop.
|
||||
- **Breakpoints**: Use Tailwind responsive prefixes (`sm:`, `md:`, `lg:`, `xl:`).
|
||||
- **Touch-Friendly**: Minimum 44px touch targets for interactive elements.
|
||||
|
||||
### Layout (MANDATORY)
|
||||
|
||||
- **Sticky Footer Required**: If a `footer` exists, it MUST stick to the bottom of the viewport when content is shorter than one screen height (no floating/empty gap below).
|
||||
- **Natural Push on Overflow**: When content exceeds the viewport height, the footer MUST be pushed down naturally (never overlay or cover content).
|
||||
- **Recommended Implementation (Tailwind)**: Use a root wrapper with `min-h-screen flex flex-col`, and apply `mt-auto` to the `footer`.
|
||||
- **Mobile Safe Area**: On devices with safe areas (e.g., iOS), the footer MUST respect bottom safe area insets when applicable.
|
||||
|
||||
### Accessibility (MANDATORY)
|
||||
|
||||
- **Semantic HTML**: Use `main`, `header`, `nav`, `section`, `article`.
|
||||
- **ARIA Support**: Proper roles, labels, and descriptions.
|
||||
- **Screen Readers**: Use `sr-only` class for screen reader content.
|
||||
- **Alt Text**: Descriptive alt text for all images.
|
||||
- **Keyboard Navigation**: Ensure all elements are keyboard accessible.
|
||||
|
||||
### Interactive Elements
|
||||
|
||||
- **Loading States**: Show spinners/skeletons during async operations.
|
||||
- **Error Handling**: Clear, actionable error messages.
|
||||
- **Feedback**: Toast notifications for user actions.
|
||||
- **Animations**: Subtle Framer Motion transitions (hover, focus, page transitions).
|
||||
- **Hover Effects**: Interactive feedback on all clickable elements.
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Issue 1: Vite Development Server Slow Startup
|
||||
**Solution**:
|
||||
- Check dependency pre-build cache
|
||||
- Use `npm run dev -- --force` to force rebuild
|
||||
- Optimize optimizeDeps configuration in vite.config.ts
|
||||
|
||||
### Issue 2: TypeScript Type Errors
|
||||
**Solution**:
|
||||
- Ensure correct type definition packages are installed
|
||||
- Check tsconfig.json configuration
|
||||
- Use `npm run type-check` for type checking
|
||||
|
||||
## Task Management
|
||||
|
||||
### Todo-Listen System
|
||||
|
||||
Alle AIs MÜSSEN Todo-Listen für komplexe Aufgaben verwenden:
|
||||
|
||||
- **Erstellung**: Bei mehreren Schritten oder komplexen Aufgaben eine Todo-Liste erstellen
|
||||
- **Aktualisierung**: Fortschritt regelmäßig aktualisieren (in_progress, completed)
|
||||
- **Priorisierung**: Aufgaben mit high/medium/low priorisieren
|
||||
- **Dokumentation**: Wichtige Entscheidungen in der Todo festhalten
|
||||
|
||||
Beispiel Workflow:
|
||||
1. Todo-Liste am Anfang erstellen mit allen geplanten Schritten
|
||||
2. Aktuellen Schritt als `in_progress` markieren
|
||||
3. Erledigte Schritte als `completed` markieren
|
||||
4. Bei neuen Erkenntnissen die Liste aktualisieren
|
||||
|
||||
## Reference Resources
|
||||
|
||||
- [React Official Documentation](https://react.dev/)
|
||||
- [Vite Official Documentation](https://vitejs.dev/)
|
||||
- [TypeScript Official Documentation](https://www.typescriptlang.org/)
|
||||
- [React Router Documentation](https://reactrouter.com/)
|
||||
- [Zustand Documentation](https://github.com/pmndrs/zustand)
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Multi-stage build for Omnyx Frontend
|
||||
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
# Copy built files to nginx
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
RUN echo 'server { \
|
||||
listen 3000; \
|
||||
server_name localhost; \
|
||||
location / { \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
location /api { \
|
||||
proxy_pass http://backend:3001; \
|
||||
proxy_http_version 1.1; \
|
||||
proxy_set_header Upgrade $http_upgrade; \
|
||||
proxy_set_header Connection "upgrade"; \
|
||||
proxy_set_header Host $host; \
|
||||
proxy_set_header X-Real-IP $remote_addr; \
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||
proxy_set_header X-Forwarded-Proto $scheme; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,6 +1,10 @@
|
||||
# Kyoo - Media Discovery Platform
|
||||

|
||||
|
||||
A modern web application for browsing, managing, and discovering media across multiple categories. Kyoo provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR.
|
||||
# Omnyx - Media Discovery Platform
|
||||
|
||||

|
||||
|
||||
A modern web application for browsing, managing, and discovering media across multiple categories. Omnyx provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR.
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
+3
-1
@@ -21,5 +21,7 @@
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
"registries": {
|
||||
"@acme": "https://acme.com/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Google AI Studio App</title>
|
||||
<title>Omnyx - Media Discovery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Kyoo - Media Discovery",
|
||||
"name": "Omnyx - Media Discovery",
|
||||
"description": "A polished media discovery and tracking application inspired by modern anime platforms.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
|
||||
Generated
+1404
-7
File diff suppressed because it is too large
Load Diff
+14
-4
@@ -8,7 +8,12 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"docs": "typedoc",
|
||||
"docs:serve": "typedoc && npx serve docs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
@@ -25,18 +30,23 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"shadcn": "^4.2.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite": "^6.2.0"
|
||||
"vite": "^6.2.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitest/ui": "^4.1.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"jsdom": "^29.0.2",
|
||||
"shadcn": "^4.5.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typedoc": "^0.28.19",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
"vite": "^6.2.0",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
+349
-246
@@ -6,8 +6,10 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { LayoutGroup } from 'motion/react';
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
|
||||
import Header from './components/Header';
|
||||
import AppSidebar from './components/sidebar/AppSidebar';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import BrowseView from './components/BrowseView';
|
||||
import DashboardView from './components/DashboardView';
|
||||
import DetailView from './components/DetailView';
|
||||
import CastView from './components/CastView';
|
||||
import CastDetailView from './components/CastDetailView';
|
||||
@@ -15,31 +17,61 @@ import AddMediaView from './components/AddMediaView';
|
||||
import ImporterView from './components/ImporterView';
|
||||
import SettingsView from './components/SettingsView';
|
||||
import Loading from './components/ui/loading';
|
||||
import MediaDetailRoute from './components/routes/MediaDetailRoute';
|
||||
import CastDetailRoute from './components/routes/CastDetailRoute';
|
||||
import CategoryBrowseRoute from './components/routes/CategoryBrowseRoute';
|
||||
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
||||
import { Media, Staff, MediaCategory, UserSettings } from './types';
|
||||
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
|
||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
|
||||
import { Search, Plus, LayoutGrid, List, Filter } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CATEGORY_PATHS, PATH_TO_CATEGORY, DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from './constants';
|
||||
import { useAppStore } from './store/appStore';
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
const [activeCategory, setActiveCategory] = useState<MediaCategory>(
|
||||
(searchParams.get('category') as MediaCategory) || 'Anime'
|
||||
);
|
||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
||||
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
||||
|
||||
// Load media from API on component mount (only when not on cast routes)
|
||||
const [apiMedia, setApiMedia] = useState<Media[]>([]);
|
||||
const [mediaLoading, setMediaLoading] = useState(true);
|
||||
|
||||
// Zustand store
|
||||
const {
|
||||
apiMedia,
|
||||
customMedia,
|
||||
adultMedia,
|
||||
mediaLoading,
|
||||
selectedMedia,
|
||||
selectedPerson,
|
||||
activeCategory,
|
||||
enabledCategories,
|
||||
searchQuery,
|
||||
settings,
|
||||
setApiMedia,
|
||||
setCustomMedia,
|
||||
setAdultMedia,
|
||||
setMediaLoading,
|
||||
setSelectedMedia,
|
||||
setSelectedPerson,
|
||||
setActiveCategory,
|
||||
setEnabledCategories,
|
||||
setSearchQuery,
|
||||
setSettings,
|
||||
} = useAppStore();
|
||||
|
||||
|
||||
// Set category from URL path on mount or location change
|
||||
useEffect(() => {
|
||||
const pathParts = location.pathname.split('/').filter(Boolean);
|
||||
if (pathParts.length === 1 && PATH_TO_CATEGORY[pathParts[0]]) {
|
||||
const category = PATH_TO_CATEGORY[pathParts[0]];
|
||||
if (enabledCategories.includes(category)) {
|
||||
setActiveCategory(category);
|
||||
}
|
||||
}
|
||||
}, [location.pathname, enabledCategories, setActiveCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettingsFromApi = async () => {
|
||||
try {
|
||||
@@ -49,15 +81,47 @@ function AppContent() {
|
||||
setEnabledCategories(loadedSettings.enabledCategories);
|
||||
// Sync theme with theme context
|
||||
setTheme(loadedSettings.theme);
|
||||
|
||||
// Set custom page title
|
||||
if (loadedSettings.pageTitle) {
|
||||
document.title = loadedSettings.pageTitle;
|
||||
}
|
||||
|
||||
// Set custom favicon
|
||||
if (loadedSettings.favicon) {
|
||||
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
if (!faviconLink) {
|
||||
faviconLink = document.createElement('link');
|
||||
faviconLink.rel = 'icon';
|
||||
document.head.appendChild(faviconLink);
|
||||
}
|
||||
faviconLink.href = loadedSettings.favicon;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings from API:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadSettingsFromApi();
|
||||
}, [setTheme]);
|
||||
|
||||
// Apply custom colors when settings change
|
||||
useEffect(() => {
|
||||
if (settings?.customColors) {
|
||||
const root = document.documentElement;
|
||||
const colors = settings.customColors;
|
||||
|
||||
if (colors.primary) root.style.setProperty('--color-primary', colors.primary);
|
||||
if (colors.secondary) root.style.setProperty('--color-secondary', colors.secondary);
|
||||
if (colors.background) root.style.setProperty('--color-background', colors.background);
|
||||
if (colors.surface) root.style.setProperty('--color-surface', colors.surface);
|
||||
if (colors.text) root.style.setProperty('--color-text', colors.text);
|
||||
if (colors.muted) root.style.setProperty('--color-muted', colors.muted);
|
||||
if (colors.border) root.style.setProperty('--color-border', colors.border);
|
||||
}
|
||||
}, [settings?.customColors]);
|
||||
|
||||
const reloadSettings = async () => {
|
||||
try {
|
||||
const loadedSettings = await fetchSettings();
|
||||
@@ -66,6 +130,22 @@ function AppContent() {
|
||||
setEnabledCategories(loadedSettings.enabledCategories);
|
||||
// Sync theme with theme context
|
||||
setTheme(loadedSettings.theme);
|
||||
|
||||
// Set custom page title
|
||||
if (loadedSettings.pageTitle) {
|
||||
document.title = loadedSettings.pageTitle;
|
||||
}
|
||||
|
||||
// Set custom favicon
|
||||
if (loadedSettings.favicon) {
|
||||
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
if (!faviconLink) {
|
||||
faviconLink = document.createElement('link');
|
||||
faviconLink.rel = 'icon';
|
||||
document.head.appendChild(faviconLink);
|
||||
}
|
||||
faviconLink.href = loadedSettings.favicon;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reload settings from API:', error);
|
||||
@@ -92,47 +172,35 @@ function AppContent() {
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggleCategory = async (category: MediaCategory) => {
|
||||
setEnabledCategories(prev => {
|
||||
const isEnabling = !prev.includes(category);
|
||||
const newList = isEnabling
|
||||
? [...prev, category]
|
||||
: prev.filter(c => c !== category);
|
||||
|
||||
// If we disable the current active category, switch to another enabled one
|
||||
if (!isEnabling && activeCategory === category) {
|
||||
const nextCategory = newList.find(c => c !== category) || 'Anime';
|
||||
setActiveCategory(nextCategory as MediaCategory);
|
||||
const isEnabling = !enabledCategories.includes(category);
|
||||
const newList = isEnabling
|
||||
? [...enabledCategories, category]
|
||||
: enabledCategories.filter(c => c !== category);
|
||||
|
||||
// If we disable the current active category, switch to another enabled one
|
||||
if (!isEnabling && activeCategory === category) {
|
||||
const nextCategory = newList.find(c => c !== category) || 'Anime';
|
||||
setActiveCategory(nextCategory as MediaCategory);
|
||||
}
|
||||
|
||||
setEnabledCategories(newList);
|
||||
|
||||
// Save to API
|
||||
const baseSettings = settings || DEFAULT_SETTINGS;
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
enabledCategories: newList,
|
||||
};
|
||||
updateSettings(updatedSettings).then(saved => {
|
||||
if (saved) {
|
||||
setSettings(saved);
|
||||
}
|
||||
|
||||
// Save to API
|
||||
const baseSettings = settings || {
|
||||
enabledCategories: prev,
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid',
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
};
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
enabledCategories: newList,
|
||||
};
|
||||
updateSettings(updatedSettings).then(saved => {
|
||||
if (saved) {
|
||||
setSettings(saved);
|
||||
}
|
||||
});
|
||||
|
||||
return newList;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: MediaCategory) => {
|
||||
setActiveCategory(category);
|
||||
setSearchParams({ category });
|
||||
navigate('/');
|
||||
navigate(`/${CATEGORY_PATHS[category]}`);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
@@ -146,7 +214,8 @@ function AppContent() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const allMedia = useMemo(() => {
|
||||
// All media from enabled categories (for cross-category search)
|
||||
const allEnabledMedia = useMemo(() => {
|
||||
// Use API data if available, otherwise fall back to mock data
|
||||
let list: Media[] = [];
|
||||
|
||||
@@ -164,9 +233,14 @@ function AppContent() {
|
||||
list.push(DETAIL_MEDIA);
|
||||
}
|
||||
|
||||
// Filter by enabled categories only (all enabled categories, not just active)
|
||||
return list.filter(m => enabledCategories.includes(m.category));
|
||||
}, [enabledCategories, customMedia, apiMedia]);
|
||||
|
||||
const allMedia = useMemo(() => {
|
||||
// Filter by active category AND ensure it's enabled
|
||||
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
|
||||
}, [activeCategory, enabledCategories, customMedia, apiMedia]);
|
||||
return allEnabledMedia.filter(m => m.category === activeCategory);
|
||||
}, [activeCategory, allEnabledMedia]);
|
||||
|
||||
const handleAddMedia = async () => {
|
||||
// Reload all media from API to get the newly added item
|
||||
@@ -179,16 +253,7 @@ function AppContent() {
|
||||
};
|
||||
|
||||
const handleGridItemSizeChange = async (size: number) => {
|
||||
const baseSettings = settings || {
|
||||
enabledCategories: enabledCategories,
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid',
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
};
|
||||
const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories };
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
gridItemSize: size,
|
||||
@@ -202,37 +267,55 @@ function AppContent() {
|
||||
|
||||
const allStaff = useMemo(() => {
|
||||
const staff: Staff[] = [];
|
||||
// Use API data if available, otherwise fall back to mock data
|
||||
let baseList: Media[] = [];
|
||||
const staffIds = new Set<string>(); // Track unique staff to avoid duplicates
|
||||
|
||||
if (apiMedia.length > 0) {
|
||||
// API has data, use it
|
||||
baseList = [...apiMedia];
|
||||
} else {
|
||||
// API is empty, use mock data as fallback
|
||||
baseList = [...MOCK_MEDIA];
|
||||
}
|
||||
|
||||
// Add custom media and detail media
|
||||
baseList = [...baseList, ...customMedia];
|
||||
if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) {
|
||||
baseList.push(DETAIL_MEDIA);
|
||||
}
|
||||
|
||||
const enabledMedia = baseList.filter(m => enabledCategories.includes(m.category));
|
||||
|
||||
enabledMedia.forEach(media => {
|
||||
// Use allEnabledMedia which already has enabled categories filtered
|
||||
allEnabledMedia.forEach(media => {
|
||||
media.staff?.forEach(s => {
|
||||
staff.push({
|
||||
...s,
|
||||
mediaId: media.id,
|
||||
mediaTitle: media.title
|
||||
});
|
||||
// Avoid duplicate staff entries
|
||||
if (!staffIds.has(s.id)) {
|
||||
staffIds.add(s.id);
|
||||
staff.push({
|
||||
...s,
|
||||
mediaId: media.id,
|
||||
mediaTitle: media.title
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return staff;
|
||||
}, [enabledCategories, customMedia, apiMedia]);
|
||||
}, [allEnabledMedia]);
|
||||
|
||||
// Search across all enabled media (all categories)
|
||||
const searchResultsMedia = useMemo(() => {
|
||||
if (!searchQuery.trim()) return [];
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allEnabledMedia.filter(media =>
|
||||
media.title.toLowerCase().includes(query) ||
|
||||
media.year.toLowerCase().includes(query) ||
|
||||
media.genres?.some(g => g.toLowerCase().includes(query)) ||
|
||||
media.studios?.some(s => s.toLowerCase().includes(query)) ||
|
||||
media.description?.toLowerCase().includes(query) ||
|
||||
media.tags?.some(t => t.toLowerCase().includes(query)) ||
|
||||
media.developers?.some(d => d.toLowerCase().includes(query)) ||
|
||||
media.platforms?.some(p => p.toLowerCase().includes(query))
|
||||
);
|
||||
}, [allEnabledMedia, searchQuery]);
|
||||
|
||||
// Search cast members
|
||||
const searchResultsCast = useMemo(() => {
|
||||
if (!searchQuery.trim()) return [];
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allStaff.filter(staff =>
|
||||
staff.name.toLowerCase().includes(query) ||
|
||||
staff.role.toLowerCase().includes(query) ||
|
||||
staff.bio?.toLowerCase().includes(query) ||
|
||||
staff.occupations?.some(o => o.toLowerCase().includes(query)) ||
|
||||
staff.characterName?.toLowerCase().includes(query)
|
||||
);
|
||||
}, [allStaff, searchQuery]);
|
||||
|
||||
// Legacy filteredMedia for backward compatibility (searches within current category)
|
||||
const filteredMedia = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allMedia;
|
||||
const query = searchQuery.toLowerCase();
|
||||
@@ -300,172 +383,192 @@ function AppContent() {
|
||||
params.delete('search');
|
||||
}
|
||||
setSearchParams(params);
|
||||
navigate('/');
|
||||
navigate('/browse');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
enabledCategories={enabledCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
transparent={location.pathname.startsWith('/media/') || location.pathname.startsWith('/cast/')}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<LayoutGroup>
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<BrowseView
|
||||
mediaList={filteredMedia}
|
||||
onMediaClick={handleMediaClick}
|
||||
activeCategory={activeCategory}
|
||||
itemsPerPage={settings?.itemsPerPage}
|
||||
gridItemSize={settings?.gridItemSize}
|
||||
onGridItemSizeChange={handleGridItemSizeChange}
|
||||
loading={mediaLoading}
|
||||
/>
|
||||
} />
|
||||
<Route path="/media/:id" element={
|
||||
<MediaDetailRoute
|
||||
selectedMedia={selectedMedia}
|
||||
setSelectedMedia={setSelectedMedia}
|
||||
allMedia={allMedia}
|
||||
onPersonClick={handlePersonClick}
|
||||
/>
|
||||
} />
|
||||
<Route path="/cast" element={
|
||||
<CastView
|
||||
onPersonClick={handlePersonClick}
|
||||
enabledCategories={enabledCategories}
|
||||
itemsPerPage={settings?.itemsPerPage}
|
||||
/>
|
||||
} />
|
||||
<Route path="/cast/:id" element={
|
||||
<CastDetailRoute
|
||||
selectedPerson={selectedPerson}
|
||||
setSelectedPerson={setSelectedPerson}
|
||||
/>
|
||||
} />
|
||||
<Route path="/add" element={
|
||||
<AddMediaView
|
||||
activeCategory={activeCategory}
|
||||
enabledCategories={enabledCategories}
|
||||
onAddComplete={handleAddMedia}
|
||||
/>
|
||||
} />
|
||||
<Route path="/import" element={
|
||||
<ImporterView />
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<SettingsView onSettingsSaved={reloadSettings} />
|
||||
} />
|
||||
</Routes>
|
||||
</LayoutGroup>
|
||||
</main>
|
||||
// Calculate media counts for sidebar (all categories)
|
||||
const mediaCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
// Count all enabled categories using allEnabledMedia
|
||||
enabledCategories.forEach(cat => {
|
||||
counts[cat] = allEnabledMedia.filter(m => m.category === cat).length;
|
||||
});
|
||||
// Add favorites count
|
||||
counts['favorites'] = allEnabledMedia.filter(m => m.rating && m.rating >= 8).length;
|
||||
// Add total count
|
||||
counts['all'] = allEnabledMedia.length;
|
||||
return counts;
|
||||
}, [allEnabledMedia, enabledCategories]);
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-6 border-t border-border bg-muted/50">
|
||||
<div className="max-w-[1600px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2 text-xl font-black text-muted-foreground">
|
||||
<div className="w-5 h-5 bg-muted rounded-full" />
|
||||
kyoo
|
||||
// Calculate active filter based on current URL
|
||||
const activeFilter = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
// Map routes to filter IDs
|
||||
const routeMap: Record<string, string> = {
|
||||
'/anime': 'anime',
|
||||
'/movies': 'movies',
|
||||
'/tv-series': 'tv-series',
|
||||
'/music': 'music',
|
||||
'/books': 'books',
|
||||
'/adult': 'adult',
|
||||
'/consoles': 'consoles',
|
||||
'/games': 'games',
|
||||
};
|
||||
if (routeMap[path]) return routeMap[path];
|
||||
if (searchParams.get('favorites') === 'true') return 'favorites';
|
||||
return undefined;
|
||||
}, [location.pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans selection:bg-[#e8466c]/20 selection:text-[#e8466c] flex">
|
||||
<SidebarProvider defaultOpen={true}>
|
||||
<AppSidebar
|
||||
enabledCategories={enabledCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
pageTitle={settings?.pageTitle || 'MediaVault'}
|
||||
mediaCounts={mediaCounts}
|
||||
activeFilter={activeFilter}
|
||||
/>
|
||||
|
||||
<main className="flex-1 flex flex-col relative">
|
||||
{/* Header with Search and Add Media */}
|
||||
<header className="sticky top-0 z-30 bg-background/80 backdrop-blur-xl border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4 max-w-[1920px] mx-auto">
|
||||
{/* Search Bar */}
|
||||
<div className="flex-1 max-w-xl">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search library..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-muted/30 border-border rounded-lg text-foreground placeholder:text-muted-foreground focus:border-[#e8466c]/50 focus:ring-[#e8466c]/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Toggle and Add Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center bg-muted/30 rounded-lg p-1 border border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded bg-accent text-accent-foreground"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAddMediaView}
|
||||
className="bg-[#e8466c] hover:bg-[#d13d60] text-white font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Media
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 text-sm font-bold text-muted-foreground">
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors">Terms</a>
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors">Privacy</a>
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors">Contact</a>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
© 2026 Kyoo Media Discovery. All rights reserved.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex-1">
|
||||
<LayoutGroup>
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<DashboardView
|
||||
mediaList={apiMedia.length > 0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))}
|
||||
onMediaClick={handleMediaClick}
|
||||
loading={mediaLoading}
|
||||
/>
|
||||
} />
|
||||
<Route path="/browse" element={
|
||||
<BrowseView
|
||||
mediaList={searchQuery.trim() ? searchResultsMedia : allMedia}
|
||||
onMediaClick={handleMediaClick}
|
||||
activeCategory={activeCategory}
|
||||
itemsPerPage={settings?.itemsPerPage}
|
||||
gridItemSize={settings?.gridItemSize}
|
||||
onGridItemSizeChange={handleGridItemSizeChange}
|
||||
loading={mediaLoading}
|
||||
searchResultsCast={searchQuery.trim() ? searchResultsCast : []}
|
||||
onCastClick={handlePersonClick}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
} />
|
||||
<Route path="/:category" element={
|
||||
<CategoryBrowseRoute
|
||||
mediaList={filteredMedia}
|
||||
onMediaClick={handleMediaClick}
|
||||
itemsPerPage={settings?.itemsPerPage}
|
||||
gridItemSize={settings?.gridItemSize}
|
||||
onGridItemSizeChange={handleGridItemSizeChange}
|
||||
loading={mediaLoading}
|
||||
/>
|
||||
} />
|
||||
<Route path="/media/:id" element={
|
||||
<MediaDetailRoute
|
||||
allMedia={allMedia}
|
||||
onPersonClick={handlePersonClick}
|
||||
/>
|
||||
} />
|
||||
<Route path="/cast" element={
|
||||
<CastView
|
||||
onPersonClick={handlePersonClick}
|
||||
enabledCategories={enabledCategories}
|
||||
itemsPerPage={settings?.itemsPerPage}
|
||||
/>
|
||||
} />
|
||||
<Route path="/cast/:id" element={
|
||||
<CastDetailRoute />
|
||||
} />
|
||||
<Route path="/add" element={
|
||||
<AddMediaView
|
||||
activeCategory={activeCategory}
|
||||
enabledCategories={enabledCategories}
|
||||
onAddComplete={handleAddMedia}
|
||||
/>
|
||||
} />
|
||||
<Route path="/import" element={
|
||||
<ImporterView />
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<SettingsView onSettingsSaved={reloadSettings} />
|
||||
} />
|
||||
</Routes>
|
||||
</LayoutGroup>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-auto py-3 px-6 border-t border-border bg-background">
|
||||
<div className="max-w-[1920px] mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<span>{mediaCounts.all} total</span>
|
||||
<span className="text-border-foreground">•</span>
|
||||
<span className="text-blue-400">{mediaCounts.movies} Movies</span>
|
||||
<span className="text-green-400">{mediaCounts.series} Series</span>
|
||||
<span className="text-purple-400">{mediaCounts.games} Games</span>
|
||||
<span className="text-red-400">{mediaCounts.adult} Adult</span>
|
||||
<span className="text-border-foreground">•</span>
|
||||
<span className="text-[#e8466c]">{mediaCounts.favorites} Favorites</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
© 2026 MediaVault v1.0.0
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for media detail route
|
||||
function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMedia = async () => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const fetchedMedia = await fetchMediaById(id);
|
||||
if (fetchedMedia) {
|
||||
setSelectedMedia(fetchedMedia);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media:', error);
|
||||
navigate('/');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadMedia();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Loading message="Loading media details..." />;
|
||||
if (!selectedMedia) return null;
|
||||
|
||||
return (
|
||||
<DetailView
|
||||
media={selectedMedia}
|
||||
onPersonClick={onPersonClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for cast detail route
|
||||
function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCast = async () => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const castData = await fetchCastById(id);
|
||||
if (castData) {
|
||||
const person = convertApiCastToStaff(castData);
|
||||
setSelectedPerson(person);
|
||||
} else {
|
||||
navigate('/cast');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cast:', error);
|
||||
navigate('/cast');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCast();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Loading message="Loading cast details..." />;
|
||||
if (!selectedPerson) return null;
|
||||
|
||||
return (
|
||||
<CastDetailView
|
||||
person={selectedPerson}
|
||||
relatedMedia={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
|
||||
+14
-760
@@ -1,603 +1,14 @@
|
||||
import { Media, Staff, UserSettings, MediaCategory } from './types';
|
||||
// Re-export all API functions for backward compatibility
|
||||
export * from './lib/api/mediaApi';
|
||||
export * from './lib/api/castApi';
|
||||
export * from './lib/api/settingsApi';
|
||||
export * from './lib/api/converters';
|
||||
export * from './lib/api/types';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
function normalizeUrl(url: string | null): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
// Remove leading slash if present and add base URL
|
||||
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
|
||||
return `${BASE_URL}/${cleanPath}`;
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
// Media Types
|
||||
export interface ApiEpisode {
|
||||
id: number;
|
||||
media_id: number;
|
||||
season: number;
|
||||
episode_number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
air_date: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ApiTrack {
|
||||
id: number;
|
||||
media_id: number;
|
||||
track_number: number;
|
||||
title: string;
|
||||
duration: number | null;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface ApiMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
poster: string | null;
|
||||
banner: string | null;
|
||||
description: string | null;
|
||||
rating: number | null;
|
||||
category: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
aspectRatio: string | null;
|
||||
runtime: number | null;
|
||||
director: string | null;
|
||||
writer: string | null;
|
||||
releaseDate: string | null;
|
||||
source?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
studios?: string[];
|
||||
staff?: ApiStaff[];
|
||||
categories?: string[];
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
playCount?: number;
|
||||
lastActivity?: string | null;
|
||||
playtime?: number;
|
||||
episodes?: ApiEpisode[];
|
||||
tracks?: ApiTrack[];
|
||||
}
|
||||
|
||||
export interface ApiStaff {
|
||||
id: number;
|
||||
name: string;
|
||||
photo: string | null;
|
||||
bio: string | null;
|
||||
birthDate: string | null;
|
||||
birthPlace: string | null;
|
||||
role: string;
|
||||
characterName: string | null;
|
||||
characterImage: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
export interface CreateMediaInput {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string | null;
|
||||
banner?: string | null;
|
||||
description?: string | null;
|
||||
rating?: number | null;
|
||||
category?: string | null;
|
||||
type?: string;
|
||||
status?: string;
|
||||
aspectRatio?: string | null;
|
||||
runtime?: number | null;
|
||||
director?: string | null;
|
||||
writer?: string | null;
|
||||
releaseDate?: string | null;
|
||||
source?: string | null;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
studios?: string[];
|
||||
staff?: CreateStaffInput[];
|
||||
}
|
||||
|
||||
export interface UpdateMediaInput extends Partial<CreateMediaInput> {}
|
||||
|
||||
export interface CreateStaffInput {
|
||||
name: string;
|
||||
photo?: string | null;
|
||||
bio?: string | null;
|
||||
birthDate?: string | null;
|
||||
birthPlace?: string | null;
|
||||
role: string;
|
||||
characterName?: string | null;
|
||||
characterImage?: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
// Cast Types
|
||||
export interface ApiCastItem {
|
||||
id: number;
|
||||
name: string;
|
||||
cleanname?: string;
|
||||
photo: string | null;
|
||||
bio: string | null;
|
||||
birthDate: string | null;
|
||||
birthPlace: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
occupations?: string[];
|
||||
filmography?: ApiCastMediaItem[];
|
||||
media_types?: string[];
|
||||
bust_size?: number | null;
|
||||
cup_size?: string | null;
|
||||
waist_size?: number | null;
|
||||
hip_size?: number | null;
|
||||
height?: number | null;
|
||||
weight?: number | null;
|
||||
hair_color?: string | null;
|
||||
eye_color?: string | null;
|
||||
ethnicity?: string | null;
|
||||
adult_specifics?: {
|
||||
id: number;
|
||||
cast_id: number;
|
||||
bust_size?: number | null;
|
||||
cup_size?: string | null;
|
||||
waist_size?: number | null;
|
||||
hip_size?: number | null;
|
||||
height?: number | null;
|
||||
weight?: number | null;
|
||||
hair_color?: string | null;
|
||||
eye_color?: string | null;
|
||||
ethnicity?: string | null;
|
||||
tattoos?: string | null;
|
||||
piercings?: string | null;
|
||||
measurements?: string | null;
|
||||
shoe_size?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiCastMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
poster: string | null;
|
||||
category: string | null;
|
||||
type: string;
|
||||
role: string;
|
||||
characterName?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateCastInput {
|
||||
name: string;
|
||||
photo?: string | null;
|
||||
bio?: string | null;
|
||||
birthDate?: string | null;
|
||||
birthPlace?: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateCastInput extends Partial<CreateCastInput> {}
|
||||
|
||||
|
||||
export function convertApiCastToStaff(apiItem: ApiCastItem): Staff {
|
||||
return {
|
||||
id: apiItem.id.toString(),
|
||||
name: apiItem.name,
|
||||
cleanname: apiItem.cleanname,
|
||||
role: apiItem.occupations?.[0] || 'Actor',
|
||||
photo: normalizeUrl(apiItem.photo) || `https://picsum.photos/seed/cast-${apiItem.id}/200/200`,
|
||||
bio: apiItem.bio || undefined,
|
||||
birthDate: apiItem.birthDate || undefined,
|
||||
birthPlace: apiItem.birthPlace || undefined,
|
||||
occupations: apiItem.occupations || ['Actor'],
|
||||
createdAt: apiItem.createdAt,
|
||||
updatedAt: apiItem.updatedAt,
|
||||
bust_size: apiItem.bust_size,
|
||||
cup_size: apiItem.cup_size,
|
||||
waist_size: apiItem.waist_size,
|
||||
hip_size: apiItem.hip_size,
|
||||
height: apiItem.height,
|
||||
weight: apiItem.weight,
|
||||
hair_color: apiItem.hair_color,
|
||||
eye_color: apiItem.eye_color,
|
||||
ethnicity: apiItem.ethnicity,
|
||||
filmography: apiItem.filmography?.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
poster: normalizeUrl(item.poster) || `https://picsum.photos/seed/${item.id}/400/600`,
|
||||
category: item.category,
|
||||
type: item.type,
|
||||
role: item.role,
|
||||
characterName: item.characterName
|
||||
})),
|
||||
media_types: apiItem.media_types,
|
||||
adult_specifics: apiItem.adult_specifics
|
||||
};
|
||||
}
|
||||
|
||||
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
// Convert staff from API to Media staff format
|
||||
const staff: Staff[] = (apiItem.staff || []).map((staffMember) => ({
|
||||
id: staffMember.id.toString(),
|
||||
name: staffMember.name,
|
||||
role: staffMember.role,
|
||||
photo: normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
||||
characterName: staffMember.characterName || staffMember.name,
|
||||
characterImage: normalizeUrl(staffMember.characterImage) || normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
||||
}));
|
||||
|
||||
// Determine aspect ratio from API format
|
||||
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
if (apiItem.aspectRatio) {
|
||||
const ratio = apiItem.aspectRatio.toLowerCase();
|
||||
if (ratio.includes('16:9') || ratio.includes('16/9') || ratio.includes('1.78') || ratio.includes('2.39')) {
|
||||
aspectRatio = '16/9';
|
||||
} else if (ratio.includes('1:1') || ratio.includes('1/1') || ratio.includes('1.00')) {
|
||||
aspectRatio = '1/1';
|
||||
} else if (ratio.includes('2/3')) {
|
||||
aspectRatio = '2/3';
|
||||
}
|
||||
}
|
||||
|
||||
// Map API type to Media type allowed values
|
||||
let mediaType: 'TV' | 'Movie' | 'OVA' | 'ONA' | 'Album' | 'Single' | 'Hardcover' | 'E-book' | 'Console' | 'Game' = 'Movie';
|
||||
const apiType = apiItem.type?.toLowerCase();
|
||||
if (apiType === 'tv' || apiType === 'episode') {
|
||||
mediaType = 'TV';
|
||||
} else if (apiType === 'album' || apiType === 'single') {
|
||||
mediaType = apiType === 'album' ? 'Album' : 'Single';
|
||||
} else if (apiType === 'game' || apiType === 'console') {
|
||||
mediaType = apiType === 'game' ? 'Game' : 'Console';
|
||||
} else if (apiType === 'ova') {
|
||||
mediaType = 'OVA';
|
||||
} else if (apiType === 'ona') {
|
||||
mediaType = 'ONA';
|
||||
} else if (apiType === 'hardcover' || apiType === 'e-book') {
|
||||
mediaType = apiType === 'hardcover' ? 'Hardcover' : 'E-book';
|
||||
}
|
||||
|
||||
// Map API category to MediaCategory
|
||||
let mediaCategory: 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games' = 'Movies';
|
||||
const apiCategory = apiItem.category?.toLowerCase();
|
||||
|
||||
if (apiCategory === 'anime') {
|
||||
mediaCategory = 'Anime';
|
||||
} else if (apiCategory === 'movie' || apiCategory === 'movies') {
|
||||
mediaCategory = 'Movies';
|
||||
} else if (apiCategory === 'tv' || apiCategory === 'series' || apiCategory === 'tv series' || apiType === 'tv' || apiType === 'episode') {
|
||||
mediaCategory = 'TV Series';
|
||||
} else if (apiCategory === 'music' || apiType === 'album' || apiType === 'single') {
|
||||
mediaCategory = 'Music';
|
||||
} else if (apiCategory === 'book' || apiCategory === 'books' || apiType === 'hardcover' || apiType === 'e-book') {
|
||||
mediaCategory = 'Books';
|
||||
} else if (apiCategory === 'adult') {
|
||||
mediaCategory = 'Adult';
|
||||
} else if (apiCategory === 'console' || apiCategory === 'consoles' || apiType === 'console') {
|
||||
mediaCategory = 'Consoles';
|
||||
} else if (apiCategory === 'game' || apiCategory === 'games' || apiType === 'game') {
|
||||
mediaCategory = 'Games';
|
||||
} else {
|
||||
// If category doesn't match any known category, use the original value capitalized
|
||||
// This handles cases where the API returns unexpected category values
|
||||
console.warn('Unknown category:', apiItem.category, 'defaulting to Movies');
|
||||
mediaCategory = 'Movies';
|
||||
}
|
||||
|
||||
// Map API status to Media status allowed values
|
||||
let mediaStatus: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold' = 'completed';
|
||||
const apiStatus = apiItem.status?.toLowerCase();
|
||||
if (apiStatus === 'ongoing' || apiStatus === 'watching') {
|
||||
mediaStatus = 'watching';
|
||||
} else if (apiStatus === 'upcoming' || apiStatus === 'planned') {
|
||||
mediaStatus = 'planned';
|
||||
} else if (apiStatus === 'dropped') {
|
||||
mediaStatus = 'dropped';
|
||||
} else if (apiStatus === 'reading') {
|
||||
mediaStatus = 'reading';
|
||||
} else if (apiStatus === 'listening') {
|
||||
mediaStatus = 'listening';
|
||||
} else if (apiStatus === 'playing') {
|
||||
mediaStatus = 'playing';
|
||||
} else if (apiStatus === 'on-hold') {
|
||||
mediaStatus = 'on-hold';
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiItem.id.toString(),
|
||||
title: apiItem.title,
|
||||
year: apiItem.year?.toString() || 'Unknown',
|
||||
poster: normalizeUrl(apiItem.poster) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
|
||||
category: mediaCategory,
|
||||
banner: normalizeUrl(apiItem.banner) || undefined,
|
||||
description: apiItem.description || undefined,
|
||||
rating: apiItem.rating || undefined,
|
||||
genres: apiItem.genres || [],
|
||||
tags: apiItem.tags || [],
|
||||
studios: apiItem.studios,
|
||||
type: mediaType,
|
||||
source: apiItem.source || undefined,
|
||||
status: mediaStatus,
|
||||
staff: staff.length > 0 ? staff : undefined,
|
||||
aspectRatio: aspectRatio,
|
||||
categories: apiItem.categories,
|
||||
platforms: apiItem.platforms,
|
||||
developers: apiItem.developers,
|
||||
completionStatus: apiItem.completionStatus,
|
||||
playCount: apiItem.playCount,
|
||||
lastActivity: apiItem.lastActivity,
|
||||
playtime: apiItem.playtime,
|
||||
episodes: apiItem.episodes,
|
||||
tracks: apiItem.tracks
|
||||
};
|
||||
}
|
||||
|
||||
// Media API Functions
|
||||
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching media from API:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaById(id: number | string): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMedia(media: CreateMediaInput): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(media),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating media:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMedia(id: number | string, media: UpdateMediaInput): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(media),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating media:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMedia(id: number | string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<{ message: string }> = await response.json();
|
||||
return data.success;
|
||||
} catch (error) {
|
||||
console.error('Error deleting media:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cast API Functions
|
||||
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<Staff[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<ApiCastItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiCastToStaff);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching cast from API:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCastById(id: number | string): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching cast by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCastMedia(castId: number | string): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${castId}/media`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching cast media:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCast(cast: CreateCastInput): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(cast),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating cast:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCast(id: number | string, cast: UpdateCastInput): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(cast),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating cast:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCast(id: number | string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<{ message: string }> = await response.json();
|
||||
return data.success;
|
||||
} catch (error) {
|
||||
console.error('Error deleting cast:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches all unique staff members from media
|
||||
export async function fetchAllActors(): Promise<Array<{id: number, name: string, photo: string | null}>> {
|
||||
try {
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
const actorMap = new Map<number, {id: number, name: string, photo: string | null}>();
|
||||
|
||||
media.forEach(item => {
|
||||
item.staff?.forEach(staffMember => {
|
||||
const id = parseInt(staffMember.id);
|
||||
if (!actorMap.has(id)) {
|
||||
actorMap.set(id, {
|
||||
id: id,
|
||||
name: staffMember.name,
|
||||
photo: staffMember.photo
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(actorMap.values());
|
||||
} catch (error) {
|
||||
console.error('Error fetching all actors:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches all unique tags from media
|
||||
// Legacy functions for compatibility
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
try {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
const tagSet = new Set<string>();
|
||||
|
||||
@@ -613,24 +24,9 @@ export async function fetchAllTags(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches media by actor name
|
||||
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
|
||||
try {
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
return media.filter(item =>
|
||||
item.staff?.some(staffMember =>
|
||||
staffMember.name.toLowerCase().includes(actorName.toLowerCase())
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by actor:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches media by tag
|
||||
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
||||
export async function fetchMediaByTag(tag: string) {
|
||||
try {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
return media.filter(item =>
|
||||
item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) ||
|
||||
@@ -642,154 +38,12 @@ export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function - fetch media from API (legacy compatibility)
|
||||
export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> {
|
||||
export async function fetchMediaFromApi(apiUrl?: string) {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
// Convenience function - fetch media from local JSON (legacy compatibility)
|
||||
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
|
||||
export async function fetchMediaFromLocalJson() {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
// Settings API Types
|
||||
export interface ApiSettingsItem {
|
||||
id?: number;
|
||||
enabled_categories: string[];
|
||||
items_per_page: number;
|
||||
grid_item_size?: number;
|
||||
default_view: string;
|
||||
show_adult_content: boolean;
|
||||
auto_play_trailers: boolean;
|
||||
language: string;
|
||||
theme: string;
|
||||
jellyfin_library_mappings?: string; // JSON string of LibraryMapping[]
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateSettingsInput {
|
||||
enabled_categories: string[];
|
||||
items_per_page?: number;
|
||||
grid_item_size?: number;
|
||||
default_view?: string;
|
||||
show_adult_content?: boolean;
|
||||
auto_play_trailers?: boolean;
|
||||
language?: string;
|
||||
theme?: string;
|
||||
jellyfin_library_mappings?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
|
||||
|
||||
export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
|
||||
return {
|
||||
id: apiItem.id,
|
||||
enabledCategories: apiItem.enabled_categories as MediaCategory[],
|
||||
itemsPerPage: apiItem.items_per_page || 20,
|
||||
gridItemSize: apiItem.grid_item_size || 5,
|
||||
defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid',
|
||||
showAdultContent: apiItem.show_adult_content || false,
|
||||
autoPlayTrailers: apiItem.auto_play_trailers || false,
|
||||
language: apiItem.language || 'en',
|
||||
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
|
||||
jellyfinLibraryMappings: apiItem.jellyfin_library_mappings,
|
||||
createdAt: apiItem.created_at,
|
||||
updatedAt: apiItem.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function convertSettingsToApi(settings: UserSettings): CreateSettingsInput {
|
||||
return {
|
||||
enabled_categories: settings.enabledCategories,
|
||||
items_per_page: settings.itemsPerPage,
|
||||
grid_item_size: settings.gridItemSize,
|
||||
default_view: settings.defaultView,
|
||||
show_adult_content: settings.showAdultContent,
|
||||
auto_play_trailers: settings.autoPlayTrailers,
|
||||
language: settings.language,
|
||||
theme: settings.theme,
|
||||
jellyfin_library_mappings: settings.jellyfinLibraryMappings,
|
||||
};
|
||||
}
|
||||
|
||||
// Settings API Functions
|
||||
export async function fetchSettings(): Promise<UserSettings | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/settings`);
|
||||
if (!response.ok) {
|
||||
// If settings don't exist (404), return null to use defaults
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||
try {
|
||||
const apiSettings = convertSettingsToApi(settings);
|
||||
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(apiSettings),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Create settings error response:', errorText);
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||
try {
|
||||
const apiSettings = convertSettingsToApi(settings);
|
||||
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(apiSettings),
|
||||
});
|
||||
if (!response.ok) {
|
||||
// If settings don't exist (404), try creating them instead
|
||||
if (response.status === 404) {
|
||||
return createSettings(settings);
|
||||
}
|
||||
const errorText = await response.text();
|
||||
console.error('Update settings error response:', errorText);
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+366
-286
@@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createMedia, type CreateMediaInput } from '@/api';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, Film, Calendar, Star, User, BookOpen, Music as MusicIcon, Gamepad2, Monitor, Hash, Tag, Users, FileText, Globe, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AddMediaViewProps {
|
||||
@@ -180,82 +180,112 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: MediaCategory) => {
|
||||
const icons: Record<MediaCategory, any> = {
|
||||
'Anime': <Film size={18} />,
|
||||
'Movies': <Film size={18} />,
|
||||
'TV Series': <Film size={18} />,
|
||||
'Music': <MusicIcon size={18} />,
|
||||
'Books': <BookOpen size={18} />,
|
||||
'Games': <Gamepad2 size={18} />,
|
||||
'Consoles': <Monitor size={18} />,
|
||||
'Adult': <Star size={18} />
|
||||
};
|
||||
return icons[category] || <Film size={18} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
|
||||
<div className="pt-24 pb-12 px-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/')}
|
||||
className="mb-6 gap-2 text-muted-foreground hover:text-foreground"
|
||||
className="mb-6 gap-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-xl transition-all duration-300"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Back to Browse
|
||||
</Button>
|
||||
|
||||
<div className="bg-card rounded-3xl shadow-xl p-8 border border-border">
|
||||
<h1 className="text-3xl font-black text-foreground mb-2">Add New Media</h1>
|
||||
<p className="text-muted-foreground font-medium mb-8">
|
||||
Add a new item to your {activeCategory} library.
|
||||
</p>
|
||||
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center shadow-lg shadow-[#e8466c]/30">
|
||||
{getCategoryIcon(activeCategory)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-foreground mb-1">Add New Media</h1>
|
||||
<p className="text-muted-foreground font-medium text-lg">
|
||||
Add a new item to your {activeCategory} library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitStatus === 'success' && (
|
||||
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl">
|
||||
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl backdrop-blur-sm">
|
||||
<p className="text-green-500 font-bold">✓ Successfully added to library!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitStatus === 'error' && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl">
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl backdrop-blur-sm">
|
||||
<p className="text-red-500 font-bold">✗ Error: {errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddSubmit} className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-sm font-black text-foreground">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newMedia.title}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="e.g. Mob Psycho 100"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="year" className="text-sm font-black text-foreground">Year</Label>
|
||||
<Input
|
||||
id="year"
|
||||
value={newMedia.year}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||
placeholder="2024"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
<form onSubmit={handleAddSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Info Card */}
|
||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category" className="text-sm font-black text-foreground">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={newMedia.category}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{enabledCategories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type" className="text-sm font-black text-foreground">Type</Label>
|
||||
<select
|
||||
id="type"
|
||||
value={newMedia.type}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{newMedia.category === 'Music' ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newMedia.title}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="e.g. Mob Psycho 100"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="year" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Year</Label>
|
||||
<Input
|
||||
id="year"
|
||||
value={newMedia.year}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||
placeholder="2024"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={newMedia.category}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/50 outline-none"
|
||||
>
|
||||
{enabledCategories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Type</Label>
|
||||
<select
|
||||
id="type"
|
||||
value={newMedia.type}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/50 outline-none"
|
||||
>
|
||||
{newMedia.category === 'Music' ? (
|
||||
<>
|
||||
<option value="Album">Album</option>
|
||||
<option value="Single">Single</option>
|
||||
@@ -284,287 +314,337 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
<option value="Movie">Movie</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status" className="text-sm font-black text-foreground">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
value={newMedia.status}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="Released">Released</option>
|
||||
<option value="Ongoing">Ongoing</option>
|
||||
<option value="Upcoming">Upcoming</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Watching">Watching</option>
|
||||
<option value="Reading">Reading</option>
|
||||
<option value="Listening">Listening</option>
|
||||
<option value="Playing">Playing</option>
|
||||
<option value="Dropped">Dropped</option>
|
||||
<option value="On Hold">On Hold</option>
|
||||
</select>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
value={newMedia.status}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/50 outline-none"
|
||||
>
|
||||
<option value="Released">Released</option>
|
||||
<option value="Ongoing">Ongoing</option>
|
||||
<option value="Upcoming">Upcoming</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Watching">Watching</option>
|
||||
<option value="Reading">Reading</option>
|
||||
<option value="Listening">Listening</option>
|
||||
<option value="Playing">Playing</option>
|
||||
<option value="Dropped">Dropped</option>
|
||||
<option value="On Hold">On Hold</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-foreground">Aspect Ratio (Format)</Label>
|
||||
<select
|
||||
id="aspectRatio"
|
||||
value={newMedia.aspectRatio}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="2/3">2:3 (Standard Poster)</option>
|
||||
<option value="16/9">16:9 (Wide Thumbnail)</option>
|
||||
<option value="1/1">1:1 (Square)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="poster" className="text-sm font-black text-foreground">Poster URL</Label>
|
||||
<Input
|
||||
id="poster"
|
||||
value={newMedia.poster}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="banner" className="text-sm font-black text-foreground">Banner URL (Optional)</Label>
|
||||
<Input
|
||||
id="banner"
|
||||
value={newMedia.banner}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description" className="text-sm font-black text-foreground">Description (Optional)</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={newMedia.description}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Brief description..."
|
||||
className="bg-muted border-border rounded-xl p-3 h-20 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rating" className="text-sm font-black text-foreground">Rating (Optional)</Label>
|
||||
<Input
|
||||
id="rating"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="10"
|
||||
value={newMedia.rating}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
|
||||
placeholder="8.5"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||
<>
|
||||
|
||||
{/* Media Info Card */}
|
||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||
<Globe size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Media Information</h3>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="poster" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Poster URL</Label>
|
||||
<Input
|
||||
id="poster"
|
||||
value={newMedia.poster}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="banner" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Banner URL (optional)</Label>
|
||||
<Input
|
||||
id="banner"
|
||||
value={newMedia.banner}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="runtime" className="text-sm font-black text-foreground">Runtime (min)</Label>
|
||||
<Label htmlFor="aspectRatio" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Aspect Ratio</Label>
|
||||
<select
|
||||
id="aspectRatio"
|
||||
value={newMedia.aspectRatio}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/50 outline-none"
|
||||
>
|
||||
<option value="2/3">2:3 (Poster)</option>
|
||||
<option value="16/9">16:9 (Banner)</option>
|
||||
<option value="1/1">1:1 (Square)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rating" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Rating (0-10)</Label>
|
||||
<Input
|
||||
id="rating"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={newMedia.rating}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
|
||||
placeholder="8.5"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={newMedia.description}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Enter a description..."
|
||||
rows={4}
|
||||
className="bg-background border-border/50 rounded-xl p-3 text-sm focus:ring-2 focus:ring-[#e8466c]/50 outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Production Details Card - for Movies/TV/Anime */}
|
||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||
<Clock size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Production Details</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="runtime" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Runtime (minutes)</Label>
|
||||
<Input
|
||||
id="runtime"
|
||||
type="number"
|
||||
value={newMedia.runtime}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
|
||||
placeholder="120"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="releaseDate" className="text-sm font-black text-foreground">Release Date</Label>
|
||||
<Label htmlFor="releaseDate" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Release Date</Label>
|
||||
<Input
|
||||
id="releaseDate"
|
||||
type="date"
|
||||
value={newMedia.releaseDate}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="director" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Director</Label>
|
||||
<Input
|
||||
id="director"
|
||||
value={newMedia.director}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
||||
placeholder="Director name"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="writer" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Writer</Label>
|
||||
<Input
|
||||
id="writer"
|
||||
value={newMedia.writer}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
||||
placeholder="Writer name"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="director" className="text-sm font-black text-foreground">Director</Label>
|
||||
<Input
|
||||
id="director"
|
||||
value={newMedia.director}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
||||
placeholder="Director name"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="writer" className="text-sm font-black text-foreground">Writer</Label>
|
||||
<Input
|
||||
id="writer"
|
||||
value={newMedia.writer}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
||||
placeholder="Writer name"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="genres" className="text-sm font-black text-foreground">Genres (comma-separated)</Label>
|
||||
<Input
|
||||
id="genres"
|
||||
value={newMedia.genres}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
||||
placeholder="Action, Drama, Sci-Fi"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="tags" className="text-sm font-black text-foreground">Tags (comma-separated)</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={newMedia.tags}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
||||
placeholder="Classic, Best-selling"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="studios" className="text-sm font-black text-foreground">Studios (comma-separated)</Label>
|
||||
<Input
|
||||
id="studios"
|
||||
value={newMedia.studios}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
||||
placeholder="Studio A, Studio B"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="source" className="text-sm font-black text-foreground">Source / Quelle</Label>
|
||||
<Input
|
||||
id="source"
|
||||
value={newMedia.source}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
|
||||
placeholder="e.g. username, xbvr, stashapp"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
{/* Classification Card */}
|
||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||
<Tag size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Classification</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="genres" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Genres (comma-separated)</Label>
|
||||
<Input
|
||||
id="genres"
|
||||
value={newMedia.genres}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
||||
placeholder="Action, Drama, Sci-Fi"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="tags" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Tags (comma-separated)</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={newMedia.tags}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
||||
placeholder="Classic, Best-selling"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="studios" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Studios (comma-separated)</Label>
|
||||
<Input
|
||||
id="studios"
|
||||
value={newMedia.studios}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
||||
placeholder="Studio A, Studio B"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="source" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Source / Quelle</Label>
|
||||
<Input
|
||||
id="source"
|
||||
value={newMedia.source}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
|
||||
placeholder="e.g. username, xbvr, stashapp"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cast/Staff Section */}
|
||||
{/* Cast/Staff Card */}
|
||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-black text-foreground">Cast & Crew</Label>
|
||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50 lg:col-span-2">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Cast & Crew</h3>
|
||||
</div>
|
||||
|
||||
{/* Staff List */}
|
||||
{staff.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Staff List */}
|
||||
<div className="space-y-2">
|
||||
{staff.map((member, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-muted/50 rounded-xl border border-border">
|
||||
{member.photo && (
|
||||
<img
|
||||
src={member.photo}
|
||||
alt={member.name}
|
||||
className="w-12 h-12 rounded-lg object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-foreground truncate">{member.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.role}{member.characterName ? ` as ${member.characterName}` : ''}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setStaff(prev => prev.filter((_, i) => i !== index))}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
{staff.length > 0 && (
|
||||
<>
|
||||
{staff.map((member, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-background rounded-xl border border-border/50">
|
||||
{member.photo && (
|
||||
<img
|
||||
src={member.photo}
|
||||
alt={member.name}
|
||||
className="w-12 h-12 rounded-xl object-cover border border-border/30"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-foreground truncate">{member.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.role}{member.characterName ? ` as ${member.characterName}` : ''}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setStaff(prev => prev.filter((_, i) => i !== index))}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-xl"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{staff.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
No cast members added yet
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Staff Form */}
|
||||
<div className="grid gap-3 p-4 bg-muted/30 rounded-xl border border-border">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffName" className="text-xs font-black text-foreground">Name</Label>
|
||||
<Input
|
||||
id="staffName"
|
||||
placeholder="Actor name"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const input = e.target as HTMLInputElement;
|
||||
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
|
||||
if (input.value && roleInput?.value) {
|
||||
addStaffMember();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Add Staff Form */}
|
||||
<div className="grid gap-3 p-4 bg-background rounded-xl border border-border/50">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffRole" className="text-xs font-black text-foreground">Role</Label>
|
||||
<Label htmlFor="staffName" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Name</Label>
|
||||
<Input
|
||||
id="staffRole"
|
||||
placeholder="e.g. Actor, Director"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
id="staffName"
|
||||
placeholder="Actor name"
|
||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const input = e.target as HTMLInputElement;
|
||||
const nameInput = document.getElementById('staffName') as HTMLInputElement;
|
||||
if (input.value && nameInput?.value) {
|
||||
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
|
||||
if (input.value && roleInput?.value) {
|
||||
addStaffMember();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffRole" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Role</Label>
|
||||
<Input
|
||||
id="staffRole"
|
||||
placeholder="e.g. Actor, Director"
|
||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const input = e.target as HTMLInputElement;
|
||||
const nameInput = document.getElementById('staffName') as HTMLInputElement;
|
||||
if (input.value && nameInput?.value) {
|
||||
addStaffMember();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffCharacter" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Character (optional)</Label>
|
||||
<Input
|
||||
id="staffCharacter"
|
||||
placeholder="Character name"
|
||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffCharacter" className="text-xs font-black text-foreground">Character (optional)</Label>
|
||||
<Label htmlFor="staffPhoto" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Photo URL (optional)</Label>
|
||||
<Input
|
||||
id="staffCharacter"
|
||||
placeholder="Character name"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
id="staffPhoto"
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStaffMember}
|
||||
variant="outline"
|
||||
className="w-full border-border/50 text-sm font-bold hover:border-[#e8466c]/50 hover:bg-[#e8466c]/10 rounded-xl transition-all duration-300"
|
||||
>
|
||||
+ Add Cast Member
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffPhoto" className="text-xs font-black text-foreground">Photo URL (optional)</Label>
|
||||
<Input
|
||||
id="staffPhoto"
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStaffMember}
|
||||
variant="outline"
|
||||
className="w-full border-border text-sm font-bold"
|
||||
>
|
||||
+ Add Cast Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
||||
</Button>
|
||||
{/* Submit Button - Full Width */}
|
||||
<div className="lg:col-span-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-gradient-to-br from-[#e8466c] to-[#f47298] hover:from-[#d13d60] hover:to-[#c5304e] text-white font-black h-12 rounded-xl shadow-lg shadow-[#e8466c]/30 transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+358
-289
@@ -1,18 +1,22 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { Media, MediaCategory, Staff } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import MediaListItem from './MediaListItem';
|
||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react';
|
||||
import MediaTable from './MediaTable';
|
||||
import MediaFilters from './filters/MediaFilters';
|
||||
import { LayoutGrid, List, User, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import Loading from '@/components/ui/loading';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination';
|
||||
|
||||
interface BrowseViewProps {
|
||||
mediaList: Media[];
|
||||
@@ -22,13 +26,26 @@ interface BrowseViewProps {
|
||||
gridItemSize?: number;
|
||||
onGridItemSizeChange?: (size: number) => void;
|
||||
loading?: boolean;
|
||||
searchResultsCast?: Staff[];
|
||||
onCastClick?: (person: Staff) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12, gridItemSize: initialGridItemSize = 5, onGridItemSizeChange, loading = false }: BrowseViewProps) {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
export default function BrowseView({
|
||||
mediaList,
|
||||
onMediaClick,
|
||||
activeCategory,
|
||||
itemsPerPage: initialItemsPerPage = 12,
|
||||
gridItemSize: initialGridItemSize = 5,
|
||||
onGridItemSizeChange,
|
||||
loading = false,
|
||||
searchResultsCast = [],
|
||||
onCastClick,
|
||||
searchQuery = ''
|
||||
}: BrowseViewProps) {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||
const [sortBy, setSortBy] = useState<string>('default');
|
||||
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
|
||||
|
||||
// Sync itemsPerPage with prop when API settings are loaded
|
||||
@@ -53,21 +70,13 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
|
||||
// Extract unique values for filters
|
||||
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
||||
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
|
||||
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
|
||||
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
|
||||
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]);
|
||||
const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
|
||||
|
||||
const filteredMedia = useMemo(() => {
|
||||
return mediaList.filter(media => {
|
||||
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
|
||||
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
|
||||
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
|
||||
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
|
||||
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
|
||||
if (selectedCategory && !media.series?.includes(selectedCategory)) return false;
|
||||
if (selectedSource && media.source !== selectedSource) return false;
|
||||
return true;
|
||||
});
|
||||
@@ -76,21 +85,9 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
// Reset to first page when mediaList or filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filteredMedia, sortBy]);
|
||||
|
||||
const sortedMedia = useMemo(() => {
|
||||
const list = [...filteredMedia];
|
||||
if (sortBy === 'title-asc') {
|
||||
return list.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
if (sortBy === 'title-desc') {
|
||||
return list.sort((a, b) => b.title.localeCompare(a.title));
|
||||
}
|
||||
return list;
|
||||
}, [filteredMedia, sortBy]);
|
||||
}, [filteredMedia]);
|
||||
|
||||
const gridColsClass = useMemo(() => {
|
||||
// Map slider value (1-10) to grid columns
|
||||
const colsMap: Record<number, string> = {
|
||||
1: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
2: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
@@ -106,294 +103,366 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
return `grid ${colsMap[gridItemSize] || colsMap[5]}`;
|
||||
}, [gridItemSize]);
|
||||
|
||||
const totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
|
||||
const totalPages = Math.ceil(filteredMedia.length / itemsPerPage);
|
||||
|
||||
const paginatedMedia = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
return sortedMedia.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [sortedMedia, currentPage, itemsPerPage]);
|
||||
return filteredMedia.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredMedia, currentPage, itemsPerPage]);
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setCurrentPage((prev) => Math.max(prev - 1, 1));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
const handleClearAll = () => {
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
setSelectedPlatform(null);
|
||||
setSelectedDeveloper(null);
|
||||
setSelectedCategory(null);
|
||||
setSelectedSource(null);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
const scrollContainer = document.getElementById('browse-scroll-container');
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
// Generate pagination items with ellipsis
|
||||
const getPaginationItems = () => {
|
||||
const items: (number | string)[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
items.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
items.push('ellipsis-start');
|
||||
}
|
||||
|
||||
// Show pages around current
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 2) {
|
||||
items.push('ellipsis-end');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
if (totalPages > 1) {
|
||||
items.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// Calculate favorite IDs
|
||||
const favoriteIds = useMemo(() => {
|
||||
return new Set(mediaList.filter(m => m.rating && m.rating >= 8).map(m => m.id));
|
||||
}, [mediaList]);
|
||||
|
||||
// Check if we have search results
|
||||
const hasSearchResults = searchQuery.trim().length > 0;
|
||||
const hasCastResults = searchResultsCast.length > 0;
|
||||
const hasMediaResults = mediaList.length > 0;
|
||||
|
||||
// Pagination for cast results (show first 12)
|
||||
const paginatedCast = useMemo(() => {
|
||||
return searchResultsCast.slice(0, itemsPerPage);
|
||||
}, [searchResultsCast, itemsPerPage]);
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Genre Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<Star size={16} />
|
||||
{selectedGenre || 'Genres'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
|
||||
{allGenres.sort().map(genre => (
|
||||
<DropdownMenuItem key={genre} onClick={() => setSelectedGenre(genre)}>{genre}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex flex-col h-[calc(100vh-4rem-4rem)] w-full">
|
||||
{/* Sticky Header - Filter + Results Summary + Count */}
|
||||
<div className="px-6 pt-4 pb-4 bg-background border-b border-white/10 shrink-0 z-10">
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||
<MediaFilters
|
||||
mediaList={mediaList}
|
||||
activeCategory={activeCategory}
|
||||
selectedGenre={selectedGenre}
|
||||
selectedStudio={selectedStudio}
|
||||
selectedPlatform={selectedPlatform}
|
||||
selectedDeveloper={selectedDeveloper}
|
||||
selectedCategory={selectedCategory}
|
||||
selectedSource={selectedSource}
|
||||
onGenreChange={setSelectedGenre}
|
||||
onStudioChange={setSelectedStudio}
|
||||
onPlatformChange={setSelectedPlatform}
|
||||
onDeveloperChange={setSelectedDeveloper}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
onSourceChange={setSelectedSource}
|
||||
onClearAll={handleClearAll}
|
||||
/>
|
||||
|
||||
{/* Studio Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
Studios
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
|
||||
{allStudios.sort().map(studio => (
|
||||
<DropdownMenuItem key={studio} onClick={() => setSelectedStudio(studio)}>{studio}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Grid item size slider - only show in grid mode */}
|
||||
{viewMode === 'grid' && (
|
||||
<div className="flex items-center gap-3 bg-[#1a1d26] rounded-xl px-4 py-2 border border-white/10">
|
||||
<span className="text-xs font-bold text-gray-500">Size</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={gridItemSize}
|
||||
onChange={(e) => {
|
||||
const newSize = Number(e.target.value);
|
||||
setGridItemSize(newSize);
|
||||
onGridItemSizeChange?.(newSize);
|
||||
}}
|
||||
className="w-24 h-2 bg-[#0d0f14] rounded-lg appearance-none cursor-pointer accent-[#e8466c]"
|
||||
/>
|
||||
<span className="text-xs font-bold text-[#e8466c] w-5 text-center">{gridItemSize}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Platform Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<Monitor size={16} />
|
||||
{selectedPlatform || 'Platforms'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedPlatform(null)}>All Platforms</DropdownMenuItem>
|
||||
{allPlatforms.sort().map(platform => (
|
||||
<DropdownMenuItem key={platform} onClick={() => setSelectedPlatform(platform)}>{platform}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Developer Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<Users size={16} />
|
||||
{selectedDeveloper || 'Developers'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedDeveloper(null)}>All Developers</DropdownMenuItem>
|
||||
{allDevelopers.sort().map(developer => (
|
||||
<DropdownMenuItem key={developer} onClick={() => setSelectedDeveloper(developer)}>{developer}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Category Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<FolderTree size={16} />
|
||||
{selectedCategory || 'Categories'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>All Categories</DropdownMenuItem>
|
||||
{allCategories.sort().map(category => (
|
||||
<DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Source Filter */}
|
||||
{allSources.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<Tag size={16} />
|
||||
{selectedSource || 'Source'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedSource(null)}>All Sources</DropdownMenuItem>
|
||||
{allSources.sort().map(source => (
|
||||
<DropdownMenuItem key={source} onClick={() => setSelectedSource(source)}>{source}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory || selectedSource) && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-muted-foreground font-bold"
|
||||
onClick={() => {
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
setSelectedPlatform(null);
|
||||
setSelectedDeveloper(null);
|
||||
setSelectedCategory(null);
|
||||
setSelectedSource(null);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
{/* View Toggle */}
|
||||
<div className="flex items-center bg-[#1a1d26] rounded-xl p-1 border border-white/10">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all rounded-lg",
|
||||
viewMode === 'grid' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all rounded-lg",
|
||||
viewMode === 'list' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Grid item size slider */}
|
||||
<div className="flex items-center gap-3 bg-muted rounded-md px-3 py-2">
|
||||
<span className="text-xs font-bold text-muted-foreground">Size</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={gridItemSize}
|
||||
onChange={(e) => {
|
||||
const newSize = Number(e.target.value);
|
||||
setGridItemSize(newSize);
|
||||
onGridItemSizeChange?.(newSize);
|
||||
}}
|
||||
className="w-24 h-2 bg-background rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
|
||||
/>
|
||||
<span className="text-xs font-bold text-[#6d28d9] w-5 text-center">{gridItemSize}</span>
|
||||
{/* Search Results Summary */}
|
||||
{hasSearchResults && (
|
||||
<div className="flex items-center gap-4 mb-4 p-3 bg-[#1a1d26] rounded-lg border border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-400">Search results for:</span>
|
||||
<Badge variant="secondary" className="bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30">
|
||||
"{searchQuery}"
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
{hasMediaResults && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<LayoutGrid size={14} />
|
||||
<span>{mediaList.length} media</span>
|
||||
</div>
|
||||
)}
|
||||
{hasCastResults && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<Users size={14} />
|
||||
<span>{searchResultsCast.length} cast</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-muted-foreground font-bold gap-2">
|
||||
<ArrowUpDown size={16} />
|
||||
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('title-asc')}>Title (A-Z)</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('title-desc')}>Title (Z-A)</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center bg-muted rounded-md p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all",
|
||||
viewMode === 'grid' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all",
|
||||
viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Results Count */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {paginatedMedia.length} of {filteredMedia.length} results
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{/* Scrollable Content Area */}
|
||||
<div id="browse-scroll-container" className="flex-1 overflow-y-auto px-6 pt-4 pb-20">
|
||||
{/* Cast Search Results */}
|
||||
{hasSearchResults && hasCastResults && onCastClick && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Users size={18} className="text-[#e8466c]" />
|
||||
<h3 className="text-lg font-bold text-white">Cast Results</h3>
|
||||
<Badge variant="secondary" className="bg-[#1a1d26] text-gray-400">
|
||||
{searchResultsCast.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
|
||||
{paginatedCast.map((person) => (
|
||||
<div
|
||||
key={person.id}
|
||||
onClick={() => onCastClick(person)}
|
||||
className="group cursor-pointer bg-[#1a1d26] rounded-lg p-3 border border-white/10 hover:border-[#e8466c]/50 transition-all duration-300 hover:bg-[#1f232c]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg overflow-hidden bg-[#0d0f14] shrink-0">
|
||||
{person.photo ? (
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<User size={20} className="text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate group-hover:text-[#e8466c] transition-colors">
|
||||
{person.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{person.role}</p>
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{person.filmography.length} role{person.filmography.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{searchResultsCast.length > itemsPerPage && (
|
||||
<p className="text-xs text-gray-500 mt-3 text-center">
|
||||
+{searchResultsCast.length - itemsPerPage} more cast members
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content - inside scrollable area */}
|
||||
{loading ? (
|
||||
<Loading message="Loading media..." />
|
||||
) : mediaList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<Search size={32} />
|
||||
) : mediaList.length === 0 && !hasCastResults ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-500">
|
||||
<div className="w-16 h-16 bg-[#1a1d26] rounded-full flex items-center justify-center mb-4">
|
||||
<span className="text-2xl">📁</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold">No results found</p>
|
||||
<p className="text-lg font-bold text-gray-300">No results found</p>
|
||||
<p className="text-sm">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
) : mediaList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<p className="text-sm">No media results found for this search</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? cn(gridColsClass, "gap-x-4 gap-y-8")
|
||||
: "flex flex-col gap-2"
|
||||
)}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedMedia.map((media) => (
|
||||
viewMode === 'grid' ? (
|
||||
<>
|
||||
{hasSearchResults && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<LayoutGrid size={18} className="text-[#e8466c]" />
|
||||
<h3 className="text-lg font-bold text-white">Media Results</h3>
|
||||
<Badge variant="secondary" className="bg-[#1a1d26] text-gray-400">
|
||||
{mediaList.length}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' ? (
|
||||
<MediaTable
|
||||
mediaList={paginatedMedia}
|
||||
onMediaClick={onMediaClick}
|
||||
favoriteIds={favoriteIds}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(gridColsClass, "gap-x-4 gap-y-8")}>
|
||||
{paginatedMedia.map((media) => (
|
||||
<MediaCard
|
||||
key={media.id}
|
||||
media={media}
|
||||
onClick={onMediaClick}
|
||||
showBadge={true}
|
||||
showFavorite={true}
|
||||
/>
|
||||
) : (
|
||||
<MediaListItem
|
||||
key={media.id}
|
||||
media={media}
|
||||
onClick={onMediaClick}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{mediaList.length > 0 && (
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border pt-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground font-medium">Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="bg-muted border-none rounded-md px-2 py-1 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{[12, 20, 36, 48, 60].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* End of scrollable content area */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
|
||||
<span className="text-sm text-muted-foreground font-medium">of</span>
|
||||
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
|
||||
{/* Sticky Pagination Controls */}
|
||||
{filteredMedia.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500 font-medium">Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="bg-[#1a1d26] border border-white/10 rounded-md px-2 py-1 text-sm font-medium text-gray-300 focus:ring-2 focus:ring-[#e8466c] outline-none"
|
||||
>
|
||||
{[12, 20, 36, 48, 60, 100].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
|
||||
className={cn(
|
||||
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||
currentPage === 1 && "pointer-events-none opacity-50"
|
||||
)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPaginationItems().map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{item === 'ellipsis-start' || item === 'ellipsis-end' ? (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
isActive={currentPage === item}
|
||||
onClick={() => handlePageChange(item as number)}
|
||||
className={cn(
|
||||
"border-white/10",
|
||||
currentPage === item
|
||||
? "bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30"
|
||||
: "bg-transparent text-gray-300 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
|
||||
className={cn(
|
||||
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||
(currentPage === totalPages || totalPages === 0) && "pointer-events-none opacity-50"
|
||||
)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+395
-273
@@ -1,10 +1,25 @@
|
||||
import { Staff, Media } from '@/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'motion/react';
|
||||
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye, ChevronDown, ListFilter } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye,
|
||||
BookOpen, Theater, ArrowUpAZ, ArrowDownAZ, ArrowUpDown, Star
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CastDetailViewProps {
|
||||
person: Staff;
|
||||
@@ -31,51 +46,64 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
// Sort options
|
||||
const sortOptions = [
|
||||
{ value: 'year', label: 'Year', icon: Calendar },
|
||||
{ value: 'title', label: 'Title', icon: ArrowUpAZ },
|
||||
{ value: 'role', label: 'Role', icon: Briefcase },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pb-20">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-[40vh] md:h-[50vh] overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
src={person.photo}
|
||||
<div className="min-h-screen bg-background pb-16">
|
||||
{/* Compact Hero Section */}
|
||||
<div className="relative h-[35vh] md:h-[40vh] overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="w-full h-full object-cover opacity-40 blur-xl scale-110"
|
||||
className="w-full h-full object-cover opacity-30 blur-xl scale-110"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent" />
|
||||
|
||||
<div className="absolute inset-0 flex items-end px-6 pb-12">
|
||||
<div className="max-w-[1200px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-8">
|
||||
<motion.div
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" />
|
||||
|
||||
<div className="absolute inset-0 flex items-end px-4 sm:px-6 pb-8">
|
||||
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="h-48 md:h-64 rounded-2xl overflow-hidden border-4 border-background shadow-2xl shrink-0"
|
||||
className="shrink-0"
|
||||
>
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<Avatar className="h-32 md:h-40 w-auto aspect-[3/4] rounded-none border-3 border-background shadow-2xl">
|
||||
<AvatarImage
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<AvatarFallback className="rounded-none text-3xl">
|
||||
<User className="h-12 w-12" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-1 text-center md:text-left pb-4">
|
||||
<div className="flex-1 text-center md:text-left pb-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-foreground mb-4 drop-shadow-sm">
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-3 tracking-tight">
|
||||
{person.name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-3">
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-2">
|
||||
{person.occupations?.map(occ => (
|
||||
<Badge key={occ} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] border-none font-bold px-4 py-1">
|
||||
<Badge key={occ} variant="secondary" className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 font-medium px-3 py-1 text-xs">
|
||||
{occ}
|
||||
</Badge>
|
||||
))}
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold px-4 py-1">
|
||||
{person.filmography.length} Role{person.filmography.length !== 1 ? 's' : ''}
|
||||
<Badge variant="outline" className="border-[#e8466c]/30 text-[#e8466c] font-medium px-3 py-1 text-xs">
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
{person.filmography.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -84,289 +112,383 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute top-24 left-6 bg-white/20 hover:bg-white/40 text-white rounded-full backdrop-blur-md"
|
||||
className="absolute top-20 left-4 sm:left-6 bg-white/20 hover:bg-white/40 text-white rounded-xl backdrop-blur-md transition-all duration-300 hover:scale-105 border border-white/20 h-10 w-10"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="max-w-[1200px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* Sidebar Info */}
|
||||
<div className="space-y-8">
|
||||
<div className="bg-muted/50 rounded-3xl p-8 space-y-6 border border-border">
|
||||
<h3 className="text-xl font-black text-foreground">Personal Info</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Calendar size={20} />
|
||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 mt-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar Info - Modern shadcn Design */}
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
{/* Personal Info Card */}
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-[#e8466c]/10 flex items-center justify-center">
|
||||
<User size={12} className="text-[#e8466c]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Date</p>
|
||||
<p className="font-bold text-foreground">{person.birthDate || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<MapPin size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Place</p>
|
||||
<p className="font-bold text-foreground">{person.birthPlace || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Briefcase size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Known For</p>
|
||||
<p className="font-bold text-foreground">{person.role}</p>
|
||||
Personal Info
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{/* Birth Date */}
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
|
||||
<Calendar size={14} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Born</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{person.birthDate || '—'}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Birth Place */}
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
|
||||
<MapPin size={14} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Origin</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium truncate max-w-[140px]" title={person.birthPlace || undefined}>
|
||||
{person.birthPlace || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Known For */}
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
|
||||
<Briefcase size={14} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Role</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs font-normal bg-[#e8466c]/10 text-[#e8466c] border-none">
|
||||
{person.role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Ethnicity - only if present */}
|
||||
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<User size={20} />
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
|
||||
<User size={14} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Ethnicity</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium truncate max-w-[140px]">
|
||||
{person.adult_specifics?.ethnicity || person.ethnicity}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Ethnicity</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="bg-muted/50 rounded-3xl p-8 space-y-6 border border-border">
|
||||
<h3 className="text-xl font-black text-foreground">Measurements</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Ruler size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Height</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.height || person.height} cm</p>
|
||||
</div>
|
||||
{/* Measurements Card - Only if data exists */}
|
||||
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight ||
|
||||
person.adult_specifics?.measurements || person.bust_size || person.hair_color || person.adult_specifics?.hair_color) && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-[#e8466c]/10 flex items-center justify-center">
|
||||
<Ruler size={12} className="text-[#e8466c]" />
|
||||
</div>
|
||||
|
||||
|
||||
{(person.weight || person.adult_specifics?.weight) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Ruler size={20} />
|
||||
Measurements
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{/* Height & Weight Grid */}
|
||||
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight) && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 divide-x divide-border">
|
||||
{(person.adult_specifics?.height || person.height) && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Height</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{person.adult_specifics?.height || person.height}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">cm</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(person.adult_specifics?.weight || person.weight) && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Weight</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{person.adult_specifics?.weight || person.weight}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">kg</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Weight</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.weight || person.weight} kg</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Measurements (Bust-Waist-Hip) */}
|
||||
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Ruler size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Measurements</p>
|
||||
<p className="font-bold text-foreground">
|
||||
<>
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1.5">Figure</p>
|
||||
<p className="text-sm font-medium font-mono tracking-wide">
|
||||
{person.adult_specifics?.measurements || (
|
||||
<>
|
||||
{person.bust_size && `${person.bust_size}`}
|
||||
{person.cup_size && person.cup_size}
|
||||
{person.bust_size || person.cup_size ? '-' : ''}
|
||||
{person.waist_size && `${person.waist_size}`}
|
||||
{person.waist_size ? '-' : ''}
|
||||
{person.hip_size && `${person.hip_size}`}
|
||||
{person.bust_size && <span className="inline-flex items-center gap-0.5">{person.bust_size}{person.cup_size && <span className="text-xs text-muted-foreground">{person.cup_size}</span>}</span>}
|
||||
{(person.bust_size || person.cup_size) && person.waist_size && <span className="text-muted-foreground mx-1">—</span>}
|
||||
{person.waist_size && <span>{person.waist_size}</span>}
|
||||
{person.hip_size && <span className="text-muted-foreground mx-1">—</span>}
|
||||
{person.hip_size && <span>{person.hip_size}</span>}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(person.hair_color || person.adult_specifics?.hair_color) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Palette size={20} />
|
||||
{/* Hair & Eyes Grid */}
|
||||
<div className="grid grid-cols-2 divide-x divide-border">
|
||||
{(person.hair_color || person.adult_specifics?.hair_color) && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Palette size={12} className="text-[#e8466c]" />
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Hair</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate">
|
||||
{person.adult_specifics?.hair_color || person.hair_color}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Hair Color</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.hair_color || person.hair_color}</p>
|
||||
)}
|
||||
{(person.eye_color || person.adult_specifics?.eye_color) && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Eye size={12} className="text-[#e8466c]" />
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Eyes</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate">
|
||||
{person.adult_specifics?.eye_color || person.eye_color}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(person.eye_color || person.adult_specifics?.eye_color) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Eye size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Eye Color</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.eye_color || person.eye_color}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{person.adult_specifics?.tattoos && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Palette size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Tattoos</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics.tattoos}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{person.adult_specifics?.piercings && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<Palette size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Piercings</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics.piercings}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Bio & Roles */}
|
||||
<div className="lg:col-span-2 space-y-12">
|
||||
{person.bio && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
Biography
|
||||
</h2>
|
||||
<p className="text-foreground leading-relaxed text-lg">
|
||||
{person.bio}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<User className="text-[#6d28d9]" />
|
||||
Characters
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{person.filmography.map(item => (
|
||||
<div
|
||||
key={`${item.id}-char`}
|
||||
className="flex items-center gap-4 p-4 rounded-2xl bg-muted/50 border border-border"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-background">
|
||||
<img
|
||||
src={item.poster || person.photo}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Character</p>
|
||||
<h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4>
|
||||
<button
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left"
|
||||
>
|
||||
in {item.title}
|
||||
</button>
|
||||
{item.category && (
|
||||
<Badge variant="secondary" className="text-[10px] font-bold mt-2 bg-muted text-muted-foreground border-none">
|
||||
{item.category}
|
||||
</Badge>
|
||||
{/* Tattoos & Piercings */}
|
||||
{(person.adult_specifics?.tattoos || person.adult_specifics?.piercings) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 divide-x divide-border">
|
||||
{person.adult_specifics?.tattoos && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Tattoos</p>
|
||||
<p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.tattoos}</p>
|
||||
</div>
|
||||
)}
|
||||
{person.adult_specifics?.piercings && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Piercings</p>
|
||||
<p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.piercings}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-black text-foreground flex items-center gap-3">
|
||||
<Film className="text-[#6d28d9]" />
|
||||
Filmography
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
className="rounded-full border-border"
|
||||
>
|
||||
<ListFilter size={16} />
|
||||
</Button>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as 'year' | 'title' | 'role')}
|
||||
className="bg-muted border border-border rounded-full px-3 py-1.5 text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-[#6d28d9]"
|
||||
>
|
||||
<option value="year">Year</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="role">Role</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{sortedFilmography.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="group flex items-center gap-4 p-4 rounded-2xl bg-card border border-border hover:border-[#6d28d9]/30 hover:shadow-lg transition-all cursor-pointer"
|
||||
>
|
||||
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 shadow-sm">
|
||||
<img
|
||||
src={item.poster || person.photo}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1">
|
||||
{item.year || 'Unknown'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-border">
|
||||
{item.role}
|
||||
</Badge>
|
||||
{item.category && (
|
||||
<Badge variant="secondary" className="text-[10px] font-bold py-0 h-5 bg-muted text-muted-foreground border-none">
|
||||
{item.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Main Bio & Roles - Wider */}
|
||||
<div className="lg:col-span-3">
|
||||
<Tabs defaultValue={person.bio ? 'bio' : 'filmography'} className="w-full">
|
||||
<TabsList className="mb-4 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto">
|
||||
{person.bio && (
|
||||
<TabsTrigger value="bio" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<BookOpen size={14} />
|
||||
Biography
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<>
|
||||
<TabsTrigger value="characters" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<Theater size={14} />
|
||||
Characters
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="filmography" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<Film size={14} />
|
||||
Filmography
|
||||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{person.bio && (
|
||||
<TabsContent value="bio" className="mt-0">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold">Biography</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-foreground leading-relaxed text-sm">
|
||||
{person.bio}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<>
|
||||
<TabsContent value="characters" className="mt-0">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{person.filmography.map((item, index) => (
|
||||
<motion.div
|
||||
key={`${item.id}-char`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<Card
|
||||
className="hover:border-[#e8466c]/30 hover:shadow-md transition-all duration-200 cursor-pointer group border-border/60"
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
>
|
||||
<CardContent className="p-3 flex items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
|
||||
<img
|
||||
src={item.poster || person.photo}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Character</p>
|
||||
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#e8466c] transition-colors">
|
||||
{item.characterName || item.role}
|
||||
</h4>
|
||||
<p className="text-xs text-[#e8466c] truncate">{item.title}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="filmography" className="mt-0">
|
||||
{/* Sort Toolbar */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{person.filmography.length} {person.filmography.length === 1 ? 'title' : 'titles'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 rounded-lg text-xs border-border/60"
|
||||
>
|
||||
<ArrowUpDown size={14} className="mr-1.5" />
|
||||
{sortOrder === 'asc' ? <ArrowUpAZ size={14} className="mr-1.5" /> : <ArrowDownAZ size={14} className="mr-1.5" />}
|
||||
{sortOptions.find(o => o.value === sortBy)?.label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
|
||||
Sort by
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{sortOptions.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (sortBy === option.value) {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy(option.value);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<option.icon size={14} />
|
||||
{option.label}
|
||||
</span>
|
||||
{sortBy === option.value && (
|
||||
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#e8466c]" /> : <ArrowDownAZ size={14} className="text-[#e8466c]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Filmography Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedFilmography.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<Card
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="group cursor-pointer hover:border-[#e8466c]/30 hover:shadow-md transition-all duration-200 border-border/60"
|
||||
>
|
||||
<CardContent className="p-3 flex items-center gap-3">
|
||||
<div className="w-12 h-16 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
|
||||
<img
|
||||
src={item.poster || person.photo}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#e8466c] transition-colors">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{item.year || 'Unknown'}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-border/50 font-normal">
|
||||
{item.role}
|
||||
</Badge>
|
||||
{item.category && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 bg-muted font-normal">
|
||||
{item.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+635
-255
@@ -1,10 +1,49 @@
|
||||
import { Staff, MediaCategory } from '@/types';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from 'lucide-react';
|
||||
import {
|
||||
Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter,
|
||||
LayoutGrid, Table2, Eye, Calendar, Star, ArrowUpAZ, ArrowDownAZ,
|
||||
Briefcase, Film, Users, ChevronUp, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination';
|
||||
import Loading from '@/components/ui/loading';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -30,14 +69,19 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
|
||||
});
|
||||
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
|
||||
return localStorage.getItem('castFilterOccupation') || '';
|
||||
const saved = localStorage.getItem('castFilterOccupation');
|
||||
return saved && saved !== '' ? saved : 'all';
|
||||
});
|
||||
const [filterMediaType, setFilterMediaType] = useState<string>(() => {
|
||||
return localStorage.getItem('castFilterMediaType') || '';
|
||||
const saved = localStorage.getItem('castFilterMediaType');
|
||||
return saved && saved !== '' ? saved : 'all';
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'table'>(() => {
|
||||
return (localStorage.getItem('castViewMode') as 'grid' | 'table') || 'grid';
|
||||
});
|
||||
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
||||
|
||||
// Sync itemsPerPage with prop when API settings are loaded
|
||||
useEffect(() => {
|
||||
@@ -71,11 +115,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
setSearchQuery('');
|
||||
setSortBy('roleCount');
|
||||
setSortOrder('desc');
|
||||
setFilterOccupation('');
|
||||
setFilterMediaType('');
|
||||
setFilterOccupation('all');
|
||||
setFilterMediaType('all');
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
||||
const hasActiveFilters = searchQuery || (filterOccupation && filterOccupation !== 'all') || (filterMediaType && filterMediaType !== 'all') || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
||||
|
||||
useEffect(() => {
|
||||
const loadCast = async () => {
|
||||
@@ -110,12 +154,12 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
}
|
||||
|
||||
// Filter by occupation
|
||||
if (filterOccupation && !s.occupations?.includes(filterOccupation)) {
|
||||
if (filterOccupation && filterOccupation !== 'all' && !s.occupations?.includes(filterOccupation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Filter by media type
|
||||
if (filterMediaType && !s.media_types?.includes(filterMediaType)) {
|
||||
if (filterMediaType && filterMediaType !== 'all' && !s.media_types?.includes(filterMediaType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -175,266 +219,602 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
return filteredStaff.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredStaff, currentPage, itemsPerPage]);
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setCurrentPage((prev) => Math.max(prev - 1, 1));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
const scrollContainer = document.getElementById('cast-scroll-container');
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
// Generate pagination items with ellipsis
|
||||
const getPaginationItems = () => {
|
||||
const items: (number | string)[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
items.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
items.push('ellipsis-start');
|
||||
}
|
||||
|
||||
// Show pages around current
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 2) {
|
||||
items.push('ellipsis-end');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
if (totalPages > 1) {
|
||||
items.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// Persist view mode
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castViewMode', viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
// Sort handler for table
|
||||
const handleSort = (column: typeof sortBy) => {
|
||||
if (sortBy === column) {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Sort options with labels
|
||||
const sortOptions = [
|
||||
{ value: 'name', label: 'Name', icon: ArrowUpAZ },
|
||||
{ value: 'role', label: 'Role', icon: Briefcase },
|
||||
{ value: 'birthDate', label: 'Birth Date', icon: Calendar },
|
||||
{ value: 'height', label: 'Height', icon: ArrowUpDown },
|
||||
{ value: 'roleCount', label: 'Role Count', icon: Star },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-foreground mb-2">Cast & Staff</h1>
|
||||
<p className="text-muted-foreground font-medium">Discover the people behind your favorite media</p>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-[calc(100vh-4rem-4rem)] w-full">
|
||||
{/* Sticky Header - Filters */}
|
||||
<div className="px-6 pt-4 pb-4 bg-background border-b border-white/10 shrink-0 z-10">
|
||||
{/* Compact Toolbar - Like MediaFilters */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Top Row: Search, View Toggle, Count */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-[320px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input
|
||||
placeholder="Search cast..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-muted/50 border-none rounded-lg text-sm focus-visible:ring-[#e8466c]/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
|
||||
<Input
|
||||
placeholder="Search cast..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 w-full md:w-[300px] bg-muted border-none rounded-full h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={showFilters ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
className={`rounded-full h-11 w-11 ${showFilters ? 'bg-[#6d28d9] text-white border-[#6d28d9]' : 'border-border'}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full h-11 w-11 border-border"
|
||||
onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||
>
|
||||
<ArrowUpDown size={20} />
|
||||
</Button>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full h-11 w-11 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleResetFilters}
|
||||
title="Reset filters"
|
||||
{/* View Toggle */}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={viewMode}
|
||||
onValueChange={(value: string | string[]) => {
|
||||
const v = Array.isArray(value) ? value[0] : value;
|
||||
if (v === 'grid' || v === 'table') {
|
||||
setViewMode(v);
|
||||
}
|
||||
}}
|
||||
className="bg-muted/50 p-0.5 rounded-lg"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
<ToggleGroupItem value="grid" aria-label="Grid view" className="h-8 w-8 p-0 rounded-md data-[state=on]:bg-background data-[state=on]:shadow-sm">
|
||||
<LayoutGrid size={16} />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="table" aria-label="Table view" className="h-8 w-8 p-0 rounded-md data-[state=on]:bg-background data-[state=on]:shadow-sm">
|
||||
<Table2 size={16} />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
{/* Count Badge */}
|
||||
<Badge variant="secondary" className="h-8 px-2.5 bg-muted/80 text-muted-foreground font-normal">
|
||||
{filteredStaff.length} {filteredStaff.length === 1 ? 'person' : 'people'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Filter Dropdowns */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Sort Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
||||
(sortBy !== 'roleCount' || sortOrder !== 'desc')
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<ArrowUpDown size={14} className="mr-1.5" />
|
||||
{sortOrder === 'asc' ? <ArrowUpAZ size={14} className="mr-1.5" /> : <ArrowDownAZ size={14} className="mr-1.5" />}
|
||||
{sortOptions.find(o => o.value === sortBy)?.label || 'Sort'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
|
||||
Sort by
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{sortOptions.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (sortBy === option.value) {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy(option.value);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<option.icon size={14} />
|
||||
{option.label}
|
||||
</span>
|
||||
{sortBy === option.value && (
|
||||
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#e8466c]" /> : <ArrowDownAZ size={14} className="text-[#e8466c]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Occupation Filter */}
|
||||
{uniqueOccupations.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
||||
filterOccupation && filterOccupation !== 'all'
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Briefcase size={14} className="mr-1.5" />
|
||||
{filterOccupation && filterOccupation !== 'all' ? filterOccupation : 'Occupation'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
|
||||
Filter by Occupation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setFilterOccupation('all')}>
|
||||
All Occupations
|
||||
</DropdownMenuItem>
|
||||
{uniqueOccupations.map(occ => (
|
||||
<DropdownMenuItem key={occ} onClick={() => setFilterOccupation(occ)}>
|
||||
{occ}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Media Type Filter */}
|
||||
{uniqueMediaTypes.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
||||
filterMediaType && filterMediaType !== 'all'
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Film size={14} className="mr-1.5" />
|
||||
{filterMediaType && filterMediaType !== 'all' ? filterMediaType : 'Media Type'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
|
||||
Filter by Media Type
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setFilterMediaType('all')}>
|
||||
All Media Types
|
||||
</DropdownMenuItem>
|
||||
{uniqueMediaTypes.map(type => (
|
||||
<DropdownMenuItem key={type} onClick={() => setFilterMediaType(type)}>
|
||||
{type}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Clear All */}
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetFilters}
|
||||
className="h-8 px-2 text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
>
|
||||
<X size={14} className="mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Filter Badges */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{searchQuery && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
Search: {searchQuery}
|
||||
<X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{filterOccupation && filterOccupation !== 'all' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => setFilterOccupation('all')}
|
||||
>
|
||||
{filterOccupation}
|
||||
<X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{filterMediaType && filterMediaType !== 'all' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => setFilterMediaType('all')}
|
||||
>
|
||||
{filterMediaType}
|
||||
<X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{(sortBy !== 'roleCount' || sortOrder !== 'desc') && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 px-2 text-xs bg-muted text-muted-foreground hover:bg-muted/80 cursor-pointer"
|
||||
onClick={() => { setSortBy('roleCount'); setSortOrder('desc'); }}
|
||||
>
|
||||
Sort: {sortOptions.find(o => o.value === sortBy)?.label}
|
||||
<X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="bg-muted/50 rounded-2xl p-6 mb-6 border border-border"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="text-sm font-bold text-foreground mb-2 block">Sort By</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="birthDate">Birth Date</option>
|
||||
<option value="height">Height</option>
|
||||
<option value="roleCount">Role Count</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-bold text-foreground mb-2 block">Occupation</label>
|
||||
<select
|
||||
value={filterOccupation}
|
||||
onChange={(e) => setFilterOccupation(e.target.value)}
|
||||
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="">All Occupations</option>
|
||||
{uniqueOccupations.map(occ => (
|
||||
<option key={occ} value={occ}>{occ}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-bold text-foreground mb-2 block">Media Type</label>
|
||||
<select
|
||||
value={filterMediaType}
|
||||
onChange={(e) => setFilterMediaType(e.target.value)}
|
||||
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="">All Media Types</option>
|
||||
{uniqueMediaTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{searchQuery && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
Search: {searchQuery}
|
||||
<button onClick={() => setSearchQuery('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{filterOccupation && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
Occupation: {filterOccupation}
|
||||
<button onClick={() => setFilterOccupation('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{filterMediaType && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
Media Type: {filterMediaType}
|
||||
<button onClick={() => setFilterMediaType('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{(sortBy !== 'name' || sortOrder !== 'asc') && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
Sort: {sortBy} ({sortOrder})
|
||||
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Loading message="Loading cast..." />
|
||||
) : filteredStaff.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<User size={48} className="mb-4 opacity-20" />
|
||||
<p className="text-lg font-bold">No cast members found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedStaff.map((person) => (
|
||||
<motion.div
|
||||
key={person.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="group bg-card rounded-2xl p-4 shadow-sm border border-border hover:shadow-xl hover:border-[#6d28d9]/20 transition-all duration-300 cursor-pointer"
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-border group-hover:border-[#6d28d9] transition-colors">
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
{person.name}
|
||||
</h3>
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{person.role}
|
||||
</p>
|
||||
</div>
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold text-[10px] px-2 py-0.5 shrink-0">
|
||||
{person.filmography.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<div className="bg-muted/50 rounded-xl p-3 flex items-center gap-3">
|
||||
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background">
|
||||
<img
|
||||
src={person.filmography[0].poster || person.photo}
|
||||
alt={person.filmography[0].title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
{/* Scrollable Content Area */}
|
||||
<div id="cast-scroll-container" className="flex-1 overflow-y-auto px-6 pt-4 pb-20">
|
||||
{/* Content Area */}
|
||||
{loading ? (
|
||||
<Loading message="Loading cast..." />
|
||||
) : filteredStaff.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-32 text-muted-foreground">
|
||||
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6">
|
||||
<User size={40} />
|
||||
</div>
|
||||
<p className="text-xl font-bold">No cast members found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View - Modern Cards */
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedStaff.map((person) => (
|
||||
<motion.div
|
||||
key={person.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Card
|
||||
className="group cursor-pointer overflow-hidden hover:shadow-xl hover:border-[#e8466c]/30 transition-all duration-300 border-border/60"
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
{/* Card Header with Avatar and Info */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-12 w-12 rounded-lg border-2 border-border/50 group-hover:border-[#e8466c] transition-colors duration-300 shadow-sm">
|
||||
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" className="object-cover" />
|
||||
<AvatarFallback className="rounded-lg bg-muted">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-foreground truncate group-hover:text-[#e8466c] transition-colors duration-300 text-sm leading-tight">
|
||||
{person.name}
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||
{person.role}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1.5">
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 bg-muted">
|
||||
<Star className="w-2.5 h-2.5 mr-0.5" />
|
||||
{person.filmography.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{person.filmography.length} roles</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{person.birthDate && (
|
||||
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
|
||||
<Calendar className="w-2.5 h-2.5" />
|
||||
{new Date(person.birthDate).getFullYear()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest leading-none mb-1">Latest Role</p>
|
||||
<p className="text-xs font-bold text-foreground truncate">{person.filmography[0].title}</p>
|
||||
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">{person.filmography[0].role}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredStaff.length > 0 && (
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border pt-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground font-medium">Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
}}
|
||||
className="bg-muted border-none rounded-md px-2 py-1 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{[12, 20, 36, 48, 60].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
{/* Latest Role Section */}
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="bg-muted/50 rounded-lg p-2 flex items-center gap-2 border border-border/40 group-hover:border-[#e8466c]/20 transition-colors">
|
||||
<div className="w-8 h-11 rounded overflow-hidden shrink-0 bg-background border border-border/40">
|
||||
<img
|
||||
src={person.filmography[0].poster || person.photo}
|
||||
alt={person.filmography[0].title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide leading-none">Latest</p>
|
||||
<p className="text-[11px] font-medium text-foreground truncate">{person.filmography[0].title}</p>
|
||||
<p className="text-[10px] text-[#e8466c] truncate">{person.filmography[0].role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</select>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
/* Table View */
|
||||
<Table className="w-full table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-border/60 bg-muted/30">
|
||||
<TableHead className="w-14 rounded-tl-lg"></TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:text-[#e8466c] transition-colors"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Name
|
||||
{sortBy === 'name' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:text-[#e8466c] transition-colors"
|
||||
onClick={() => handleSort('role')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Role
|
||||
{sortBy === 'role' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Latest Work</TableHead>
|
||||
<TableHead
|
||||
className="hidden sm:table-cell cursor-pointer hover:text-[#e8466c] transition-colors text-right"
|
||||
onClick={() => handleSort('roleCount')}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
Roles
|
||||
{sortBy === 'roleCount' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-10 rounded-tr-lg"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedStaff.map((person) => (
|
||||
<motion.tr
|
||||
key={person.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={cn(
|
||||
"group cursor-pointer border-border/40 transition-colors",
|
||||
hoveredRow === person.id ? "bg-muted/60" : "hover:bg-muted/40"
|
||||
)}
|
||||
onMouseEnter={() => setHoveredRow(person.id)}
|
||||
onMouseLeave={() => setHoveredRow(null)}
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
<TableCell className="py-3">
|
||||
<Avatar className="h-10 w-10 rounded-lg border border-border/50">
|
||||
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" />
|
||||
<AvatarFallback className="rounded-lg bg-muted">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className="group-hover:text-[#e8466c] transition-colors">{person.name}</span>
|
||||
{person.birthDate && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(person.birthDate).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="font-normal bg-muted/80 text-muted-foreground">
|
||||
{person.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{person.filmography && person.filmography.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-10 rounded overflow-hidden shrink-0 bg-muted">
|
||||
<img
|
||||
src={person.filmography[0].poster || person.photo}
|
||||
alt={person.filmography[0].title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm truncate">{person.filmography[0].title}</p>
|
||||
<p className="text-xs text-muted-foreground">{person.filmography[0].role}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-right">
|
||||
{person.filmography ? (
|
||||
<Badge variant="outline" className="font-medium">
|
||||
{person.filmography.length}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPersonClick(person);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
|
||||
<span className="text-sm text-muted-foreground font-medium">of</span>
|
||||
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* End of scrollable content area */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky Pagination Controls */}
|
||||
{filteredStaff.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500 font-medium">Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="bg-[#1a1d26] border border-white/10 rounded-md px-2 py-1 text-sm font-medium text-gray-300 focus:ring-2 focus:ring-[#e8466c] outline-none"
|
||||
>
|
||||
{[12, 20, 36, 48, 60, 100].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
|
||||
className={cn(
|
||||
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||
currentPage === 1 && "pointer-events-none opacity-50"
|
||||
)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPaginationItems().map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{item === 'ellipsis-start' || item === 'ellipsis-end' ? (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
isActive={currentPage === item}
|
||||
onClick={() => handlePageChange(item as number)}
|
||||
className={cn(
|
||||
"border-white/10",
|
||||
currentPage === item
|
||||
? "bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30"
|
||||
: "bg-transparent text-gray-300 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
|
||||
className={cn(
|
||||
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||
(currentPage === totalPages || totalPages === 0) && "pointer-events-none opacity-50"
|
||||
)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import {
|
||||
Film,
|
||||
Tv,
|
||||
Gamepad2,
|
||||
Users,
|
||||
Heart,
|
||||
FolderKanban,
|
||||
Database,
|
||||
Sparkles,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import Loading from '@/components/ui/loading';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface DashboardViewProps {
|
||||
mediaList: Media[];
|
||||
onMediaClick: (media: Media) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function DashboardView({ mediaList, onMediaClick, loading = false }: DashboardViewProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const categories = mediaList.reduce((acc, media) => {
|
||||
acc[media.category] = (acc[media.category] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<MediaCategory, number>);
|
||||
|
||||
const favoritesCount = mediaList.filter(m => m.rating && m.rating >= 8).length;
|
||||
|
||||
return {
|
||||
movies: categories['Movies'] || 0,
|
||||
series: categories['TV Series'] || 0,
|
||||
games: categories['Games'] || 0,
|
||||
adult: categories['Adult'] || 0,
|
||||
actors: new Set(mediaList.flatMap(m => m.staff?.map(s => s.id) || [])).size,
|
||||
collections: 3, // Placeholder
|
||||
favorites: favoritesCount
|
||||
};
|
||||
}, [mediaList]);
|
||||
|
||||
// Get recently added media
|
||||
const recentMedia = useMemo(() => {
|
||||
return [...mediaList].slice(0, 10);
|
||||
}, [mediaList]);
|
||||
|
||||
// Get favorites
|
||||
const favoritesMedia = useMemo(() => {
|
||||
return [...mediaList]
|
||||
.filter(m => m.rating && m.rating >= 8)
|
||||
.slice(0, 8);
|
||||
}, [mediaList]);
|
||||
|
||||
// Category card config
|
||||
const categoryCards = [
|
||||
{
|
||||
key: 'movies',
|
||||
label: 'MOVIES',
|
||||
count: stats.movies,
|
||||
icon: Film,
|
||||
color: 'from-blue-500/20 to-blue-600/10',
|
||||
iconBg: 'bg-blue-500/20',
|
||||
path: '/movies'
|
||||
},
|
||||
{
|
||||
key: 'series',
|
||||
label: 'SERIES',
|
||||
count: stats.series,
|
||||
icon: Tv,
|
||||
color: 'from-green-500/20 to-green-600/10',
|
||||
iconBg: 'bg-green-500/20',
|
||||
path: '/tv-series'
|
||||
},
|
||||
{
|
||||
key: 'games',
|
||||
label: 'GAMES',
|
||||
count: stats.games,
|
||||
icon: Gamepad2,
|
||||
color: 'from-purple-500/20 to-purple-600/10',
|
||||
iconBg: 'bg-purple-500/20',
|
||||
path: '/games'
|
||||
},
|
||||
{
|
||||
key: 'adult',
|
||||
label: 'ADULT',
|
||||
count: stats.adult,
|
||||
icon: Eye,
|
||||
color: 'from-rose-500/20 to-rose-600/10',
|
||||
iconBg: 'bg-rose-500/20',
|
||||
path: '/adult'
|
||||
},
|
||||
{
|
||||
key: 'actors',
|
||||
label: 'ACTORS',
|
||||
count: stats.actors,
|
||||
icon: Users,
|
||||
color: 'from-amber-500/20 to-amber-600/10',
|
||||
iconBg: 'bg-amber-500/20',
|
||||
path: '/cast'
|
||||
},
|
||||
{
|
||||
key: 'collections',
|
||||
label: 'COLLECTIONS',
|
||||
count: stats.collections,
|
||||
icon: FolderKanban,
|
||||
color: 'from-cyan-500/20 to-cyan-600/10',
|
||||
iconBg: 'bg-cyan-500/20',
|
||||
path: '/collections'
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <Loading message="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-6 pb-20 px-6 max-w-[1920px] mx-auto">
|
||||
{/* Welcome Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Welcome to MediaVault
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm ml-11">Your media library at a glance</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-8"
|
||||
>
|
||||
{categoryCards.map((card, index) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={card.key}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05 }}
|
||||
onClick={() => navigate(card.path)}
|
||||
className={`relative overflow-hidden rounded-xl p-5 bg-gradient-to-br ${card.color} border border-border/50 hover:border-border/80 transition-all duration-300 cursor-pointer group`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">{card.label}</p>
|
||||
<p className="text-3xl font-bold text-foreground">{card.count}</p>
|
||||
</div>
|
||||
<div className={`w-10 h-10 rounded-lg ${card.iconBg} flex items-center justify-center`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* Favorites Section */}
|
||||
{favoritesMedia.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div
|
||||
onClick={() => navigate('/browse?favorites=true')}
|
||||
className="relative overflow-hidden rounded-xl p-6 bg-gradient-to-r from-[#e8466c]/10 to-[#f47298]/5 border border-[#e8466c]/20 hover:border-[#e8466c]/30 transition-all duration-300 cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#e8466c]/20 flex items-center justify-center">
|
||||
<Heart className="w-6 h-6 text-[#e8466c]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#e8466c] uppercase tracking-wider">FAVORITES</p>
|
||||
<p className="text-2xl font-bold text-foreground">{favoritesMedia.length} <span className="text-sm font-normal text-muted-foreground">items in your favorites</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<span className="text-sm font-medium">View Favorites</span>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Recently Added Section */}
|
||||
{recentMedia.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-5 h-5 text-[#e8466c]" />
|
||||
<h2 className="text-sm font-bold text-foreground uppercase tracking-wider">Recently Added</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/browse?sort=recent')}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View All <ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-4">
|
||||
{recentMedia.map((media) => (
|
||||
<MediaCard
|
||||
key={media.id}
|
||||
media={media}
|
||||
onClick={onMediaClick}
|
||||
showBadge={true}
|
||||
showFavorite={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{mediaList.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
|
||||
<div className="w-20 h-20 bg-muted rounded-2xl flex items-center justify-center mb-6 border border-border">
|
||||
<Database className="w-10 h-10" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-foreground">No media found</p>
|
||||
<p className="text-sm">Start by adding media to your collection</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+390
-396
@@ -1,425 +1,419 @@
|
||||
import { Media, Staff, Track } from '@/types';
|
||||
import { Media, Staff } from '@/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Bookmark,
|
||||
MoreHorizontal,
|
||||
Star,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
ListFilter,
|
||||
ChevronDown
|
||||
import { useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ArrowLeft, Calendar, Clock, Play, Star, Users, Disc, Layers,
|
||||
Tv, BookOpen, Gamepad2, Film, Music, Package, Heart, Bookmark,
|
||||
MoreHorizontal, Share2, ExternalLink
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { motion } from 'motion/react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import OverviewTab from './details/tabs/OverviewTab';
|
||||
import CastTab from './details/tabs/CastTab';
|
||||
import SeasonsTab from './details/tabs/SeasonsTab';
|
||||
import TracksTab from './details/tabs/TracksTab';
|
||||
import SeriesTab from './details/tabs/SeriesTab';
|
||||
|
||||
interface DetailViewProps {
|
||||
media: Media;
|
||||
allMedia: Media[];
|
||||
onPersonClick: (person: Staff) => void;
|
||||
}
|
||||
|
||||
export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
const categoryIcons: Record<string, React.ReactNode> = {
|
||||
'Anime': <Tv className="w-4 h-4" />,
|
||||
'Movies': <Film className="w-4 h-4" />,
|
||||
'TV Series': <Tv className="w-4 h-4" />,
|
||||
'Music': <Music className="w-4 h-4" />,
|
||||
'Books': <BookOpen className="w-4 h-4" />,
|
||||
'Games': <Gamepad2 className="w-4 h-4" />,
|
||||
'Consoles': <Package className="w-4 h-4" />,
|
||||
'Adult': <Film className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'watching': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
'reading': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
'listening': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
'playing': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
'completed': 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||
'planned': 'bg-amber-500/10 text-amber-500 border-amber-500/20',
|
||||
'dropped': 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||
'on-hold': 'bg-muted text-muted-foreground border-border',
|
||||
};
|
||||
|
||||
export default function DetailView({ media, allMedia, onPersonClick }: DetailViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [castLimit, setCastLimit] = useState(6);
|
||||
const [showAllCast, setShowAllCast] = useState(false);
|
||||
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
|
||||
const [progress] = useState(media.playCount ? Math.min(100, (media.playCount * 10)) : 0);
|
||||
|
||||
// Group episodes by season
|
||||
const episodesBySeason = useMemo(() => {
|
||||
if (!media.episodes) return {};
|
||||
const grouped: Record<number, typeof media.episodes> = {};
|
||||
media.episodes.forEach(episode => {
|
||||
if (!grouped[episode.season]) {
|
||||
grouped[episode.season] = [];
|
||||
}
|
||||
grouped[episode.season].push(episode);
|
||||
});
|
||||
// Sort episodes within each season by episode number
|
||||
Object.keys(grouped).forEach(season => {
|
||||
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
|
||||
});
|
||||
return grouped;
|
||||
}, [media.episodes]);
|
||||
const hasEpisodes = media.episodes && media.episodes.length > 0;
|
||||
const hasTracks = media.tracks && media.tracks.length > 0;
|
||||
const hasCast = media.staff && media.staff.length > 0;
|
||||
const hasFranchise = media.category === 'Games' && media.series && media.series.length > 0;
|
||||
|
||||
// Expand first season by default on mount
|
||||
useEffect(() => {
|
||||
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
|
||||
if (seasons.length > 0) {
|
||||
setExpandedSeasons(new Set([seasons[0]]));
|
||||
}
|
||||
}, [episodesBySeason]);
|
||||
|
||||
const toggleSeason = (season: number) => {
|
||||
setExpandedSeasons(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(season)) {
|
||||
newSet.delete(season);
|
||||
} else {
|
||||
newSet.add(season);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
// Determine default tab based on available content
|
||||
const getDefaultTab = () => {
|
||||
if (hasEpisodes) return 'seasons';
|
||||
if (hasTracks) return 'tracks';
|
||||
if (hasCast) return 'cast';
|
||||
return 'overview';
|
||||
};
|
||||
|
||||
const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []);
|
||||
const hasMoreCast = (media.staff?.length || 0) > castLimit;
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Banner */}
|
||||
<div className="relative h-[400px] w-full overflow-hidden">
|
||||
<img
|
||||
src={media.banner || media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/40 to-transparent" />
|
||||
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute top-24 left-6 p-2 bg-black/20 hover:bg-black/40 text-white rounded-full transition-colors z-10"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-[1400px] mx-auto px-6 -mt-32 relative z-10 pb-24">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Left Column: Poster + Metadata */}
|
||||
<div className="w-full md:w-[300px] shrink-0">
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className={`rounded-xl overflow-hidden shadow-2xl bg-card ${
|
||||
media.aspectRatio === '16/9' ? 'aspect-video' :
|
||||
media.aspectRatio === '1/1' ? 'aspect-square' :
|
||||
'aspect-[2/3]'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Compact metadata under poster */}
|
||||
<div className="mt-4 space-y-2">
|
||||
{media.studios && media.studios.length > 0 && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Studios:</span>
|
||||
{media.studios.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{media.developers && media.developers.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Developers:</span>
|
||||
{media.developers.map(dev => (
|
||||
<Badge key={dev} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
|
||||
{dev}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.platforms && media.platforms.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Platforms:</span>
|
||||
{media.platforms.map(platform => (
|
||||
<Badge key={platform} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.categories && media.categories.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Categories:</span>
|
||||
{media.categories.map(category => (
|
||||
<Badge key={category} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.completionStatus && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Completion:</span>
|
||||
{media.completionStatus}
|
||||
</p>
|
||||
)}
|
||||
{media.source && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Source:</span>
|
||||
{media.source}
|
||||
</p>
|
||||
)}
|
||||
{media.playCount !== undefined && media.playCount !== null && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Play Count:</span>
|
||||
{media.playCount}
|
||||
</p>
|
||||
)}
|
||||
{media.playtime !== undefined && media.playtime !== null && media.playtime > 0 && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Playtime:</span>
|
||||
{media.playtime}h
|
||||
</p>
|
||||
)}
|
||||
{media.lastActivity && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Last Activity:</span>
|
||||
{media.lastActivity}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Links:</span>
|
||||
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
|
||||
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">AniDb</Button>
|
||||
</div>
|
||||
</div>
|
||||
const [activeTab, setActiveTab] = useState(getDefaultTab());
|
||||
|
||||
const tabItems = [
|
||||
{ id: 'overview', label: 'Overview', icon: BookOpen, hidden: false },
|
||||
{ id: 'cast', label: 'Cast', icon: Users, hidden: !hasCast },
|
||||
{ id: 'seasons', label: 'Seasons', icon: Layers, hidden: !hasEpisodes },
|
||||
{ id: 'tracks', label: 'Tracks', icon: Disc, hidden: !hasTracks },
|
||||
{ id: 'series', label: 'Series', icon: Gamepad2, hidden: !hasFranchise },
|
||||
].filter(tab => !tab.hidden);
|
||||
|
||||
const statusBadgeClass = media.status ? statusColors[media.status] : 'bg-muted text-muted-foreground border-border';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="min-h-screen bg-background pb-20">
|
||||
{/* Hero Section - Full height from top behind transparent navbar */}
|
||||
<div className="relative h-[40vh] md:h-[45vh] overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
src={media.banner || media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover opacity-40 blur-sm scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/60 to-transparent" />
|
||||
|
||||
{/* Back Button - z-50 to ensure clickable */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute top-4 left-4 sm:left-6 z-50 bg-black/30 hover:bg-black/50 text-white rounded-xl backdrop-blur-md transition-all duration-300 hover:scale-105 border border-white/20 h-10 w-10"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Quick Actions - z-50 to ensure clickable */}
|
||||
<div className="absolute top-4 right-4 sm:right-6 z-50 flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
|
||||
>
|
||||
<Heart className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add to favorites</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
|
||||
>
|
||||
<Bookmark className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Bookmark</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Share</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>More options</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Info */}
|
||||
<div className="flex-1 pt-4 md:pt-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-foreground mb-2">
|
||||
{media.title} <span className="text-muted-foreground font-medium">({media.year})</span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" className="rounded-full bg-[#6d28d9] hover:bg-[#5b21b6]">
|
||||
<Play size={20} fill="currentColor" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="rounded-full border-border">
|
||||
<Bookmark size={20} />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="rounded-full border-border">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
{/* Hero Content - pt-16 to account for navbar + buttons */}
|
||||
<div className="absolute inset-0 pt-16 flex items-end px-4 sm:px-6 pb-8">
|
||||
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-6">
|
||||
{/* Poster */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Avatar className={`h-40 md:h-48 w-auto rounded-none border-4 border-background shadow-2xl ${
|
||||
media.aspectRatio === '16/9' ? 'aspect-video' :
|
||||
media.aspectRatio === '1/1' ? 'aspect-square' :
|
||||
'aspect-[2/3]'
|
||||
}`}>
|
||||
<AvatarImage
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<AvatarFallback className="rounded-none text-3xl bg-muted">
|
||||
{categoryIcons[media.category] || <Film className="h-12 w-12" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</motion.div>
|
||||
|
||||
{/* Title & Meta */}
|
||||
<div className="flex-1 text-center md:text-left pb-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mb-3">
|
||||
{categoryIcons[media.category] && (
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20">
|
||||
{categoryIcons[media.category]}
|
||||
<span className="ml-1">{media.category}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{media.type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{media.type}
|
||||
</Badge>
|
||||
)}
|
||||
{media.status && (
|
||||
<Badge variant="outline" className={`text-xs font-medium ${statusBadgeClass}`}>
|
||||
{media.status.charAt(0).toUpperCase() + media.status.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
{media.completionStatus && (
|
||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20 text-xs font-medium">
|
||||
{media.completionStatus}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-foreground font-bold">
|
||||
<Star size={18} className="text-yellow-500" fill="currentColor" />
|
||||
{media.rating} / 10
|
||||
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-3 tracking-tight">
|
||||
{media.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{media.year}</span>
|
||||
</div>
|
||||
{media.rating && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Star className="w-4 h-4 text-amber-500" />
|
||||
<span>{media.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{media.playtime && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{media.playtime}h played</span>
|
||||
</div>
|
||||
)}
|
||||
{hasEpisodes && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tv className="w-4 h-4" />
|
||||
<span>{media.episodes!.length} episodes</span>
|
||||
</div>
|
||||
)}
|
||||
{hasTracks && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Disc className="w-4 h-4" />
|
||||
<span>{media.tracks!.length} tracks</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block text-right">
|
||||
<h3 className="text-xs font-black text-[#6d28d9] uppercase tracking-wider mb-2">Genres</h3>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{media.genres?.map(genre => (
|
||||
<span key={genre} className="text-sm font-bold text-foreground hover:text-[#6d28d9] cursor-pointer transition-colors">
|
||||
• {genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-foreground leading-relaxed mb-6 max-w-3xl prose prose-sm dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: media.description || '' }}
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{media.tags?.map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20 border-none px-3 py-1 font-bold text-[10px] uppercase tracking-wider">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{/* Primary Action */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Button size="lg" className="rounded-xl px-8 shadow-lg">
|
||||
<Play className="w-5 h-5 mr-2 fill-current" />
|
||||
Play
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Staff Section - Only show if staff data exists */}
|
||||
{media.staff && media.staff.length > 0 && (
|
||||
<section className="mt-20">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-black text-foreground">Cast & Crew</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-bold text-muted-foreground">
|
||||
{showAllCast ? media.staff.length : displayedCast.length} / {media.staff.length}
|
||||
</span>
|
||||
{hasMoreCast && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAllCast(!showAllCast)}
|
||||
className="rounded-full border-border font-bold"
|
||||
>
|
||||
{showAllCast ? 'Show Less' : 'Show All'}
|
||||
<ChevronDown size={16} className={`ml-2 transition-transform ${showAllCast ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{displayedCast.map(person => (
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex items-center gap-4 bg-card p-3 rounded-xl shadow-sm border border-border hover:shadow-md transition-shadow cursor-pointer group"
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0">
|
||||
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
|
||||
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
|
||||
</div>
|
||||
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-muted">
|
||||
<img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Episodes Section - Only show if episodes data exists */}
|
||||
{media.episodes && media.episodes.length > 0 && (
|
||||
<section className="mt-20">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-xl">
|
||||
<span className="opacity-40">{media.episodes.length}</span> Episode{media.episodes.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="text-sm font-bold text-muted-foreground">
|
||||
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted border-none rounded-full h-9 text-sm" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
||||
<ListFilter size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.keys(episodesBySeason)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(season => (
|
||||
<div key={season} className="border border-border rounded-2xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSeason(season)}
|
||||
className="w-full flex items-center justify-between p-6 bg-card hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-2xl font-black text-foreground">Season {season}</h3>
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold">
|
||||
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
className={`transition-transform duration-300 text-muted-foreground ${
|
||||
expandedSeasons.has(season) ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{expandedSeasons.has(season) && (
|
||||
<div className="p-6 pt-0 space-y-6">
|
||||
{episodesBySeason[season].map(episode => (
|
||||
<div key={episode.id} className="group cursor-pointer">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-xl overflow-hidden shadow-sm relative">
|
||||
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 py-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors">
|
||||
E{episode.episode_number} • {episode.title}
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-muted-foreground">{episode.air_date} • {episode.duration}m</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{episode.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mt-6 bg-border" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tracks Section - Only show if tracks data exists (Music) */}
|
||||
{media.tracks && media.tracks.length > 0 && (
|
||||
<section className="mt-20">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-xl">
|
||||
<span className="opacity-40">{media.tracks.length}</span> Track{media.tracks.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted border-none rounded-full h-9 text-sm" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
||||
<ListFilter size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-2xl overflow-hidden">
|
||||
<div className="divide-y divide-border">
|
||||
{media.tracks
|
||||
.sort((a, b) => a.track_number - b.track_number)
|
||||
.map((track, index) => (
|
||||
<div key={track.id} className="group cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground group-hover:bg-[#6d28d9] group-hover:text-white transition-colors">
|
||||
{track.track_number}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-foreground group-hover:text-[#6d28d9] transition-colors truncate">
|
||||
{track.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{track.artist}</p>
|
||||
</div>
|
||||
{track.duration && (
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{track.duration}s
|
||||
</span>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Play size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Content Section */}
|
||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 mt-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Left Sidebar - Info Cards */}
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
{/* Progress Card */}
|
||||
{progress > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Progress</span>
|
||||
<span className="text-sm font-bold text-primary">{progress}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Studios */}
|
||||
{media.studios && media.studios.length > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Film className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Studios
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{media.studios.map(studio => (
|
||||
<Badge key={studio} variant="secondary" className="text-xs">
|
||||
{studio}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Platforms (for Games) */}
|
||||
{media.platforms && media.platforms.length > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Gamepad2 className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Platforms
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{media.platforms.map(platform => (
|
||||
<Badge key={platform} variant="secondary" className="text-xs">
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Developers (for Games) */}
|
||||
{media.developers && media.developers.length > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Users className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Developers
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{media.developers.map(dev => (
|
||||
<Badge key={dev} variant="secondary" className="text-xs">
|
||||
{dev}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Source */}
|
||||
{media.source && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<ExternalLink className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Source
|
||||
</h3>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{media.source}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Main Content - Tabs */}
|
||||
<div className="lg:col-span-3">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-4 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto flex-wrap">
|
||||
{tabItems.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
<OverviewTab media={media} />
|
||||
</TabsContent>
|
||||
|
||||
{hasCast && (
|
||||
<TabsContent value="cast" className="mt-0">
|
||||
<CastTab staff={media.staff!} onPersonClick={onPersonClick} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{hasEpisodes && (
|
||||
<TabsContent value="seasons" className="mt-0">
|
||||
<SeasonsTab episodes={media.episodes!} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{hasTracks && (
|
||||
<TabsContent value="tracks" className="mt-0">
|
||||
<TracksTab tracks={media.tracks!} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{hasFranchise && (
|
||||
<TabsContent value="series" className="mt-0">
|
||||
<SeriesTab media={media} allMedia={allMedia} onMediaClick={(m) => navigate(`/media/${m.id}`)} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+116
-47
@@ -1,7 +1,7 @@
|
||||
import { Search, User, X, Plus, Download, Settings } from 'lucide-react';
|
||||
import { Search, User, X, Plus, Download, Settings, Menu } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { Link, NavLink, useLocation } from 'react-router-dom';
|
||||
import { MediaCategory } from '@/types';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
@@ -25,7 +25,21 @@ export default function Header({
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
// Map category names to URL-friendly paths
|
||||
const categoryPaths: Record<MediaCategory, string> = {
|
||||
'Anime': 'anime',
|
||||
'Movies': 'movies',
|
||||
'TV Series': 'tv-series',
|
||||
'Music': 'music',
|
||||
'Books': 'books',
|
||||
'Games': 'games',
|
||||
'Consoles': 'consoles',
|
||||
'Adult': 'adult'
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -53,70 +67,91 @@ export default function Header({
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-300",
|
||||
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-500",
|
||||
transparent && !scrolled
|
||||
? "bg-transparent"
|
||||
: transparent && scrolled
|
||||
? "backdrop-blur-md bg-background/80 border-b border-border/50"
|
||||
: "bg-[#6d28d9]"
|
||||
? "backdrop-blur-xl bg-background/70 border-b border-border/30"
|
||||
: "backdrop-blur-xl bg-gradient-to-r from-[#e8466c]/90 via-[#f47298]/90 to-[#e8466c]/90 border-b border-white/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-8">
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
"text-2xl font-black flex items-center gap-1",
|
||||
"text-2xl font-black flex items-center gap-2 transition-all duration-300 hover:scale-105",
|
||||
(transparent && !scrolled) || !transparent ? "text-white" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white" : "bg-[#6d28d9]"
|
||||
"w-8 h-8 rounded-xl flex items-center justify-center shadow-lg transition-all duration-300",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "bg-white/20 backdrop-blur-sm border border-white/30"
|
||||
: "bg-gradient-to-br from-[#e8466c] to-[#f47298] shadow-[#e8466c]/30"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"w-3 h-3 rounded-full",
|
||||
(transparent && !scrolled) || !transparent ? "bg-[#6d28d9]" : "bg-white"
|
||||
"w-4 h-4 rounded-full",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white" : "bg-white"
|
||||
)} />
|
||||
</div>
|
||||
kyoo
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
|
||||
omnyx
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className={cn(
|
||||
"md:hidden p-2 rounded-lg transition-all duration-300 hover:bg-white/10",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{enabledCategories.map(cat => (
|
||||
<button
|
||||
<NavLink
|
||||
key={cat}
|
||||
onClick={() => onCategoryChange(cat)}
|
||||
className={cn(
|
||||
"text-sm font-bold transition-colors uppercase tracking-wider",
|
||||
to={`/${categoryPaths[cat]}`}
|
||||
className={({ isActive }) => cn(
|
||||
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg relative",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? activeCategory === cat ? "text-white" : "text-white/60 hover:text-white"
|
||||
: activeCategory === cat ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
? isActive
|
||||
? "text-white bg-white/10"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
: isActive
|
||||
? "text-foreground bg-[#e8466c]/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
</NavLink>
|
||||
))}
|
||||
<div className={cn(
|
||||
"w-px h-4 mx-2",
|
||||
"w-px h-6 mx-2",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white/20" : "bg-border"
|
||||
)} />
|
||||
<NavLink
|
||||
to="/cast"
|
||||
className={({ isActive }) => cn(
|
||||
"text-sm font-bold transition-colors uppercase tracking-wider",
|
||||
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? isActive ? "text-white" : "text-white/60 hover:text-white"
|
||||
: isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
? isActive ? "text-white bg-white/10" : "text-white/70 hover:text-white hover:bg-white/5"
|
||||
: isActive ? "text-foreground bg-[#e8466c]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
CAST
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex items-center transition-all duration-300 overflow-hidden",
|
||||
isSearchOpen ? "w-48 md:w-64 rounded-full px-3 py-1" : "w-0",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white/10" : "bg-muted"
|
||||
"flex items-center transition-all duration-300 overflow-hidden rounded-2xl",
|
||||
isSearchOpen ? "w-48 md:w-72 px-4 py-2.5" : "w-0",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "bg-white/10 backdrop-blur-md border border-white/20"
|
||||
: "bg-muted/50 backdrop-blur-md border border-border"
|
||||
)}>
|
||||
<input
|
||||
type="text"
|
||||
@@ -124,9 +159,9 @@ export default function Header({
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className={cn(
|
||||
"bg-transparent border-none outline-none text-sm w-full",
|
||||
"bg-transparent border-none outline-none text-sm w-full placeholder:opacity-60",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white placeholder:text-white/50"
|
||||
? "text-white placeholder:text-white"
|
||||
: "text-foreground placeholder:text-muted-foreground"
|
||||
)}
|
||||
autoFocus={isSearchOpen}
|
||||
@@ -135,50 +170,52 @@ export default function Header({
|
||||
<button
|
||||
onClick={toggleSearch}
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{isSearchOpen ? <X size={20} /> : <Search size={20} />}
|
||||
{isSearchOpen ? <X size={18} /> : <Search size={18} />}
|
||||
</button>
|
||||
<Link
|
||||
to="/add"
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Plus size={20} />
|
||||
<Plus size={18} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/import"
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Download size={20} />
|
||||
<Download size={18} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Settings size={20} />
|
||||
<Settings size={18} />
|
||||
</Link>
|
||||
<button className={cn(
|
||||
"w-8 h-8 rounded-full overflow-hidden border-2",
|
||||
(transparent && !scrolled) || !transparent ? "border-white/20" : "border-border"
|
||||
"w-9 h-9 rounded-xl overflow-hidden border-2 transition-all duration-300 hover:scale-110 hover:shadow-lg",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "border-white/30 hover:border-white/50"
|
||||
: "border-border hover:border-[#e8466c]/50"
|
||||
)}>
|
||||
<img
|
||||
src="https://picsum.photos/seed/user/100/100"
|
||||
@@ -188,6 +225,38 @@ export default function Header({
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden absolute top-full left-0 right-0 bg-background border-b border-border shadow-lg">
|
||||
<nav className="flex flex-col p-4 gap-2">
|
||||
{enabledCategories.map(cat => (
|
||||
<NavLink
|
||||
key={cat}
|
||||
to={`/${categoryPaths[cat]}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={({ isActive }) => cn(
|
||||
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
|
||||
isActive ? "text-[#e8466c] bg-[#e8466c]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="w-full h-px bg-border my-2" />
|
||||
<NavLink
|
||||
to="/cast"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={({ isActive }) => cn(
|
||||
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
|
||||
isActive ? "text-[#e8466c] bg-[#e8466c]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
CAST
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
|
||||
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
|
||||
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
|
||||
import { importFromPlaynite, PlayniteConfig, PlayniteImportOptions } from '@/lib/playniteImporter';
|
||||
import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter';
|
||||
import { fetchSettings, updateSettings } from '@/api';
|
||||
|
||||
@@ -25,6 +25,10 @@ export default function ImporterView() {
|
||||
port: import.meta.env.VITE_PLAYNITE_PORT ? parseInt(import.meta.env.VITE_PLAYNITE_PORT) : undefined,
|
||||
updateExisting: true
|
||||
});
|
||||
const [playniteOptions, setPlayniteOptions] = useState<PlayniteImportOptions>({
|
||||
limit: undefined,
|
||||
nameFilter: undefined
|
||||
});
|
||||
const [jellyfinConfig, setJellyfinConfig] = useState<JellyfinConfig>({
|
||||
url: import.meta.env.VITE_JELLYFIN_URL || '',
|
||||
apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || ''
|
||||
@@ -199,6 +203,7 @@ export default function ImporterView() {
|
||||
|
||||
const result = await importFromPlaynite(
|
||||
playniteConfig,
|
||||
playniteOptions,
|
||||
addLog,
|
||||
(progressUpdate) => {
|
||||
setProgress(prev => ({ ...prev, ...progressUpdate }));
|
||||
@@ -341,7 +346,7 @@ export default function ImporterView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -349,12 +354,12 @@ export default function ImporterView() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/')}
|
||||
className="text-muted-foreground hover:text-[#6d28d9]"
|
||||
className="text-muted-foreground hover:text-[#6d28d9] hover:bg-muted/50 rounded-xl transition-all duration-300"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-foreground">Media Importers</h1>
|
||||
<h1 className="text-4xl font-black text-foreground mb-1">Media Importers</h1>
|
||||
<p className="text-sm text-muted-foreground font-medium">Import media from external platforms</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,7 +369,7 @@ export default function ImporterView() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
{/* XBVR Importer Card */}
|
||||
{xbvrConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
@@ -413,7 +418,7 @@ export default function ImporterView() {
|
||||
<Button
|
||||
onClick={handleXBVRImport}
|
||||
disabled={progress.stage !== 'idle' || !xbvrConfig.url}
|
||||
className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-bold"
|
||||
className="w-full bg-[#6d28d9] hover:bg-[#d13d60] text-white font-bold"
|
||||
>
|
||||
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||
<>
|
||||
@@ -433,7 +438,7 @@ export default function ImporterView() {
|
||||
|
||||
{/* StashAPP Importer Card */}
|
||||
{stashappConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
@@ -513,7 +518,7 @@ export default function ImporterView() {
|
||||
|
||||
{/* StashAPP Actor Updater Card */}
|
||||
{stashappConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
@@ -571,7 +576,7 @@ export default function ImporterView() {
|
||||
|
||||
{/* Playnite Importer Card */}
|
||||
{playniteConfig.ip && playniteConfig.apiToken && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
@@ -639,6 +644,28 @@ export default function ImporterView() {
|
||||
/>
|
||||
<label htmlFor="playnite-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">Limit (optional, for testing)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={playniteOptions.limit || ''}
|
||||
onChange={(e) => setPlayniteOptions({ ...playniteOptions, limit: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="e.g. 10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-muted-foreground mb-1 block">Name Filter (optional, for testing)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={playniteOptions.nameFilter || ''}
|
||||
onChange={(e) => setPlayniteOptions({ ...playniteOptions, nameFilter: e.target.value || undefined })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||
placeholder="e.g. Reside"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePlayniteImport}
|
||||
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
|
||||
@@ -662,7 +689,7 @@ export default function ImporterView() {
|
||||
|
||||
{/* Jellyfin Importer Card */}
|
||||
{jellyfinConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||
@@ -844,7 +871,7 @@ export default function ImporterView() {
|
||||
|
||||
{/* Jellyfin Cleanup Card */}
|
||||
{jellyfinConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
@@ -937,20 +964,20 @@ export default function ImporterView() {
|
||||
|
||||
{/* Progress Section */}
|
||||
{progress.stage !== 'idle' && (
|
||||
<div className="bg-card border border-border rounded-xl p-6">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{progress.stage === 'complete' ? (
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="text-green-600" size={20} />
|
||||
<div className="w-10 h-10 bg-green-500/10 rounded-full flex items-center justify-center border border-green-500/30">
|
||||
<CheckCircle className="text-green-500" size={20} />
|
||||
</div>
|
||||
) : progress.stage === 'error' ? (
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<XCircle className="text-red-600" size={20} />
|
||||
<div className="w-10 h-10 bg-red-500/10 rounded-full flex items-center justify-center border border-red-500/30">
|
||||
<XCircle className="text-red-500" size={20} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground animate-spin" size={20} />
|
||||
<div className="w-10 h-10 bg-purple-500/10 rounded-full flex items-center justify-center border border-purple-500/30">
|
||||
<Loader2 className="text-purple-500 animate-spin" size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -968,7 +995,7 @@ export default function ImporterView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetImport}
|
||||
className="gap-2 font-bold border-border"
|
||||
className="gap-2 font-bold border-border/50 hover:border-[#6d28d9]/50 transition-all duration-300"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Reset
|
||||
@@ -983,7 +1010,7 @@ export default function ImporterView() {
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-300 ease-out",
|
||||
progress.stage === 'error' ? "bg-red-500" : "bg-[#6d28d9]"
|
||||
progress.stage === 'error' ? "bg-gradient-to-r from-red-500 to-red-600" : "bg-gradient-to-r from-[#6d28d9] to-[#f47298]"
|
||||
)}
|
||||
style={{ width: `${getProgressPercentage()}%` }}
|
||||
/>
|
||||
@@ -997,9 +1024,9 @@ export default function ImporterView() {
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Film size={16} className="text-muted-foreground" />
|
||||
<Film size={16} className="text-[#6d28d9]" />
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{(progress as any).gamesImported !== undefined ? 'Games' :
|
||||
(progress as any).moviesImported !== undefined ? 'Movies' :
|
||||
@@ -1014,16 +1041,16 @@ export default function ImporterView() {
|
||||
(progress as any).musicImported !== undefined ? (progress as any).musicImported : progress.videosImported}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users size={16} className="text-muted-foreground" />
|
||||
<Users size={16} className="text-[#6d28d9]" />
|
||||
<span className="text-xs font-bold text-muted-foreground">{(progress as any).castImported !== undefined ? 'Cast' : 'Actors'}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black text-foreground">{(progress as any).castImported !== undefined ? (progress as any).castImported : progress.actorsImported}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle size={16} className="text-muted-foreground" />
|
||||
<AlertCircle size={16} className="text-red-500" />
|
||||
<span className="text-xs font-bold text-muted-foreground">Errors</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black text-foreground">{progress.errors.length}</p>
|
||||
@@ -1034,7 +1061,7 @@ export default function ImporterView() {
|
||||
{importLog.length > 0 && (
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="bg-zinc-900 rounded-lg p-4 max-h-64 overflow-y-auto"
|
||||
className="bg-zinc-900/90 backdrop-blur-sm rounded-xl p-4 max-h-64 overflow-y-auto border border-border/50"
|
||||
>
|
||||
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">
|
||||
{importLog.join('\n')}
|
||||
@@ -1045,10 +1072,10 @@ export default function ImporterView() {
|
||||
{/* Errors */}
|
||||
{progress.errors.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-bold text-red-600 mb-2">Errors</h4>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 max-h-32 overflow-y-auto">
|
||||
<h4 className="text-sm font-bold text-red-500 mb-2">Errors</h4>
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 max-h-32 overflow-y-auto backdrop-blur-sm">
|
||||
{progress.errors.map((error, index) => (
|
||||
<p key={index} className="text-xs text-red-700 font-medium mb-1">
|
||||
<p key={index} className="text-xs text-red-500 font-medium mb-1">
|
||||
• {error}
|
||||
</p>
|
||||
))}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface LibrarySettingsProps {
|
||||
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
|
||||
Anime: <Tv size={18} />,
|
||||
Movies: <Film size={18} />,
|
||||
'TV Series': <Tv size={18} />,
|
||||
Music: <Music size={18} />,
|
||||
Books: <Book size={18} />,
|
||||
Consoles: <Gamepad2 size={18} />,
|
||||
@@ -34,29 +35,29 @@ export default function LibrarySettings({ enabledCategories, onToggleCategory }:
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-colors">
|
||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-all duration-300 hover:scale-110">
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
|
||||
<DialogContent className="sm:max-w-[425px] bg-card/50 backdrop-blur-sm rounded-3xl border border-border/50">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-black text-zinc-900">Library Settings</DialogTitle>
|
||||
<DialogDescription className="text-zinc-500 font-medium">
|
||||
<DialogTitle className="text-2xl font-black text-foreground">Library Settings</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground font-medium">
|
||||
Toggle which media areas you want to see in your library.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-6">
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 border border-zinc-100 transition-all hover:border-[#6d28d9]/20">
|
||||
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-muted/30 border border-border/50 transition-all hover:border-[#e8466c]/30 hover:bg-muted/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#e8466c] shadow-sm border border-border/30">
|
||||
{CATEGORY_ICONS[category]}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={category} className="text-sm font-black text-zinc-900 cursor-pointer">
|
||||
<Label htmlFor={category} className="text-sm font-black text-foreground cursor-pointer">
|
||||
{category}
|
||||
</Label>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
{enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+502
-32
@@ -1,14 +1,78 @@
|
||||
import { Media } from '@/types';
|
||||
import React, { useState } from 'react';
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'motion/react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Star,
|
||||
Heart,
|
||||
Gamepad2,
|
||||
Film,
|
||||
Tv,
|
||||
Eye,
|
||||
Play,
|
||||
Calendar,
|
||||
Hash,
|
||||
Trophy,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
interface MediaCardProps {
|
||||
key?: string;
|
||||
media: Media;
|
||||
onClick: (media: Media) => void;
|
||||
showBadge?: boolean;
|
||||
showFavorite?: boolean;
|
||||
isFavorite?: boolean;
|
||||
onFavoriteToggle?: (media: Media) => void;
|
||||
variant?: 'default' | 'compact' | 'hero' | 'minimal';
|
||||
}
|
||||
|
||||
export default function MediaCard({ media, onClick }: MediaCardProps) {
|
||||
const categoryConfig: Record<
|
||||
MediaCategory,
|
||||
{ label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive'; icon: React.ElementType | null }
|
||||
> = {
|
||||
Anime: { label: 'ANIME', variant: 'secondary', icon: null },
|
||||
Movies: { label: 'MOVIE', variant: 'secondary', icon: Film },
|
||||
'TV Series': { label: 'SERIES', variant: 'secondary', icon: Tv },
|
||||
Music: { label: 'MUSIC', variant: 'secondary', icon: null },
|
||||
Books: { label: 'BOOK', variant: 'secondary', icon: null },
|
||||
Games: { label: 'GAME', variant: 'secondary', icon: Gamepad2 },
|
||||
Consoles: { label: 'CONSOLE', variant: 'secondary', icon: null },
|
||||
Adult: { label: 'ADULT', variant: 'destructive', icon: Eye },
|
||||
};
|
||||
|
||||
const statusConfig: Record<
|
||||
string,
|
||||
{ label: string; color: string; ringColor: string }
|
||||
> = {
|
||||
watching: { label: 'Watching', color: 'bg-blue-500', ringColor: 'ring-blue-500' },
|
||||
completed: { label: 'Completed', color: 'bg-green-500', ringColor: 'ring-green-500' },
|
||||
planned: { label: 'Planned', color: 'bg-gray-500', ringColor: 'ring-gray-500' },
|
||||
dropped: { label: 'Dropped', color: 'bg-red-500', ringColor: 'ring-red-500' },
|
||||
reading: { label: 'Reading', color: 'bg-amber-500', ringColor: 'ring-amber-500' },
|
||||
listening: { label: 'Listening', color: 'bg-purple-500', ringColor: 'ring-purple-500' },
|
||||
playing: { label: 'Playing', color: 'bg-indigo-500', ringColor: 'ring-indigo-500' },
|
||||
'on-hold': { label: 'On Hold', color: 'bg-orange-500', ringColor: 'ring-orange-500' },
|
||||
};
|
||||
|
||||
export default function MediaCard({
|
||||
media,
|
||||
onClick,
|
||||
showBadge = true,
|
||||
showFavorite = true,
|
||||
isFavorite = false,
|
||||
onFavoriteToggle,
|
||||
variant = 'default'
|
||||
}: MediaCardProps) {
|
||||
const statusColors = {
|
||||
watching: 'bg-blue-500',
|
||||
completed: 'bg-green-500',
|
||||
@@ -43,40 +107,446 @@ export default function MediaCard({ media, onClick }: MediaCardProps) {
|
||||
'1/1': 'aspect-[1/1]',
|
||||
}[getAspectRatio()];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
const categoryInfo = categoryConfig[media.category];
|
||||
const CategoryIcon = categoryInfo?.icon;
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleFavoriteClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onFavoriteToggle?.(media);
|
||||
};
|
||||
|
||||
const formatPlayCount = (count?: number) => {
|
||||
if (!count) return null;
|
||||
if (count === 1) return '1x played';
|
||||
if (count < 1000) return `${count}x played`;
|
||||
return `${(count / 1000).toFixed(1)}k played`;
|
||||
};
|
||||
|
||||
const renderRating = () => {
|
||||
if (!media.rating) return null;
|
||||
const stars = Math.floor(media.rating / 2);
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
size={10}
|
||||
className={cn(
|
||||
i < stars
|
||||
? 'text-primary fill-primary'
|
||||
: 'text-muted-foreground/50'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs font-semibold">{media.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Rating: {media.rating}/10</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCategoryBadge = () => {
|
||||
if (!showBadge || !categoryInfo) return null;
|
||||
return (
|
||||
<Badge
|
||||
variant={categoryInfo.variant}
|
||||
className="absolute top-2 right-2 z-20 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider backdrop-blur-sm bg-opacity-90"
|
||||
>
|
||||
{CategoryIcon && <CategoryIcon size={10} />}
|
||||
{categoryInfo.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFavoriteButton = () => {
|
||||
if (!showFavorite) return null;
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: isHovered ? 1 : 0, scale: isHovered ? 1 : 0.8 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-2 left-2 z-20"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleFavoriteClick}
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full backdrop-blur-sm transition-all duration-200',
|
||||
isFavorite
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-black/50 text-white hover:bg-black/70 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Heart
|
||||
size={14}
|
||||
className={cn('transition-transform', isFavorite && 'fill-current scale-110')}
|
||||
/>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusIndicator = () => {
|
||||
if (!media.status) return null;
|
||||
const status = statusConfig[media.status];
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-2 z-20 w-3 h-3 rounded-full border-2 border-background shadow-md',
|
||||
status.color,
|
||||
showFavorite ? 'left-10' : 'left-2'
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Status: {status.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCompactVariant = () => (
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => onClick(media)}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
whileHover={{ y: -2 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative rounded-lg overflow-hidden shadow-lg bg-card transition-all duration-300",
|
||||
aspectRatioClass
|
||||
)}>
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
{media.status && (
|
||||
<div className={cn(
|
||||
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
|
||||
statusColors[media.status]
|
||||
)} />
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border-0 bg-muted/50 transition-all duration-300',
|
||||
aspectRatioClass,
|
||||
isHovered && 'ring-2 ring-primary/20 shadow-xl'
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<h3 className="text-sm font-bold text-foreground line-clamp-1 group-hover:text-[#6d28d9] transition-colors">
|
||||
{media.title}
|
||||
</h3>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{media.year}
|
||||
</p>
|
||||
</div>
|
||||
>
|
||||
<div className="absolute inset-0 bg-muted">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
||||
{renderCategoryBadge()}
|
||||
{renderFavoriteButton()}
|
||||
{renderStatusIndicator()}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2">
|
||||
<h3 className="text-xs font-semibold text-white line-clamp-1">{media.title}</h3>
|
||||
<p className="text-[10px] text-white/60">{media.year}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const renderMinimalVariant = () => (
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => onClick(media)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
whileHover={{ y: -2 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border-0 transition-all duration-300',
|
||||
aspectRatioClass,
|
||||
isHovered && 'shadow-lg'
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-muted">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent opacity-0 transition-opacity duration-300',
|
||||
isHovered && 'opacity-100'
|
||||
)}
|
||||
/>
|
||||
{showFavorite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleFavoriteClick}
|
||||
className={cn(
|
||||
'absolute top-2 right-2 h-7 w-7 rounded-full backdrop-blur-sm opacity-0 transition-opacity duration-300',
|
||||
isHovered && 'opacity-100',
|
||||
isFavorite
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-black/50 text-white hover:bg-black/70'
|
||||
)}
|
||||
>
|
||||
<Heart size={14} className={cn(isFavorite && 'fill-current')} />
|
||||
</Button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 p-3 translate-y-2 opacity-0 transition-all duration-300',
|
||||
isHovered && 'translate-y-0 opacity-100'
|
||||
)}
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-white line-clamp-2">{media.title}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-white/70">{media.year}</span>
|
||||
{media.rating && (
|
||||
<>
|
||||
<span className="text-[10px] text-white/50">•</span>
|
||||
<span className="text-[10px] text-white/70">{media.rating.toFixed(1)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const renderDefaultVariant = () => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => onClick(media)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border-0 bg-card transition-all duration-300',
|
||||
aspectRatioClass,
|
||||
isHovered && 'ring-2 ring-primary/30 shadow-2xl'
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-muted">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/95 via-black/40 to-transparent" />
|
||||
|
||||
{renderCategoryBadge()}
|
||||
{renderFavoriteButton()}
|
||||
{renderStatusIndicator()}
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 space-y-2">
|
||||
<h3 className="text-sm font-bold text-white line-clamp-2 leading-tight">
|
||||
{media.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">{renderRating()}</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-white/10" />
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1 text-white/70">
|
||||
<Calendar size={11} />
|
||||
{media.year}
|
||||
</span>
|
||||
{media.playCount && media.playCount > 0 && (
|
||||
<>
|
||||
<span className="text-white/30">•</span>
|
||||
<span className="flex items-center gap-1 text-white/70">
|
||||
<Play size={11} />
|
||||
{formatPlayCount(media.playCount)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{media.studios && media.studios.length > 0 && (
|
||||
<>
|
||||
<span className="text-white/30">•</span>
|
||||
<span className="truncate max-w-[100px] text-white/50">
|
||||
{media.studios[0]}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{media.genres && media.genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{media.genres.slice(0, 2).map((genre) => (
|
||||
<Badge key={genre} variant="outline" className="text-[9px] py-0 h-4 border-white/20 text-white/60">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
{media.genres.length > 2 && (
|
||||
<Badge variant="outline" className="text-[9px] py-0 h-4 border-white/20 text-white/60">
|
||||
+{media.genres.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHovered && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute inset-0 bg-primary/5 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{media.title}</p>
|
||||
{media.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{media.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs pt-1">
|
||||
<span>{media.category}</span>
|
||||
{media.year && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{media.year}</span>
|
||||
</>
|
||||
)}
|
||||
{media.rating && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{media.rating}/10</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
const renderHeroVariant = () => (
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => onClick(media)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border-0 bg-card transition-all duration-300',
|
||||
aspectRatioClass,
|
||||
isHovered && 'ring-2 ring-primary/30 shadow-2xl'
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-muted">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent" />
|
||||
|
||||
{renderCategoryBadge()}
|
||||
{renderFavoriteButton()}
|
||||
{renderStatusIndicator()}
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-3">
|
||||
{media.rating && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Trophy size={12} className="mr-1" />
|
||||
{media.rating.toFixed(1)}/10
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<h3 className="text-lg font-bold text-white line-clamp-2 leading-tight">
|
||||
{media.title}
|
||||
</h3>
|
||||
|
||||
{media.description && (
|
||||
<p className="text-sm text-white/70 line-clamp-2">{media.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="flex items-center gap-1 text-white/80">
|
||||
<Calendar size={14} />
|
||||
{media.year}
|
||||
</span>
|
||||
{media.playCount && media.playCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-white/80">
|
||||
<Play size={14} />
|
||||
{formatPlayCount(media.playCount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{media.genres && media.genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{media.genres.slice(0, 4).map((genre) => (
|
||||
<Badge key={genre} variant="outline" className="text-xs border-white/20 text-white/70">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHovered && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute inset-0 bg-primary/5 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const renderVariant = () => {
|
||||
switch (variant) {
|
||||
case 'compact':
|
||||
return renderCompactVariant();
|
||||
case 'minimal':
|
||||
return renderMinimalVariant();
|
||||
case 'hero':
|
||||
return renderHeroVariant();
|
||||
default:
|
||||
return renderDefaultVariant();
|
||||
}
|
||||
};
|
||||
|
||||
return renderVariant();
|
||||
}
|
||||
|
||||
@@ -1,100 +1,114 @@
|
||||
import { Media } from '@/types';
|
||||
import React from 'react';
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'motion/react';
|
||||
import { Star, Play, Bookmark } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Star, Heart, Gamepad2, Film, Tv, Eye } from 'lucide-react';
|
||||
|
||||
interface MediaListItemProps {
|
||||
key?: string;
|
||||
media: Media;
|
||||
onClick: (media: Media) => void;
|
||||
isFavorite?: boolean;
|
||||
onFavoriteToggle?: (media: Media) => void;
|
||||
}
|
||||
|
||||
export default function MediaListItem({ media, onClick }: MediaListItemProps) {
|
||||
const statusColors = {
|
||||
watching: 'bg-blue-500',
|
||||
completed: 'bg-green-500',
|
||||
planned: 'bg-gray-500',
|
||||
dropped: 'bg-red-500',
|
||||
reading: 'bg-amber-500',
|
||||
listening: 'bg-purple-500',
|
||||
playing: 'bg-indigo-500',
|
||||
'on-hold': 'bg-orange-500',
|
||||
};
|
||||
const categoryConfig: Record<MediaCategory, { label: string; color: string; bgColor: string; icon: any }> = {
|
||||
'Anime': { label: 'ANIME', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: null },
|
||||
'Movies': { label: 'MOVIE', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Film },
|
||||
'TV Series': { label: 'SERIES', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: Tv },
|
||||
'Music': { label: 'MUSIC', color: 'text-pink-400', bgColor: 'bg-pink-500/20', icon: null },
|
||||
'Books': { label: 'BOOK', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: null },
|
||||
'Games': { label: 'GAME', color: 'text-indigo-400', bgColor: 'bg-indigo-500/20', icon: Gamepad2 },
|
||||
'Consoles': { label: 'CONSOLE', color: 'text-orange-400', bgColor: 'bg-orange-500/20', icon: null },
|
||||
'Adult': { label: 'ADULT', color: 'text-rose-400', bgColor: 'bg-rose-500/20', icon: Eye },
|
||||
};
|
||||
|
||||
const getAspectRatio = () => {
|
||||
if (media.aspectRatio) return media.aspectRatio;
|
||||
switch (media.category) {
|
||||
case 'Music': return '1/1';
|
||||
case 'Games':
|
||||
case 'Adult': return '16/9';
|
||||
default: return '2/3';
|
||||
}
|
||||
};
|
||||
export default function MediaListItem({ media, onClick, isFavorite = false, onFavoriteToggle }: MediaListItemProps) {
|
||||
const categoryInfo = categoryConfig[media.category];
|
||||
const CategoryIcon = categoryInfo?.icon;
|
||||
|
||||
const aspectRatioClass = {
|
||||
'2/3': 'w-24 h-32',
|
||||
'16/9': 'w-48 h-27', // 16:9 ratio for w-48 is approx h-27
|
||||
'1/1': 'w-24 h-24',
|
||||
}[getAspectRatio()];
|
||||
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onFavoriteToggle?.(media);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="group flex items-center gap-6 p-4 rounded-xl hover:bg-muted/50 transition-colors cursor-pointer border border-transparent hover:border-border"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="group flex items-center px-4 py-2 hover:bg-muted/30 transition-colors cursor-pointer border-b border-border/30 last:border-b-0"
|
||||
onClick={() => onClick(media)}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative rounded-lg overflow-hidden shrink-0 shadow-md bg-card transition-all duration-300",
|
||||
aspectRatioClass
|
||||
)}>
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
{media.status && (
|
||||
<div className={cn(
|
||||
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
|
||||
statusColors[media.status]
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-lg font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
{media.title}
|
||||
</h3>
|
||||
<span className="text-sm font-bold text-muted-foreground">({media.year})</span>
|
||||
{/* TITLE Column: Poster + Title + Rating (like screenshot 2) */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-3 mr-4">
|
||||
{/* Poster Thumbnail */}
|
||||
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-muted">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className="flex items-center gap-1 text-xs font-bold text-muted-foreground">
|
||||
<Star size={14} className="text-yellow-500" fill="currentColor" />
|
||||
{media.rating || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{media.genres?.slice(0, 3).join(' • ') || 'Anime'}
|
||||
{/* Title + Rating stacked */}
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-foreground truncate group-hover:text-[#e8466c] transition-colors">
|
||||
{media.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Star size={10} className="text-[#e8466c] fill-[#e8466c]" />
|
||||
<span className="text-xs font-medium text-[#e8466c]">
|
||||
{media.rating?.toFixed(1) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 max-w-2xl">
|
||||
{media.description || "No description available for this title."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" className="rounded-full text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
|
||||
<Play size={18} fill="currentColor" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="rounded-full text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
|
||||
<Bookmark size={18} />
|
||||
</Button>
|
||||
{/* TYPE Column */}
|
||||
<div className="w-[70px] shrink-0 mr-4">
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
|
||||
categoryInfo.bgColor,
|
||||
categoryInfo.color
|
||||
)}>
|
||||
{CategoryIcon && <CategoryIcon size={9} />}
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* GENRE Column */}
|
||||
<div className="w-[140px] shrink-0 mr-4">
|
||||
<span className="text-sm text-muted-foreground truncate block">
|
||||
{media.genres?.slice(0, 2).join(', ') || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* YEAR Column */}
|
||||
<div className="w-[60px] shrink-0 text-center mr-4">
|
||||
<span className="text-sm text-muted-foreground/80">{media.year}</span>
|
||||
</div>
|
||||
|
||||
{/* PLAYS Column */}
|
||||
<div className="w-[50px] shrink-0 text-right mr-4">
|
||||
<span className="text-sm text-muted-foreground/80">{media.playCount || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* FAVORITE Column (Heart) */}
|
||||
<div className="w-8 shrink-0 flex justify-end">
|
||||
<button
|
||||
onClick={handleFavoriteClick}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
isFavorite
|
||||
? "text-[#e8466c]"
|
||||
: "text-muted-foreground/40 hover:text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
<Heart size={14} className={cn(isFavorite && "fill-current")} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Star,
|
||||
Heart,
|
||||
Gamepad2,
|
||||
Film,
|
||||
Tv,
|
||||
Eye,
|
||||
Music,
|
||||
BookOpen,
|
||||
Monitor,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
interface MediaTableProps {
|
||||
mediaList: Media[];
|
||||
onMediaClick: (media: Media) => void;
|
||||
onFavoriteToggle?: (media: Media) => void;
|
||||
favoriteIds?: Set<string>;
|
||||
}
|
||||
|
||||
type SortField = 'title' | 'category' | 'genre' | 'rating' | 'year' | 'plays';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const categoryConfig: Record<MediaCategory, {
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
icon: React.ElementType | null;
|
||||
}> = {
|
||||
'Anime': { label: 'ANIME', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: null },
|
||||
'Movies': { label: 'MOVIE', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Film },
|
||||
'TV Series': { label: 'SERIES', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: Tv },
|
||||
'Music': { label: 'MUSIC', color: 'text-pink-400', bgColor: 'bg-pink-500/20', icon: Music },
|
||||
'Books': { label: 'BOOK', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: BookOpen },
|
||||
'Games': { label: 'GAME', color: 'text-indigo-400', bgColor: 'bg-indigo-500/20', icon: Gamepad2 },
|
||||
'Consoles': { label: 'CONSOLE', color: 'text-orange-400', bgColor: 'bg-orange-500/20', icon: Monitor },
|
||||
'Adult': { label: 'ADULT', color: 'text-rose-400', bgColor: 'bg-rose-500/20', icon: Eye },
|
||||
};
|
||||
|
||||
export default function MediaTable({
|
||||
mediaList,
|
||||
onMediaClick,
|
||||
onFavoriteToggle,
|
||||
favoriteIds = new Set()
|
||||
}: MediaTableProps) {
|
||||
const [sortField, setSortField] = useState<SortField>('title');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedMedia = useMemo(() => {
|
||||
const sorted = [...mediaList];
|
||||
sorted.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'title':
|
||||
comparison = a.title.localeCompare(b.title);
|
||||
break;
|
||||
case 'category':
|
||||
comparison = a.category.localeCompare(b.category);
|
||||
break;
|
||||
case 'genre':
|
||||
const genreA = a.genres?.[0] || '';
|
||||
const genreB = b.genres?.[0] || '';
|
||||
comparison = genreA.localeCompare(genreB);
|
||||
break;
|
||||
case 'rating':
|
||||
comparison = (b.rating || 0) - (a.rating || 0);
|
||||
break;
|
||||
case 'year':
|
||||
comparison = b.year.localeCompare(a.year);
|
||||
break;
|
||||
case 'plays':
|
||||
comparison = (b.playCount || 0) - (a.playCount || 0);
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
return sorted;
|
||||
}, [mediaList, sortField, sortDirection]);
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) {
|
||||
return <ArrowUpDown size={14} className="text-muted-foreground/40 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />;
|
||||
}
|
||||
return sortDirection === 'asc'
|
||||
? <ArrowUp size={14} className="text-[#e8466c] ml-1" />
|
||||
: <ArrowDown size={14} className="text-[#e8466c] ml-1" />;
|
||||
};
|
||||
|
||||
const handleFavoriteClick = (e: React.MouseEvent, media: Media) => {
|
||||
e.stopPropagation();
|
||||
onFavoriteToggle?.(media);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table className="w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-border/20 hover:bg-transparent">
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[45%]"
|
||||
onClick={() => handleSort('title')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Title <SortIcon field="title" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[80px]"
|
||||
onClick={() => handleSort('category')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Type <SortIcon field="category" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[18%]"
|
||||
onClick={() => handleSort('genre')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Genre <SortIcon field="genre" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[70px] text-center"
|
||||
onClick={() => handleSort('rating')}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
Rating <SortIcon field="rating" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[60px] text-center"
|
||||
onClick={() => handleSort('year')}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
Year <SortIcon field="year" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[60px] text-right"
|
||||
onClick={() => handleSort('plays')}
|
||||
>
|
||||
<div className="flex items-center justify-end">
|
||||
Plays <SortIcon field="plays" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[40px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedMedia.map((media) => {
|
||||
const categoryInfo = categoryConfig[media.category];
|
||||
const CategoryIcon = categoryInfo?.icon;
|
||||
const isFavorite = favoriteIds.has(media.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={media.id}
|
||||
className="border-b border-border/20 hover:bg-muted/30 transition-colors cursor-pointer group"
|
||||
onClick={() => onMediaClick(media)}
|
||||
>
|
||||
{/* Title Cell with Poster */}
|
||||
<TableCell className="py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-muted">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate group-hover:text-[#e8466c] transition-colors">
|
||||
{media.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Type Badge */}
|
||||
<TableCell>
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
|
||||
categoryInfo.bgColor,
|
||||
categoryInfo.color
|
||||
)}>
|
||||
{CategoryIcon && <CategoryIcon size={9} />}
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Genre */}
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground truncate block">
|
||||
{media.genres?.join(', ') || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Rating */}
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Star size={12} className="text-[#e8466c] fill-[#e8466c]" />
|
||||
<span className="text-sm font-medium text-foreground/80">
|
||||
{media.rating?.toFixed(1) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Year */}
|
||||
<TableCell className="text-center">
|
||||
<span className="text-sm text-muted-foreground/80">{media.year}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Plays */}
|
||||
<TableCell className="text-right">
|
||||
<span className="text-sm text-muted-foreground/80">{media.playCount || 0}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Favorite */}
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={(e) => handleFavoriteClick(e, media)}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
isFavorite
|
||||
? "text-[#e8466c]"
|
||||
: "text-muted-foreground/40 hover:text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
<Heart size={14} className={cn(isFavorite && "fill-current")} />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
+474
-236
@@ -1,21 +1,34 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MediaCategory, UserSettings } from '@/types';
|
||||
import { MediaCategory, UserSettings, CustomColors } from '@/types';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Film, Music, Book, Tv, Gamepad2, ShieldAlert, LayoutGrid, List, Globe, Monitor, Sun, Moon, Save, ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
Film, Music, BookOpen, Tv, Gamepad2, ShieldAlert, LayoutGrid, List, Globe, Monitor, Sun, Moon,
|
||||
Save, ArrowLeft, Type, Image as ImageIcon, Palette, Library, Eye, Sparkles, Languages, Settings2,
|
||||
Check, AlertCircle, MonitorPlay
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { fetchSettings, updateSettings } from '@/api';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
|
||||
Anime: <Tv size={18} />,
|
||||
Movies: <Film size={18} />,
|
||||
'TV Series': <Tv size={18} />,
|
||||
Music: <Music size={18} />,
|
||||
Books: <Book size={18} />,
|
||||
Consoles: <Gamepad2 size={18} />,
|
||||
Games: <Gamepad2 size={18} />,
|
||||
Adult: <ShieldAlert size={18} />,
|
||||
const CATEGORY_ICONS: Record<MediaCategory, React.ElementType> = {
|
||||
Anime: Tv,
|
||||
Movies: Film,
|
||||
'TV Series': Tv,
|
||||
Music: Music,
|
||||
Books: BookOpen,
|
||||
Consoles: Gamepad2,
|
||||
Games: Gamepad2,
|
||||
Adult: ShieldAlert,
|
||||
};
|
||||
|
||||
const ITEMS_PER_PAGE_OPTIONS = [12, 20, 36, 48, 60];
|
||||
@@ -32,7 +45,9 @@ interface SettingsViewProps {
|
||||
}
|
||||
|
||||
export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const { setTheme } = useTheme();
|
||||
const [activeTab, setActiveTab] = useState('library');
|
||||
const [settings, setSettings] = useState<UserSettings>({
|
||||
enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'],
|
||||
itemsPerPage: 20,
|
||||
@@ -47,6 +62,12 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
|
||||
// Page Settings State
|
||||
const [pageTitle, setPageTitle] = useState<string>('');
|
||||
const [favicon, setFavicon] = useState<string>('');
|
||||
const [customColors, setCustomColors] = useState<CustomColors>({});
|
||||
const [faviconPreview, setFaviconPreview] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
@@ -56,6 +77,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
const loadedSettings = await fetchSettings();
|
||||
if (loadedSettings) {
|
||||
setSettings(loadedSettings);
|
||||
setPageTitle(loadedSettings.pageTitle || '');
|
||||
setFavicon(loadedSettings.favicon || '');
|
||||
setCustomColors(loadedSettings.customColors || {});
|
||||
setFaviconPreview(loadedSettings.favicon || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
@@ -68,7 +93,13 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
setIsSaving(true);
|
||||
setSaveStatus('idle');
|
||||
try {
|
||||
const savedSettings = await updateSettings(settings);
|
||||
const updatedSettings: UserSettings = {
|
||||
...settings,
|
||||
pageTitle: pageTitle || undefined,
|
||||
favicon: favicon || undefined,
|
||||
customColors: Object.keys(customColors).length > 0 ? customColors : undefined,
|
||||
};
|
||||
const savedSettings = await updateSettings(updatedSettings);
|
||||
if (savedSettings) {
|
||||
setSettings(savedSettings);
|
||||
setSaveStatus('success');
|
||||
@@ -96,6 +127,31 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFaviconUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setFavicon(base64);
|
||||
setFaviconPreview(base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFavicon = () => {
|
||||
setFavicon('');
|
||||
setFaviconPreview('');
|
||||
};
|
||||
|
||||
const handleColorChange = (colorKey: keyof CustomColors, value: string) => {
|
||||
setCustomColors(prev => ({
|
||||
...prev,
|
||||
[colorKey]: value || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
@@ -104,245 +160,427 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const enabledCount = settings.enabledCategories.length;
|
||||
const totalCategories = 8;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pt-20">
|
||||
{/* Content */}
|
||||
<div className="max-w-[1600px] mx-auto px-6 py-12">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-[#6d28d9] transition-colors mb-2"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back to home
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black text-foreground">Settings</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-[#6d28d9] text-white hover:bg-[#5b21b6] font-bold px-6 py-3 h-12 rounded-lg flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? (
|
||||
'Saving...'
|
||||
) : (
|
||||
<>
|
||||
<Save size={16} />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveStatus === 'success' && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 font-medium">
|
||||
Settings saved successfully!
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 font-medium">
|
||||
Failed to save settings. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-8">
|
||||
{/* Library Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Library Settings</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-4">
|
||||
Toggle which media areas you want to see in your library.
|
||||
</p>
|
||||
<div className="grid gap-4">
|
||||
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => (
|
||||
<div key={category} className="flex items-center justify-between p-4 rounded-xl bg-background border border-border transition-all hover:border-[#6d28d9]/20">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center text-[#6d28d9]">
|
||||
{CATEGORY_ICONS[category]}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={category} className="text-sm font-black text-foreground cursor-pointer">
|
||||
{category}
|
||||
</Label>
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
{settings.enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id={category}
|
||||
checked={settings.enabledCategories.includes(category)}
|
||||
onCheckedChange={() => toggleCategory(category)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="min-h-screen bg-background pb-16">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border/50">
|
||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage your preferences</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<AnimatePresence mode="wait">
|
||||
{saveStatus === 'success' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="flex items-center gap-2 text-sm text-emerald-500 bg-emerald-500/10 px-3 py-1.5 rounded-lg"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Saved
|
||||
</motion.div>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="flex items-center gap-2 text-sm text-red-500 bg-red-500/10 px-3 py-1.5 rounded-lg"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Error
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 py-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-6 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto flex-wrap">
|
||||
<TabsTrigger value="library" className="gap-2">
|
||||
<Library className="h-4 w-4" />
|
||||
Library
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="display" className="gap-2">
|
||||
<Monitor className="h-4 w-4" />
|
||||
Display
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="content" className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
Content
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="appearance" className="gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
Appearance
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Library Settings */}
|
||||
<TabsContent value="library" className="mt-0 space-y-6">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Media Categories</CardTitle>
|
||||
<CardDescription>Toggle which media types appear in your library</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">{enabledCount}/{totalCategories} enabled</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3">
|
||||
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => {
|
||||
const Icon = CATEGORY_ICONS[category];
|
||||
const isEnabled = settings.enabledCategories.includes(category);
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-4 rounded-lg border transition-all cursor-pointer",
|
||||
isEnabled
|
||||
? "bg-background border-primary/30"
|
||||
: "bg-muted/30 border-border/50 opacity-60"
|
||||
)}
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-lg flex items-center justify-center border",
|
||||
isEnabled
|
||||
? "bg-primary/10 text-primary border-primary/20"
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{category}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEnabled ? 'Visible in library' : 'Hidden'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={() => toggleCategory(category)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Display Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Display Settings</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border space-y-6">
|
||||
{/* Items per page */}
|
||||
<div>
|
||||
<Label className="text-sm font-black text-foreground mb-2 block">Items per page</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{ITEMS_PER_PAGE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: option }))}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${
|
||||
settings.itemsPerPage === option
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="display" className="mt-0 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle>View Options</CardTitle>
|
||||
<CardDescription>Configure how items are displayed</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Items per page */}
|
||||
<div className="space-y-3">
|
||||
<Label>Items per page</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{ITEMS_PER_PAGE_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
variant={settings.itemsPerPage === option ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: option }))}
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default view */}
|
||||
<div>
|
||||
<Label className="text-sm font-black text-foreground mb-2 block">Default view</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||
settings.defaultView === 'grid'
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={18} />
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'list' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||
settings.defaultView === 'list'
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
<List size={18} />
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Grid item size */}
|
||||
<div>
|
||||
<Label className="text-sm font-black text-foreground mb-2 block">Grid item size</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-bold text-muted-foreground">Small</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={settings.gridItemSize}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, gridItemSize: Number(e.target.value) }))}
|
||||
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
|
||||
/>
|
||||
<span className="text-xs font-bold text-muted-foreground">Large</span>
|
||||
<span className="text-sm font-bold text-[#6d28d9] w-8 text-center">{settings.gridItemSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Default view */}
|
||||
<div className="space-y-3">
|
||||
<Label>Default view</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant={settings.defaultView === 'grid' ? 'default' : 'outline'}
|
||||
className="justify-center gap-2"
|
||||
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Grid
|
||||
</Button>
|
||||
<Button
|
||||
variant={settings.defaultView === 'list' ? 'default' : 'outline'}
|
||||
className="justify-center gap-2"
|
||||
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'list' }))}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<div>
|
||||
<Label className="text-sm font-black text-foreground mb-2 block">Theme</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['light', 'dark', 'system'] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => setSettings(prev => ({ ...prev, theme }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||
settings.theme === theme
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{theme === 'light' && <Sun size={18} />}
|
||||
{theme === 'dark' && <Moon size={18} />}
|
||||
{theme === 'system' && <Monitor size={18} />}
|
||||
{theme.charAt(0).toUpperCase() + theme.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Grid item size */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Grid item size</Label>
|
||||
<span className="text-sm font-medium text-primary">{settings.gridItemSize}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-muted-foreground">Small</span>
|
||||
<Slider
|
||||
value={settings.gridItemSize}
|
||||
min={1}
|
||||
max={10}
|
||||
onValueChange={(value) => setSettings(prev => ({ ...prev, gridItemSize: value }))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Large</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Languages className="h-4 w-4 text-primary" />
|
||||
Language
|
||||
</CardTitle>
|
||||
<CardDescription>Interface language preference</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={settings.language === option.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSettings(prev => ({ ...prev, language: option.value }))}
|
||||
className="justify-center"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
{/* Content Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Content Settings</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border space-y-4">
|
||||
{/* Show adult content */}
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-background border border-border">
|
||||
<div>
|
||||
<Label htmlFor="showAdult" className="text-sm font-black text-foreground cursor-pointer">
|
||||
Show adult content
|
||||
</Label>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">
|
||||
Display adult media in your library
|
||||
</p>
|
||||
<TabsContent value="content" className="mt-0 space-y-6">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle>Content Preferences</CardTitle>
|
||||
<CardDescription>Control what content is shown</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30 border border-border/50">
|
||||
<div>
|
||||
<Label htmlFor="showAdult" className="cursor-pointer">Show adult content</Label>
|
||||
<p className="text-sm text-muted-foreground">Display adult media in your library</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showAdult"
|
||||
checked={settings.showAdultContent}
|
||||
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, showAdultContent: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="showAdult"
|
||||
checked={settings.showAdultContent}
|
||||
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, showAdultContent: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-play trailers */}
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-background border border-border">
|
||||
<div>
|
||||
<Label htmlFor="autoPlay" className="text-sm font-black text-foreground cursor-pointer">
|
||||
Auto-play trailers
|
||||
</Label>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">
|
||||
Automatically play trailers when viewing media
|
||||
</p>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30 border border-border/50">
|
||||
<div>
|
||||
<Label htmlFor="autoPlay" className="cursor-pointer">Auto-play trailers</Label>
|
||||
<p className="text-sm text-muted-foreground">Automatically play trailers when viewing media</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="autoPlay"
|
||||
checked={settings.autoPlayTrailers}
|
||||
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="autoPlay"
|
||||
checked={settings.autoPlayTrailers}
|
||||
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Language Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Language</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Globe size={18} className="text-[#6d28d9]" />
|
||||
<Label className="text-sm font-black text-foreground">Interface language</Label>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setSettings(prev => ({ ...prev, language: option.value }))}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${
|
||||
settings.language === option.value
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Appearance Settings */}
|
||||
<TabsContent value="appearance" className="mt-0 space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
Theme
|
||||
</CardTitle>
|
||||
<CardDescription>Choose your preferred color scheme</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{([
|
||||
{ value: 'light' as const, icon: Sun, label: 'Light' },
|
||||
{ value: 'dark' as const, icon: Moon, label: 'Dark' },
|
||||
{ value: 'system' as const, icon: Monitor, label: 'System' },
|
||||
]).map(({ value, icon: Icon, label }) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={settings.theme === value ? 'default' : 'outline'}
|
||||
className="flex-col gap-2 h-auto py-4"
|
||||
onClick={() => setSettings(prev => ({ ...prev, theme: value }))}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-xs">{label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Type className="h-4 w-4 text-primary" />
|
||||
Page Title
|
||||
</CardTitle>
|
||||
<CardDescription>Customize the page title</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
value={pageTitle}
|
||||
onChange={(e) => setPageTitle(e.target.value)}
|
||||
placeholder="Leave empty for default title"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Custom title for your page. Leave empty to use the default title.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-primary" />
|
||||
Favicon
|
||||
</CardTitle>
|
||||
<CardDescription>Upload a custom favicon</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
{faviconPreview && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={faviconPreview}
|
||||
alt="Favicon preview"
|
||||
className="w-16 h-16 rounded-lg object-cover border border-border"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRemoveFavicon}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFaviconUpload}
|
||||
className="hidden"
|
||||
id="favicon-upload"
|
||||
/>
|
||||
<label htmlFor="favicon-upload">
|
||||
<Button variant="outline" className="cursor-pointer" asChild>
|
||||
<span>{favicon ? 'Change favicon' : 'Upload favicon'}</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
The image will be converted to Base64 format.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-primary" />
|
||||
Custom Colors
|
||||
</CardTitle>
|
||||
<CardDescription>Customize the application colors</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-4">
|
||||
{[
|
||||
{ key: 'primary', label: 'Primary' },
|
||||
{ key: 'secondary', label: 'Secondary' },
|
||||
{ key: 'background', label: 'Background' },
|
||||
{ key: 'surface', label: 'Surface' },
|
||||
{ key: 'text', label: 'Text' },
|
||||
{ key: 'muted', label: 'Muted' },
|
||||
{ key: 'border', label: 'Border' },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customColors[key as keyof CustomColors] || '#e8466c'}
|
||||
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
|
||||
className="w-10 h-10 rounded-lg cursor-pointer border-0 p-0"
|
||||
/>
|
||||
<Input
|
||||
value={customColors[key as keyof CustomColors] || ''}
|
||||
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
|
||||
placeholder="#e8466c"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Leave color fields empty to use the default theme colors.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Library,
|
||||
Users,
|
||||
FolderKanban,
|
||||
Database,
|
||||
Settings,
|
||||
Sun,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Plus,
|
||||
Film,
|
||||
Tv,
|
||||
Gamepad2,
|
||||
Heart,
|
||||
Eye,
|
||||
Flame,
|
||||
Clock,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { MediaCategory } from '@/types';
|
||||
|
||||
interface SidebarProps {
|
||||
enabledCategories: MediaCategory[];
|
||||
onToggleCategory: (category: MediaCategory) => void;
|
||||
pageTitle?: string;
|
||||
mediaCounts?: {
|
||||
all: number;
|
||||
movies: number;
|
||||
series: number;
|
||||
games: number;
|
||||
adult: number;
|
||||
favorites: number;
|
||||
};
|
||||
activeFilter?: string;
|
||||
onFilterChange?: (filter: string) => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
enabledCategories,
|
||||
onToggleCategory,
|
||||
pageTitle,
|
||||
mediaCounts = { all: 24, movies: 8, series: 6, games: 6, adult: 4, favorites: 11 },
|
||||
activeFilter = 'all',
|
||||
onFilterChange
|
||||
}: SidebarProps) {
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
console.log('Logout clicked');
|
||||
};
|
||||
|
||||
const handleFilterClick = (filter: string) => {
|
||||
onFilterChange?.(filter);
|
||||
if (filter === 'all') {
|
||||
navigate('/browse');
|
||||
} else if (filter === 'movies') {
|
||||
navigate('/movies');
|
||||
} else if (filter === 'series') {
|
||||
navigate('/tv-series');
|
||||
} else if (filter === 'games') {
|
||||
navigate('/games');
|
||||
} else if (filter === 'adult') {
|
||||
navigate('/adult');
|
||||
} else if (filter === 'favorites') {
|
||||
navigate('/browse?favorites=true');
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickFilter = (filter: string) => {
|
||||
if (filter === 'most-played') {
|
||||
navigate('/browse?sort=plays');
|
||||
} else if (filter === 'recently-added') {
|
||||
navigate('/browse?sort=recent');
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(!isMobileOpen)}
|
||||
className="lg:hidden fixed top-4 left-4 z-50 p-2 bg-card rounded-lg border border-border/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
{isMobileOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{isMobileOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 top-0 bottom-0 w-64 bg-[#0d0f14] border-r border-white/5 z-50 flex flex-col transition-transform duration-300',
|
||||
isMobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-[#e8466c] to-[#f47298] rounded-lg flex items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 text-white" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">{pageTitle || 'MediaVault'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
|
||||
{/* Main Navigation */}
|
||||
<NavLink
|
||||
to="/"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
|
||||
isActive('/')
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<LayoutDashboard size={18} />
|
||||
<span className="font-medium text-sm">Dashboard</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/browse"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
|
||||
isActive('/browse') || isActive('/movies') || isActive('/tv-series') || isActive('/games') || isActive('/adult')
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Library size={18} />
|
||||
<span className="font-medium text-sm">Library</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/cast"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
|
||||
isActive('/cast')
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Users size={18} />
|
||||
<span className="font-medium text-sm">Actors</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/collections"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
|
||||
isActive('/collections')
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<FolderKanban size={18} />
|
||||
<span className="font-medium text-sm">Collections</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/sources"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
|
||||
isActive('/sources')
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Database size={18} />
|
||||
<span className="font-medium text-sm">Sources</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
|
||||
isActive('/settings')
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Settings size={18} />
|
||||
<span className="font-medium text-sm">Settings</span>
|
||||
</NavLink>
|
||||
|
||||
{/* MEDIA TYPE Section */}
|
||||
<div className="mt-6">
|
||||
<div className="px-3 mb-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Media Type</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleFilterClick('all')}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
|
||||
activeFilter === 'all'
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Library size={16} />
|
||||
<span className="text-sm">All</span>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full',
|
||||
activeFilter === 'all' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
|
||||
)}>
|
||||
{mediaCounts.all}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{enabledCategories.includes('Movies') && (
|
||||
<button
|
||||
onClick={() => handleFilterClick('movies')}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
|
||||
activeFilter === 'movies' || location.pathname === '/movies'
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Film size={16} />
|
||||
<span className="text-sm">Movies</span>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full',
|
||||
activeFilter === 'movies' || location.pathname === '/movies' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
|
||||
)}>
|
||||
{mediaCounts.movies}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{enabledCategories.includes('TV Series') && (
|
||||
<button
|
||||
onClick={() => handleFilterClick('series')}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
|
||||
activeFilter === 'series' || location.pathname === '/tv-series'
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Tv size={16} />
|
||||
<span className="text-sm">Series</span>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full',
|
||||
activeFilter === 'series' || location.pathname === '/tv-series' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
|
||||
)}>
|
||||
{mediaCounts.series}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{enabledCategories.includes('Games') && (
|
||||
<button
|
||||
onClick={() => handleFilterClick('games')}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
|
||||
activeFilter === 'games' || location.pathname === '/games'
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Gamepad2 size={16} />
|
||||
<span className="text-sm">Games</span>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full',
|
||||
activeFilter === 'games' || location.pathname === '/games' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
|
||||
)}>
|
||||
{mediaCounts.games}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{enabledCategories.includes('Adult') && (
|
||||
<button
|
||||
onClick={() => handleFilterClick('adult')}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
|
||||
activeFilter === 'adult' || location.pathname === '/adult'
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Eye size={16} />
|
||||
<span className="text-sm">Adult</span>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full',
|
||||
activeFilter === 'adult' || location.pathname === '/adult' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
|
||||
)}>
|
||||
{mediaCounts.adult}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleFilterClick('favorites')}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
|
||||
activeFilter === 'favorites'
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Heart size={16} />
|
||||
<span className="text-sm">Favorites</span>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full',
|
||||
activeFilter === 'favorites' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
|
||||
)}>
|
||||
{mediaCounts.favorites}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* QUICK FILTER Section */}
|
||||
<div className="mt-6">
|
||||
<div className="px-3 mb-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Quick Filter</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleQuickFilter('most-played')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<Flame size={16} className="text-orange-500" />
|
||||
<span className="text-sm">Most Played</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleQuickFilter('recently-added')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<Clock size={16} className="text-cyan-500" />
|
||||
<span className="text-sm">Recently Added</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="p-3 border-t border-white/5 space-y-1">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Sun size={16} />
|
||||
<span className="text-sm font-medium">{theme === 'dark' ? 'Light theme' : 'Dark theme'}</span>
|
||||
</button>
|
||||
|
||||
{/* User avatar */}
|
||||
<div className="flex items-center gap-3 px-3 py-3 mt-2">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center text-white text-sm font-bold">
|
||||
N
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">User</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Staff } from '@/types';
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Users, ChevronDown, ChevronUp, User } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
interface CastTabProps {
|
||||
staff: Staff[];
|
||||
onPersonClick: (person: Staff) => void;
|
||||
}
|
||||
|
||||
export default function CastTab({ staff, onPersonClick }: CastTabProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayLimit = 8;
|
||||
|
||||
const displayedCast = showAll ? staff : staff.slice(0, displayLimit);
|
||||
const hasMore = staff.length > displayLimit;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Cast & Crew
|
||||
</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{staff.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cast Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{displayedCast.map((person, index) => (
|
||||
<motion.div
|
||||
key={person.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<Card
|
||||
className="group cursor-pointer hover:border-primary/30 hover:shadow-md transition-all duration-200 border-border/60"
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-14 w-10 rounded-lg border border-border/30">
|
||||
<AvatarImage
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<AvatarFallback className="rounded-lg bg-muted">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm text-foreground truncate group-hover:text-primary transition-colors">
|
||||
{person.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{person.characterName || person.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Show More/Less Button */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="gap-2 rounded-lg"
|
||||
>
|
||||
{showAll ? (
|
||||
<>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
Show Less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
Show {staff.length - displayLimit} More
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Media } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { BookOpen, Tag } from 'lucide-react';
|
||||
|
||||
interface OverviewTabProps {
|
||||
media: Media;
|
||||
}
|
||||
|
||||
export default function OverviewTab({ media }: OverviewTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Genres */}
|
||||
{media.genres && media.genres.length > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Tag className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Genres
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{media.genres.map(genre => (
|
||||
<Badge
|
||||
key={genre}
|
||||
variant="secondary"
|
||||
className="text-xs px-3 py-1 bg-primary/5 text-primary border-primary/20 hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{media.tags && media.tags.length > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Tag className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Tags
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{media.tags.map(tag => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs px-3 py-1 border-border/50 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<BookOpen className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Synopsis
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{media.description ? (
|
||||
<div
|
||||
className="text-foreground leading-relaxed prose prose-sm dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: media.description }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
No description available.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Episode } from '@/types';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Search, Play, Clock, Calendar, ChevronDown, Tv } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
||||
interface SeasonsTabProps {
|
||||
episodes: Episode[];
|
||||
}
|
||||
|
||||
export default function SeasonsTab({ episodes }: SeasonsTabProps) {
|
||||
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
|
||||
|
||||
// Group episodes by season
|
||||
const episodesBySeason = useMemo(() => {
|
||||
if (!episodes) return {};
|
||||
const grouped: Record<number, typeof episodes> = {};
|
||||
episodes.forEach(episode => {
|
||||
if (!grouped[episode.season]) {
|
||||
grouped[episode.season] = [];
|
||||
}
|
||||
grouped[episode.season].push(episode);
|
||||
});
|
||||
// Sort episodes within each season by episode number
|
||||
Object.keys(grouped).forEach(season => {
|
||||
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
|
||||
});
|
||||
return grouped;
|
||||
}, [episodes]);
|
||||
|
||||
// Expand first season by default on mount
|
||||
useEffect(() => {
|
||||
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
|
||||
if (seasons.length > 0) {
|
||||
setExpandedSeasons(new Set([seasons[0]]));
|
||||
}
|
||||
}, [episodesBySeason]);
|
||||
|
||||
const toggleSeason = (season: number) => {
|
||||
setExpandedSeasons(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(season)) {
|
||||
newSet.delete(season);
|
||||
} else {
|
||||
newSet.add(season);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Tv className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Episodes
|
||||
</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{episodes.length}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Search episodes..."
|
||||
className="pl-9 w-full sm:w-[200px] bg-muted/50 border-none rounded-lg h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seasons */}
|
||||
<div className="space-y-3">
|
||||
{Object.keys(episodesBySeason)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(season => (
|
||||
<Collapsible
|
||||
key={season}
|
||||
open={expandedSeasons.has(season)}
|
||||
onOpenChange={() => toggleSeason(season)}
|
||||
>
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="py-3 px-4 cursor-pointer hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-foreground">Season {season}</h3>
|
||||
<Badge variant="outline" className="text-xs border-primary/30 text-primary">
|
||||
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${
|
||||
expandedSeasons.has(season) ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border/50">
|
||||
{episodesBySeason[season].map((episode, index) => (
|
||||
<div
|
||||
key={episode.id}
|
||||
className="group p-4 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Thumbnail */}
|
||||
<div className="w-full sm:w-[160px] shrink-0 aspect-video rounded-lg overflow-hidden relative bg-muted border border-border/30">
|
||||
{episode.thumbnail ? (
|
||||
<img
|
||||
src={episode.thumbnail}
|
||||
alt={episode.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Play className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/90 text-primary-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
|
||||
<Play className="w-5 h-5 fill-current ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
Episode {episode.episode_number}
|
||||
</p>
|
||||
<h4 className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
|
||||
{episode.title}
|
||||
</h4>
|
||||
{episode.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
{episode.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0">
|
||||
{episode.duration > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{episode.duration}m</span>
|
||||
</div>
|
||||
)}
|
||||
{episode.air_date && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{episode.air_date}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Media } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Gamepad2, Layers } from 'lucide-react';
|
||||
|
||||
interface SeriesTabProps {
|
||||
media: Media;
|
||||
allMedia: Media[];
|
||||
onMediaClick: (media: Media) => void;
|
||||
}
|
||||
|
||||
export default function SeriesTab({ media, allMedia, onMediaClick }: SeriesTabProps) {
|
||||
// Filter games that share at least one series with the current game
|
||||
const seriesGames = allMedia.filter(
|
||||
(m) =>
|
||||
m.category === 'Games' &&
|
||||
m.id !== media.id &&
|
||||
m.series &&
|
||||
media.series &&
|
||||
m.series.some((s) => media.series!.includes(s))
|
||||
);
|
||||
|
||||
if (seriesGames.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Layers className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Series
|
||||
</h2>
|
||||
</div>
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Gamepad2 className="w-12 h-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No other games found in the same series.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Layers className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Series
|
||||
</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{seriesGames.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{media.series?.map((s) => (
|
||||
<Badge
|
||||
key={s}
|
||||
variant="outline"
|
||||
className="text-xs border-primary/30 text-primary"
|
||||
>
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Games Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{seriesGames.map((game) => (
|
||||
<Card
|
||||
key={game.id}
|
||||
className="group cursor-pointer hover:border-primary/30 hover:shadow-md transition-all duration-200 border-border/60 overflow-hidden"
|
||||
onClick={() => onMediaClick(game)}
|
||||
>
|
||||
<div className={`aspect-[2/3] overflow-hidden bg-muted ${
|
||||
game.aspectRatio === '16/9' ? 'aspect-video' :
|
||||
game.aspectRatio === '1/1' ? 'aspect-square' : 'aspect-[2/3]'
|
||||
}`}>
|
||||
<img
|
||||
src={game.poster}
|
||||
alt={game.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-3">
|
||||
<p className="font-medium text-sm text-foreground truncate group-hover:text-primary transition-colors">
|
||||
{game.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{game.year}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Track } from '@/types';
|
||||
import { Search, Play, Disc, Clock } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
interface TracksTabProps {
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
export default function TracksTab({ tracks }: TracksTabProps) {
|
||||
const formatDuration = (seconds: number | null) => {
|
||||
if (!seconds) return '—';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Disc className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Tracks
|
||||
</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{tracks.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Search tracks..."
|
||||
className="pl-9 w-full sm:w-[200px] bg-muted/50 border-none rounded-lg h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tracks List */}
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border/50">
|
||||
{tracks.map((track, index) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="group flex items-center gap-4 p-3 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{/* Track Number / Play Button */}
|
||||
<div className="w-8 text-center">
|
||||
<span className="text-sm text-muted-foreground group-hover:hidden">
|
||||
{track.track_number}
|
||||
</span>
|
||||
<div className="hidden group-hover:flex items-center justify-center">
|
||||
<Play className="w-4 h-4 text-primary fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
|
||||
{track.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{track.artist}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatDuration(track.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import React from 'react';
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Star,
|
||||
Building2,
|
||||
Monitor,
|
||||
Users,
|
||||
FolderTree,
|
||||
Database,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface FilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface MediaFiltersProps {
|
||||
mediaList: Media[];
|
||||
activeCategory: MediaCategory;
|
||||
selectedGenre: string | null;
|
||||
selectedStudio: string | null;
|
||||
selectedPlatform: string | null;
|
||||
selectedDeveloper: string | null;
|
||||
selectedCategory: string | null;
|
||||
selectedSource: string | null;
|
||||
onGenreChange: (value: string | null) => void;
|
||||
onStudioChange: (value: string | null) => void;
|
||||
onPlatformChange: (value: string | null) => void;
|
||||
onDeveloperChange: (value: string | null) => void;
|
||||
onCategoryChange: (value: string | null) => void;
|
||||
onSourceChange: (value: string | null) => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
export default function MediaFilters({
|
||||
mediaList,
|
||||
activeCategory,
|
||||
selectedGenre,
|
||||
selectedStudio,
|
||||
selectedPlatform,
|
||||
selectedDeveloper,
|
||||
selectedCategory,
|
||||
selectedSource,
|
||||
onGenreChange,
|
||||
onStudioChange,
|
||||
onPlatformChange,
|
||||
onDeveloperChange,
|
||||
onCategoryChange,
|
||||
onSourceChange,
|
||||
onClearAll
|
||||
}: MediaFiltersProps) {
|
||||
// Extract unique filter values
|
||||
const genres = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.flatMap(m => m.genres || []))).sort(),
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const studios = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.flatMap(m => m.studios || []))).sort(),
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const platforms = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.flatMap(m => m.platforms || []))).sort(),
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const developers = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.flatMap(m => m.developers || []))).sort(),
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const categories = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.flatMap(m => m.series || []))).sort(),
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const sources = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.map(m => m.source).filter(Boolean))).sort() as string[],
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const hasActiveFilters = selectedGenre || selectedStudio || selectedPlatform ||
|
||||
selectedDeveloper || selectedCategory || selectedSource;
|
||||
|
||||
// Get available filters based on category
|
||||
const getAvailableFilters = () => {
|
||||
const baseFilters = ['genre'];
|
||||
|
||||
switch (activeCategory) {
|
||||
case 'Movies':
|
||||
case 'TV Series':
|
||||
return [...baseFilters, 'studio'];
|
||||
case 'Games':
|
||||
return [...baseFilters, 'platform', 'developer', 'category'];
|
||||
case 'Adult':
|
||||
return [...baseFilters, 'studio'];
|
||||
default:
|
||||
return baseFilters;
|
||||
}
|
||||
};
|
||||
|
||||
const availableFilters = getAvailableFilters();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Genre Filter - Always available */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedGenre
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Star size={14} className="mr-2" />
|
||||
{selectedGenre || 'Genres'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Filter by Genre
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onGenreChange(null)}>
|
||||
All Genres
|
||||
</DropdownMenuItem>
|
||||
{genres.map(genre => (
|
||||
<DropdownMenuItem key={genre} onClick={() => onGenreChange(genre)}>
|
||||
{genre}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Studio Filter - For Movies/Series/Adult */}
|
||||
{availableFilters.includes('studio') && studios.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedStudio
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Building2 size={14} className="mr-2" />
|
||||
{selectedStudio || 'Studios'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Filter by Studio
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onStudioChange(null)}>
|
||||
All Studios
|
||||
</DropdownMenuItem>
|
||||
{studios.map(studio => (
|
||||
<DropdownMenuItem key={studio} onClick={() => onStudioChange(studio)}>
|
||||
{studio}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Platform Filter - For Games */}
|
||||
{availableFilters.includes('platform') && platforms.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedPlatform
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Monitor size={14} className="mr-2" />
|
||||
{selectedPlatform || 'Platforms'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Filter by Platform
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onPlatformChange(null)}>
|
||||
All Platforms
|
||||
</DropdownMenuItem>
|
||||
{platforms.map(platform => (
|
||||
<DropdownMenuItem key={platform} onClick={() => onPlatformChange(platform)}>
|
||||
{platform}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Developer Filter - For Games */}
|
||||
{availableFilters.includes('developer') && developers.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedDeveloper
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Users size={14} className="mr-2" />
|
||||
{selectedDeveloper || 'Developers'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Filter by Developer
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onDeveloperChange(null)}>
|
||||
All Developers
|
||||
</DropdownMenuItem>
|
||||
{developers.map(developer => (
|
||||
<DropdownMenuItem key={developer} onClick={() => onDeveloperChange(developer)}>
|
||||
{developer}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Category/Series Filter - For Games */}
|
||||
{availableFilters.includes('category') && categories.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedCategory
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<FolderTree size={14} className="mr-2" />
|
||||
{selectedCategory || 'Series'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Filter by Series
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onCategoryChange(null)}>
|
||||
All Series
|
||||
</DropdownMenuItem>
|
||||
{categories.map(category => (
|
||||
<DropdownMenuItem key={category} onClick={() => onCategoryChange(category)}>
|
||||
{category}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Source Filter */}
|
||||
{sources.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedSource
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Database size={14} className="mr-2" />
|
||||
{selectedSource || 'Source'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Filter by Source
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onSourceChange(null)}>
|
||||
All Sources
|
||||
</DropdownMenuItem>
|
||||
{sources.map(source => (
|
||||
<DropdownMenuItem key={source} onClick={() => onSourceChange(source)}>
|
||||
{source}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Clear All Filters */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="h-9 px-3 inline-flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<X size={14} className="mr-2" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Active Filter Badges */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-1 ml-2">
|
||||
{selectedGenre && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onGenreChange(null)}
|
||||
>
|
||||
{selectedGenre} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{selectedStudio && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onStudioChange(null)}
|
||||
>
|
||||
{selectedStudio} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{selectedPlatform && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onPlatformChange(null)}
|
||||
>
|
||||
{selectedPlatform} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{selectedDeveloper && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onDeveloperChange(null)}
|
||||
>
|
||||
{selectedDeveloper} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{selectedCategory && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onCategoryChange(null)}
|
||||
>
|
||||
{selectedCategory} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{selectedSource && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onSourceChange(null)}
|
||||
>
|
||||
{selectedSource} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Staff } from '../../types';
|
||||
import { fetchCastById, convertApiCastToStaff } from '../../api';
|
||||
import CastDetailView from '../CastDetailView';
|
||||
import Loading from '../ui/loading';
|
||||
|
||||
export default function CastDetailRoute() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCast = async () => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const castData = await fetchCastById(id);
|
||||
if (castData) {
|
||||
const person = convertApiCastToStaff(castData);
|
||||
setSelectedPerson(person);
|
||||
} else {
|
||||
navigate('/cast');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cast:', error);
|
||||
navigate('/cast');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCast();
|
||||
}, [id, navigate]);
|
||||
|
||||
if (loading) return <Loading message="Loading cast details..." />;
|
||||
if (!selectedPerson) return null;
|
||||
|
||||
return (
|
||||
<CastDetailView
|
||||
person={selectedPerson}
|
||||
relatedMedia={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Media, Staff, MediaCategory } from '../../types';
|
||||
import BrowseView from '../BrowseView';
|
||||
|
||||
interface CategoryBrowseRouteProps {
|
||||
mediaList: Media[];
|
||||
onMediaClick: (media: Media) => void;
|
||||
itemsPerPage?: number;
|
||||
gridItemSize?: number;
|
||||
onGridItemSizeChange: (size: number) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export default function CategoryBrowseRoute({
|
||||
mediaList,
|
||||
onMediaClick,
|
||||
itemsPerPage,
|
||||
gridItemSize,
|
||||
onGridItemSizeChange,
|
||||
loading
|
||||
}: CategoryBrowseRouteProps) {
|
||||
const { category } = useParams<{ category: string }>();
|
||||
|
||||
// Map URL path to category
|
||||
const categoryMap: Record<string, MediaCategory> = {
|
||||
'anime': 'Anime',
|
||||
'movies': 'Movies',
|
||||
'tv-series': 'TV Series',
|
||||
'music': 'Music',
|
||||
'books': 'Books',
|
||||
'games': 'Games',
|
||||
'consoles': 'Consoles',
|
||||
'adult': 'Adult'
|
||||
};
|
||||
|
||||
const activeCategory = category ? categoryMap[category] : 'Anime';
|
||||
|
||||
return (
|
||||
<BrowseView
|
||||
mediaList={mediaList}
|
||||
onMediaClick={onMediaClick}
|
||||
activeCategory={activeCategory}
|
||||
itemsPerPage={itemsPerPage}
|
||||
gridItemSize={gridItemSize}
|
||||
onGridItemSizeChange={onGridItemSizeChange}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Media, Staff } from '../../types';
|
||||
import { fetchMediaById } from '../../api';
|
||||
import DetailView from '../DetailView';
|
||||
import Loading from '../ui/loading';
|
||||
|
||||
interface MediaDetailRouteProps {
|
||||
allMedia: Media[];
|
||||
onPersonClick: (person: Staff) => void;
|
||||
}
|
||||
|
||||
export default function MediaDetailRoute({ allMedia, onPersonClick }: MediaDetailRouteProps) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMedia = async () => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const fetchedMedia = await fetchMediaById(id);
|
||||
if (fetchedMedia) {
|
||||
setSelectedMedia(fetchedMedia);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media:', error);
|
||||
navigate('/');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadMedia();
|
||||
}, [id, navigate]);
|
||||
|
||||
if (loading) return <Loading message="Loading media details..." />;
|
||||
if (!selectedMedia) return null;
|
||||
|
||||
return (
|
||||
<DetailView
|
||||
media={selectedMedia}
|
||||
allMedia={allMedia}
|
||||
onPersonClick={onPersonClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useLocation, useNavigate, NavLink } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { MediaCategory } from '@/types';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Library,
|
||||
Users,
|
||||
FolderKanban,
|
||||
Database,
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
LogOut,
|
||||
Film,
|
||||
Tv,
|
||||
Gamepad2,
|
||||
Heart,
|
||||
Eye,
|
||||
Flame,
|
||||
Clock,
|
||||
User,
|
||||
Music,
|
||||
BookOpen,
|
||||
Monitor,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
|
||||
// shadcn/ui sidebar components
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface AppSidebarProps {
|
||||
enabledCategories: MediaCategory[];
|
||||
onToggleCategory: (category: MediaCategory) => void;
|
||||
pageTitle?: string;
|
||||
mediaCounts?: Record<string, number>;
|
||||
activeFilter?: string;
|
||||
onFilterChange?: (filter: string) => void;
|
||||
user?: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AppSidebar({
|
||||
enabledCategories,
|
||||
pageTitle = 'MediaVault',
|
||||
mediaCounts = {},
|
||||
activeFilter,
|
||||
onFilterChange,
|
||||
user,
|
||||
}: AppSidebarProps) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
console.log('Logout clicked');
|
||||
};
|
||||
|
||||
// Category config with icons, colors and routes
|
||||
const categoryConfig: Record<MediaCategory, { icon: any; label: string; route: string; color: string }> = {
|
||||
'Anime': { icon: Tv, label: 'Anime', route: '/anime', color: 'text-purple-400' },
|
||||
'Movies': { icon: Film, label: 'Movies', route: '/movies', color: 'text-blue-400' },
|
||||
'TV Series': { icon: Tv, label: 'Series', route: '/tv-series', color: 'text-green-400' },
|
||||
'Music': { icon: Music, label: 'Music', route: '/music', color: 'text-pink-400' },
|
||||
'Books': { icon: BookOpen, label: 'Books', route: '/books', color: 'text-yellow-400' },
|
||||
'Adult': { icon: Eye, label: 'Adult', route: '/adult', color: 'text-rose-400' },
|
||||
'Consoles': { icon: Monitor, label: 'Consoles', route: '/consoles', color: 'text-orange-400' },
|
||||
'Games': { icon: Gamepad2, label: 'Games', route: '/games', color: 'text-indigo-400' },
|
||||
};
|
||||
|
||||
const handleFilterClick = (filter: string) => {
|
||||
onFilterChange?.(filter);
|
||||
if (filter === 'favorites') {
|
||||
navigate('/browse?favorites=true');
|
||||
return;
|
||||
}
|
||||
// Find route for category
|
||||
const config = categoryConfig[filter as MediaCategory];
|
||||
if (config) {
|
||||
navigate(config.route);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickFilter = (filter: string) => {
|
||||
const routes: Record<string, string> = {
|
||||
'most-played': '/browse?sort=plays',
|
||||
'recently-added': '/browse?sort=recent',
|
||||
};
|
||||
navigate(routes[filter] || '/browse');
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
// Build category routes for Library isActive check
|
||||
const categoryRoutes = enabledCategories.map(cat => categoryConfig[cat].route);
|
||||
|
||||
const mainNavItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', isActive: isActive('/') },
|
||||
{ to: '/browse', icon: Library, label: 'Library', isActive: isActive('/browse') || categoryRoutes.some(route => isActive(route)) },
|
||||
{ to: '/cast', icon: Users, label: 'Actors', isActive: isActive('/cast') },
|
||||
//{ to: '/collections', icon: FolderKanban, label: 'Collections', isActive: isActive('/collections') },
|
||||
{ to: '/import', icon: Download, label: 'Import', isActive: isActive('/import') },
|
||||
//{ to: '/sources', icon: Database, label: 'Sources', isActive: isActive('/sources') },
|
||||
{ to: '/settings', icon: Settings, label: 'Settings', isActive: isActive('/settings') },
|
||||
];
|
||||
|
||||
// Build media type filters from enabled categories
|
||||
const mediaTypeFilters = enabledCategories.map(cat => {
|
||||
const config = categoryConfig[cat];
|
||||
return {
|
||||
id: cat.toLowerCase().replace(/\s+/g, '-'),
|
||||
icon: config.icon,
|
||||
label: config.label,
|
||||
count: mediaCounts[cat] || 0,
|
||||
color: config.color,
|
||||
category: cat,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader className="p-4">
|
||||
<NavLink to="/" className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center shadow-lg shadow-[#e8466c]/20">
|
||||
<Database className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-sidebar-foreground tracking-tight">{pageTitle}</span>
|
||||
</NavLink>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="px-2">
|
||||
{/* Main Navigation */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
Navigation
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{mainNavItems.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={item.isActive}
|
||||
className={cn(
|
||||
item.isActive
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<NavLink to={item.to} className="flex items-center gap-2 w-full">
|
||||
<item.icon className={cn('w-4 h-4 shrink-0', item.isActive ? 'text-[#e8466c]' : '')} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Media Type Filters */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
Media Type
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{mediaTypeFilters.map((filter) => {
|
||||
const isFilterActive = activeFilter === filter.id;
|
||||
return (
|
||||
<SidebarMenuItem key={filter.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handleFilterClick(filter.category)}
|
||||
isActive={isFilterActive}
|
||||
className={cn(
|
||||
isFilterActive
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<filter.icon
|
||||
className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
isFilterActive ? 'text-[#e8466c]' : filter.color || ''
|
||||
)}
|
||||
/>
|
||||
<span className="truncate flex-1 text-left">{filter.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs font-medium px-2 py-0.5 rounded-full shrink-0',
|
||||
isFilterActive
|
||||
? 'bg-[#e8466c]/20 text-[#e8466c]'
|
||||
: 'bg-sidebar-accent text-sidebar-foreground/60'
|
||||
)}
|
||||
>
|
||||
{filter.count}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Quick Filters */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
Quick Filters
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handleQuickFilter('most-played')}
|
||||
>
|
||||
<Flame className="w-4 h-4 text-orange-400 shrink-0" />
|
||||
<span className="truncate">Most Played</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handleQuickFilter('recently-added')}
|
||||
>
|
||||
<Clock className="w-4 h-4 text-cyan-400 shrink-0" />
|
||||
<span className="truncate">Recently Added</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="p-2 space-y-1">
|
||||
{/* Theme Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleTheme}
|
||||
className="w-full justify-start gap-2 text-sidebar-foreground/60 hover:text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<>
|
||||
<Sun className="w-4 h-4 text-amber-400" />
|
||||
<span>Light Mode</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="w-4 h-4 text-sidebar-foreground/60" />
|
||||
<span>Dark Mode</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* User Profile */}
|
||||
{user ? (
|
||||
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-sidebar-accent">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c] text-xs">
|
||||
{user.name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-sidebar-foreground truncate">{user.name}</p>
|
||||
<p className="text-xs text-sidebar-foreground/50 truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-sidebar-accent">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c]">
|
||||
<User className="w-4 h-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-sidebar-foreground">Guest</p>
|
||||
<p className="text-xs text-sidebar-foreground/50">Not logged in</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="w-full justify-start gap-2 text-sidebar-foreground/60 hover:text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Logout</span>
|
||||
</Button>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 select-none after:absolute after:inset-0 after: after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -7,7 +7,7 @@ interface LoadingProps {
|
||||
export default function Loading({ message = 'Loading...' }: LoadingProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="animate-spin h-12 w-12 text-[#6d28d9] mb-4" />
|
||||
<Loader2 className="animate-spin h-12 w-12 text-[#e8466c] mb-4" />
|
||||
<p className="text-lg font-bold">{message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex items-center gap-0.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<Button
|
||||
variant={isActive ? "outline" : "ghost"}
|
||||
size={size}
|
||||
className={cn(className)}
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
text = "Previous",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("pl-1.5!", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:block">{text}</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
text = "Next",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("pr-1.5!", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">{text}</span>
|
||||
<ChevronRightIcon data-icon="inline-end" />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon
|
||||
/>
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, max = 100, ...props }, ref) => {
|
||||
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="h-full w-full flex-1 bg-primary transition-all duration-500"
|
||||
style={{ transform: `translateX(-${100 - percentage}%)` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Progress.displayName = "Progress"
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,199 @@
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-label",
|
||||
sidebar: "group-label",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-action",
|
||||
sidebar: "group-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
render,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const { isMobile, state } = useSidebar()
|
||||
const comp = useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||
state: {
|
||||
slot: "sidebar-menu-button",
|
||||
sidebar: "menu-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
|
||||
if (!tooltip) {
|
||||
return comp
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
render,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-action",
|
||||
sidebar: "menu-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
render,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a"> &
|
||||
React.ComponentProps<"a"> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-sub-button",
|
||||
sidebar: "menu-sub-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
value?: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
onValueChange?: (value: number) => void
|
||||
}
|
||||
|
||||
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
({ className, value, min = 0, max = 100, step = 1, onValueChange, onChange, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = Number(e.target.value)
|
||||
onValueChange?.(newValue)
|
||||
onChange?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
ref={ref}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
"w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Slider.displayName = "Slider"
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from "react"
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
orientation: "horizontal",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
...props
|
||||
}: ToggleGroupPrimitive.Props &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider
|
||||
value={{ variant, size, spacing, orientation }}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TogglePrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,49 @@
|
||||
import { MediaCategory } from './types';
|
||||
|
||||
// Category to URL path mapping
|
||||
export const CATEGORY_PATHS: Record<MediaCategory, string> = {
|
||||
'Anime': 'anime',
|
||||
'Movies': 'movies',
|
||||
'TV Series': 'tv-series',
|
||||
'Music': 'music',
|
||||
'Books': 'books',
|
||||
'Games': 'games',
|
||||
'Consoles': 'consoles',
|
||||
'Adult': 'adult'
|
||||
};
|
||||
|
||||
// URL path to category mapping
|
||||
export const PATH_TO_CATEGORY: Record<string, MediaCategory> = {
|
||||
'anime': 'Anime',
|
||||
'movies': 'Movies',
|
||||
'tv-series': 'TV Series',
|
||||
'music': 'Music',
|
||||
'books': 'Books',
|
||||
'games': 'Games',
|
||||
'consoles': 'Consoles',
|
||||
'adult': 'Adult'
|
||||
};
|
||||
|
||||
// Default enabled categories
|
||||
export const DEFAULT_ENABLED_CATEGORIES: MediaCategory[] = [
|
||||
'Anime',
|
||||
'Movies',
|
||||
'TV Series',
|
||||
'Music',
|
||||
'Books',
|
||||
'Consoles',
|
||||
'Games',
|
||||
'Adult'
|
||||
];
|
||||
|
||||
// Default settings
|
||||
export const DEFAULT_SETTINGS = {
|
||||
enabledCategories: DEFAULT_ENABLED_CATEGORIES,
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid' as const,
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
language: 'en',
|
||||
theme: 'system' as const,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
@@ -53,10 +53,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
const setTheme = useCallback((newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, effectiveTheme }}>
|
||||
|
||||
+7
-1
@@ -127,7 +127,13 @@ export const MOCK_MEDIA: Media[] = [
|
||||
studios: ['Example Studio'],
|
||||
}
|
||||
];
|
||||
export const DETAIL_MEDIA: Media = {}
|
||||
export const DETAIL_MEDIA: Media = {
|
||||
id: '',
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: 'Movies'
|
||||
}
|
||||
/*
|
||||
export const DETAIL_MEDIA: Media = {
|
||||
id: 'mob-psycho',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
+100
-32
@@ -83,7 +83,7 @@
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--radius: 0.75rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
@@ -92,40 +92,71 @@
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
/* MediaVault accent color - pink/coral */
|
||||
--mv-accent: #e8466c;
|
||||
--mv-accent-hover: #d13d60;
|
||||
--mv-accent-light: #f47298;
|
||||
|
||||
/* Custom gradient colors */
|
||||
--gradient-purple: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
||||
--gradient-blue: linear-gradient(135deg, #3b82f6 0%, #60a5fa 50%, #93c5fd 100%);
|
||||
--gradient-green: linear-gradient(135deg, #22c55e 0%, #4ade80 50%, #86efac 100%);
|
||||
--gradient-yellow: linear-gradient(135deg, #eab308 0%, #facc15 50%, #fde047 100%);
|
||||
--gradient-pink: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--background: oklch(0.145 0.005 35);
|
||||
--foreground: oklch(0.82 0.008 35);
|
||||
--card: oklch(0.17 0.005 35);
|
||||
--card-foreground: oklch(0.82 0.008 35);
|
||||
--popover: oklch(0.17 0.005 35);
|
||||
--popover-foreground: oklch(0.82 0.008 35);
|
||||
--primary: oklch(0.82 0.008 35);
|
||||
--primary-foreground: oklch(0.145 0.005 35);
|
||||
--secondary: oklch(0.21 0.005 35);
|
||||
--secondary-foreground: oklch(0.82 0.008 35);
|
||||
--muted: oklch(0.19 0.005 35);
|
||||
--muted-foreground: oklch(0.55 0.01 35);
|
||||
--accent: oklch(0.21 0.005 35);
|
||||
--accent-foreground: oklch(0.82 0.008 35);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--border: oklch(0.82 0.008 35 / 10%);
|
||||
--input: oklch(0.82 0.008 35 / 15%);
|
||||
--ring: oklch(0.55 0 0);
|
||||
--chart-1: oklch(0.7 0.08 35);
|
||||
--chart-2: oklch(0.55 0.04 35);
|
||||
--chart-3: oklch(0.4 0.02 35);
|
||||
--chart-4: oklch(0.3 0.015 35);
|
||||
--chart-5: oklch(0.2 0.01 35);
|
||||
--sidebar: oklch(0.125 0.005 35);
|
||||
--sidebar-foreground: oklch(0.82 0.008 35);
|
||||
--sidebar-primary: oklch(0.55 0.22 0);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.19 0.005 35);
|
||||
--sidebar-accent-foreground: oklch(0.82 0.008 35);
|
||||
--sidebar-border: oklch(0.82 0.008 35 / 8%);
|
||||
--sidebar-ring: oklch(0.55 0 0);
|
||||
|
||||
/* MediaVault accent color - pink/coral */
|
||||
--mv-accent: #e8466c;
|
||||
--mv-accent-hover: #d13d60;
|
||||
--mv-accent-light: #f47298;
|
||||
|
||||
/* Custom gradient colors for dark mode - softer on eyes */
|
||||
--gradient-purple: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
||||
--gradient-blue: linear-gradient(135deg, #3b82f6 0%, #60a5fa 50%, #93c5fd 100%);
|
||||
--gradient-green: linear-gradient(135deg, #22c55e 0%, #4ade80 50%, #86efac 100%);
|
||||
--gradient-yellow: linear-gradient(135deg, #eab308 0%, #facc15 50%, #fde047 100%);
|
||||
--gradient-pink: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
||||
--gradient-orange: linear-gradient(135deg, #f97316 0%, #fb923c 50%, #fbbf24 100%);
|
||||
--gradient-cyan: linear-gradient(135deg, #06b6d4 0%, #22d3ee 50%, #67e8f9 100%);
|
||||
|
||||
/* Background gradients for dark mode */
|
||||
--bg-gradient-subtle: radial-gradient(circle at top right, rgba(232, 70, 108, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(circle at bottom left, rgba(232, 70, 108, 0.04) 0%, transparent 50%);
|
||||
--bg-gradient-mesh: linear-gradient(135deg, rgba(232, 70, 108, 0.02) 0%, rgba(244, 114, 152, 0.02) 50%, rgba(249, 168, 201, 0.02) 100%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -133,9 +164,46 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground transition-[background-color,border-color] duration-200;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.708 0 0);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
/* Glassmorphism utility */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dark .glass {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Tests for Jellyfin Importer
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { importFromJellyfin, fetchJellyfinLibraries, JellyfinConfig, JellyfinImportOptions, ImportProgress } from '../jellyfinImporter';
|
||||
|
||||
// Mock global fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('jellyfinImporter', () => {
|
||||
const mockConfig: JellyfinConfig = {
|
||||
url: 'http://localhost:8096',
|
||||
apiKey: 'test-api-key'
|
||||
};
|
||||
|
||||
const mockOptions: JellyfinImportOptions = {
|
||||
importMovies: true,
|
||||
importSeries: true,
|
||||
importMusic: false,
|
||||
importCast: false,
|
||||
updateExisting: false
|
||||
};
|
||||
|
||||
const mockLogCallback = vi.fn();
|
||||
const mockProgressCallback = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetch).mockClear();
|
||||
});
|
||||
|
||||
describe('fetchJellyfinLibraries', () => {
|
||||
it('should successfully fetch libraries from Jellyfin', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
Items: [
|
||||
{ Id: 'lib-1', Name: 'Movies', Type: 'CollectionFolder', CollectionType: 'movies' },
|
||||
{ Id: 'lib-2', Name: 'TV Shows', Type: 'CollectionFolder', CollectionType: 'tvshows' }
|
||||
],
|
||||
TotalRecordCount: 2
|
||||
})
|
||||
} as Response);
|
||||
|
||||
const libraries = await fetchJellyfinLibraries(mockConfig);
|
||||
|
||||
expect(libraries).toHaveLength(2);
|
||||
expect(libraries[0].Name).toBe('Movies');
|
||||
expect(libraries[1].Name).toBe('TV Shows');
|
||||
});
|
||||
|
||||
it('should handle connection errors', async () => {
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
|
||||
|
||||
await expect(fetchJellyfinLibraries(mockConfig)).rejects.toThrow('Connection failed');
|
||||
});
|
||||
|
||||
it('should handle API response errors', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Unauthorized'
|
||||
} as Response);
|
||||
|
||||
await expect(fetchJellyfinLibraries(mockConfig)).rejects.toThrow('Failed to fetch libraries from Jellyfin: Unauthorized');
|
||||
});
|
||||
|
||||
it('should handle empty library list', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ Items: [], TotalRecordCount: 0 })
|
||||
} as Response);
|
||||
|
||||
const libraries = await fetchJellyfinLibraries(mockConfig);
|
||||
|
||||
expect(libraries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFromJellyfin', () => {
|
||||
it('should successfully import movies from Jellyfin', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ([{ Id: 'user-1' }])
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
Items: [
|
||||
{
|
||||
Id: 'movie-1',
|
||||
Name: 'Test Movie',
|
||||
Type: 'Movie',
|
||||
ProductionYear: 2024,
|
||||
CommunityRating: 8.5,
|
||||
Overview: 'A test movie',
|
||||
Genres: ['Action'],
|
||||
Studios: [{ Name: 'Test Studio', Id: 'studio-1' }],
|
||||
People: [
|
||||
{ Name: 'Actor 1', Type: 'Actor' },
|
||||
{ Name: 'Director 1', Type: 'Director' }
|
||||
],
|
||||
ImageTags: { Primary: 'tag-1' }
|
||||
}
|
||||
],
|
||||
TotalRecordCount: 1
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromJellyfin(
|
||||
mockConfig,
|
||||
mockOptions,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('complete');
|
||||
expect(result.moviesImported).toBe(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('Starting Jellyfin import...');
|
||||
});
|
||||
|
||||
it('should successfully import series from Jellyfin', async () => {
|
||||
const seriesOptions: JellyfinImportOptions = {
|
||||
...mockOptions,
|
||||
importMovies: false,
|
||||
importSeries: true
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ([{ Id: 'user-1' }])
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
Items: [
|
||||
{
|
||||
Id: 'series-1',
|
||||
Name: 'Test Series',
|
||||
Type: 'Series',
|
||||
ProductionYear: 2024,
|
||||
CommunityRating: 9.0,
|
||||
Overview: 'A test series',
|
||||
Genres: ['Drama'],
|
||||
Studios: [{ Name: 'Test Studio', Id: 'studio-1' }],
|
||||
People: [
|
||||
{ Name: 'Actor 1', Type: 'Actor' }
|
||||
],
|
||||
ImageTags: { Primary: 'tag-1' }
|
||||
}
|
||||
],
|
||||
TotalRecordCount: 1
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
Items: []
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromJellyfin(
|
||||
mockConfig,
|
||||
seriesOptions,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('complete');
|
||||
expect(result.seriesImported).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle connection errors', async () => {
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
|
||||
|
||||
const result = await importFromJellyfin(
|
||||
mockConfig,
|
||||
mockOptions,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('error');
|
||||
expect(result.errors).toContain('Connection failed');
|
||||
});
|
||||
|
||||
it('should skip existing items when updateExisting is false', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'media-1', title: 'Test Movie' }
|
||||
]
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ([{ Id: 'user-1' }])
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
Items: [
|
||||
{
|
||||
Id: 'movie-1',
|
||||
Name: 'Test Movie',
|
||||
Type: 'Movie'
|
||||
}
|
||||
],
|
||||
TotalRecordCount: 1
|
||||
})
|
||||
} as Response);
|
||||
|
||||
const result = await importFromJellyfin(
|
||||
mockConfig,
|
||||
mockOptions,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.moviesImported).toBe(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped movie: Test Movie (already exists, updateExisting is false)');
|
||||
});
|
||||
|
||||
it('should update existing items when updateExisting is true', async () => {
|
||||
const updateOptions: JellyfinImportOptions = {
|
||||
...mockOptions,
|
||||
updateExisting: true
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'media-1', title: 'Test Movie' }
|
||||
]
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ([{ Id: 'user-1' }])
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
Items: [
|
||||
{
|
||||
Id: 'movie-1',
|
||||
Name: 'Test Movie',
|
||||
Type: 'Movie'
|
||||
}
|
||||
],
|
||||
TotalRecordCount: 1
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromJellyfin(
|
||||
mockConfig,
|
||||
updateOptions,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.moviesImported).toBe(1);
|
||||
});
|
||||
|
||||
it('should respect library mappings and skip libraries marked as skip', async () => {
|
||||
const optionsWithMapping: JellyfinImportOptions = {
|
||||
...mockOptions,
|
||||
libraryMappings: [
|
||||
{ libraryName: 'Movies', category: 'skip' }
|
||||
]
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ([{ Id: 'user-1' }])
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
Items: [
|
||||
{
|
||||
Id: 'movie-1',
|
||||
Name: 'Test Movie',
|
||||
Type: 'Movie',
|
||||
ParentId: 'lib-1'
|
||||
}
|
||||
],
|
||||
TotalRecordCount: 1
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
Items: [
|
||||
{ Id: 'lib-1', Name: 'Movies', Type: 'CollectionFolder', CollectionType: 'movies' }
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
const result = await importFromJellyfin(
|
||||
mockConfig,
|
||||
optionsWithMapping,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.moviesImported).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JellyfinConfig', () => {
|
||||
it('should accept valid configuration', () => {
|
||||
const config: JellyfinConfig = {
|
||||
url: 'http://localhost:8096',
|
||||
apiKey: 'test-api-key'
|
||||
};
|
||||
|
||||
expect(config.url).toBe('http://localhost:8096');
|
||||
expect(config.apiKey).toBe('test-api-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JellyfinImportOptions', () => {
|
||||
it('should accept valid options', () => {
|
||||
const options: JellyfinImportOptions = {
|
||||
importMovies: true,
|
||||
importSeries: true,
|
||||
importMusic: false,
|
||||
importCast: false,
|
||||
limit: 100,
|
||||
updateExisting: false
|
||||
};
|
||||
|
||||
expect(options.importMovies).toBe(true);
|
||||
expect(options.importSeries).toBe(true);
|
||||
expect(options.importMusic).toBe(false);
|
||||
expect(options.importCast).toBe(false);
|
||||
expect(options.limit).toBe(100);
|
||||
expect(options.updateExisting).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept library mappings', () => {
|
||||
const options: JellyfinImportOptions = {
|
||||
libraryMappings: [
|
||||
{ libraryName: 'Movies', category: 'Movies' },
|
||||
{ libraryName: 'TV Shows', category: 'TV Series' },
|
||||
{ libraryName: 'Anime', category: 'Anime' },
|
||||
{ libraryName: 'Music', category: 'Music' },
|
||||
{ libraryName: 'Unwanted', category: 'skip' }
|
||||
]
|
||||
};
|
||||
|
||||
expect(options.libraryMappings).toHaveLength(5);
|
||||
expect(options.libraryMappings![4].category).toBe('skip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportProgress', () => {
|
||||
it('should have correct structure', () => {
|
||||
const progress: ImportProgress = {
|
||||
current: 5,
|
||||
total: 10,
|
||||
stage: 'importing',
|
||||
message: 'Importing...',
|
||||
moviesImported: 3,
|
||||
seriesImported: 2,
|
||||
musicImported: 0,
|
||||
castImported: 5,
|
||||
errors: []
|
||||
};
|
||||
|
||||
expect(progress.current).toBe(5);
|
||||
expect(progress.total).toBe(10);
|
||||
expect(progress.stage).toBe('importing');
|
||||
expect(progress.moviesImported).toBe(3);
|
||||
expect(progress.seriesImported).toBe(2);
|
||||
expect(progress.musicImported).toBe(0);
|
||||
expect(progress.castImported).toBe(5);
|
||||
expect(progress.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Tests for Playnite Importer
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { importFromPlaynite, PlayniteConfig, ImportProgress } from '../playniteImporter';
|
||||
|
||||
// Mock global fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('playniteImporter', () => {
|
||||
const mockConfig: PlayniteConfig = {
|
||||
ip: '192.168.1.100',
|
||||
apiToken: 'test-token',
|
||||
port: 19821,
|
||||
updateExisting: false
|
||||
};
|
||||
|
||||
const mockLogCallback = vi.fn();
|
||||
const mockProgressCallback = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetch).mockClear();
|
||||
});
|
||||
|
||||
describe('importFromPlaynite', () => {
|
||||
it('should successfully import games from Playnite', async () => {
|
||||
// Mock existing media check
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
// Mock games list fetch
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 5000,
|
||||
games: [
|
||||
{
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
description: 'A test game',
|
||||
genres: ['Action'],
|
||||
developers: ['Test Dev'],
|
||||
publishers: ['Test Pub'],
|
||||
releaseDate: '2024-01-01'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
// Mock game detail fetch
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
description: 'A test game',
|
||||
genres: ['Action'],
|
||||
developers: ['Test Dev'],
|
||||
publishers: ['Test Pub'],
|
||||
releaseDate: '2024-01-01'
|
||||
})
|
||||
} as Response);
|
||||
|
||||
// Mock media creation
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromPlaynite(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('complete');
|
||||
expect(result.gamesImported).toBe(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('Starting Playnite import...');
|
||||
});
|
||||
|
||||
it('should handle connection errors', async () => {
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
|
||||
|
||||
const result = await importFromPlaynite(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('error');
|
||||
expect(result.errors).toContain('Connection failed');
|
||||
});
|
||||
|
||||
it('should handle API response errors', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Unauthorized'
|
||||
} as Response);
|
||||
|
||||
const result = await importFromPlaynite(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('error');
|
||||
expect(result.errors).toContain('Failed to connect to Playnite API: Unauthorized');
|
||||
});
|
||||
|
||||
it('should skip existing games when updateExisting is false', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'media-1', title: 'Test Game' }
|
||||
]
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 5000,
|
||||
games: [
|
||||
{
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
description: 'A test game'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
description: 'A test game'
|
||||
})
|
||||
} as Response);
|
||||
|
||||
const result = await importFromPlaynite(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.gamesImported).toBe(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped game: Test Game (already exists, updateExisting is false)');
|
||||
});
|
||||
|
||||
it('should update existing games when updateExisting is true', async () => {
|
||||
const configWithUpdate: PlayniteConfig = {
|
||||
...mockConfig,
|
||||
updateExisting: true
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'media-1', title: 'Test Game' }
|
||||
]
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 5000,
|
||||
games: [
|
||||
{
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
description: 'A test game'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
description: 'A test game'
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromPlaynite(
|
||||
configWithUpdate,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.gamesImported).toBe(1);
|
||||
});
|
||||
|
||||
it('should convert ratings from 0-100 scale to 0-5 scale', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 5000,
|
||||
games: [
|
||||
{
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
userScore: 80,
|
||||
communityScore: 90,
|
||||
criticScore: 85
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
userScore: 80,
|
||||
communityScore: 90,
|
||||
criticScore: 85
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromPlaynite(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.gamesImported).toBe(1);
|
||||
});
|
||||
|
||||
it('should convert playtime from seconds to minutes', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 5000,
|
||||
games: [
|
||||
{
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
playtime: 3600 // 1 hour in seconds
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
playtime: 3600
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromPlaynite(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.gamesImported).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayniteConfig', () => {
|
||||
it('should accept valid configuration', () => {
|
||||
const config: PlayniteConfig = {
|
||||
ip: '192.168.1.100',
|
||||
apiToken: 'test-token'
|
||||
};
|
||||
|
||||
expect(config.ip).toBe('192.168.1.100');
|
||||
expect(config.apiToken).toBe('test-token');
|
||||
expect(config.port).toBeUndefined();
|
||||
expect(config.updateExisting).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept configuration with optional fields', () => {
|
||||
const config: PlayniteConfig = {
|
||||
ip: '192.168.1.100',
|
||||
apiToken: 'test-token',
|
||||
port: 19821,
|
||||
updateExisting: true
|
||||
};
|
||||
|
||||
expect(config.port).toBe(19821);
|
||||
expect(config.updateExisting).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportProgress', () => {
|
||||
it('should have correct structure', () => {
|
||||
const progress: ImportProgress = {
|
||||
current: 5,
|
||||
total: 10,
|
||||
stage: 'importing',
|
||||
message: 'Importing...',
|
||||
gamesImported: 5,
|
||||
errors: []
|
||||
};
|
||||
|
||||
expect(progress.current).toBe(5);
|
||||
expect(progress.total).toBe(10);
|
||||
expect(progress.stage).toBe('importing');
|
||||
expect(progress.gamesImported).toBe(5);
|
||||
expect(progress.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Tests for StashAPP Importer
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { importFromStashAPP, updateActorsFromStashAPP, StashAPPConfig, ImportProgress } from '../stashappImporter';
|
||||
|
||||
// Mock global fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('stashappImporter', () => {
|
||||
const mockConfig: StashAPPConfig = {
|
||||
url: 'http://localhost:9999',
|
||||
apiKey: 'test-api-key',
|
||||
blacklist: ['/AI/', 'temp'],
|
||||
updateExisting: false
|
||||
};
|
||||
|
||||
const mockLogCallback = vi.fn();
|
||||
const mockProgressCallback = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetch).mockClear();
|
||||
});
|
||||
|
||||
describe('importFromStashAPP', () => {
|
||||
it('should successfully import scenes and performers from StashAPP', async () => {
|
||||
// Mock existing media and cast check
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
// Mock scenes fetch
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
findScenes: {
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
title: 'Test Scene',
|
||||
details: 'A test scene',
|
||||
date: '2024-01-01',
|
||||
rating100: 80,
|
||||
paths: {
|
||||
screenshot: 'http://example.com/screenshot.jpg'
|
||||
},
|
||||
files: [
|
||||
{
|
||||
size: 1000000,
|
||||
duration: 1800,
|
||||
video_codec: 'h264',
|
||||
audio_codec: 'aac',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
path: '/videos/test.mp4'
|
||||
}
|
||||
],
|
||||
performers: []
|
||||
}
|
||||
],
|
||||
count: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
// Mock media creation
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromStashAPP(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('complete');
|
||||
expect(result.videosImported).toBe(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('Starting StashAPP import...');
|
||||
});
|
||||
|
||||
it('should handle connection errors', async () => {
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
|
||||
|
||||
const result = await importFromStashAPP(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('error');
|
||||
expect(result.errors).toContain('Connection failed');
|
||||
});
|
||||
|
||||
it('should handle API response errors', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Unauthorized'
|
||||
} as Response);
|
||||
|
||||
const result = await importFromStashAPP(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('error');
|
||||
expect(result.errors).toContain('Failed to connect to StashAPP: Unauthorized');
|
||||
});
|
||||
|
||||
it('should skip blacklisted scenes', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
findScenes: {
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
title: 'Test Scene',
|
||||
paths: { screenshot: 'http://example.com/screenshot.jpg' },
|
||||
files: [
|
||||
{
|
||||
path: '/videos/AI/test.mp4',
|
||||
size: 1000000,
|
||||
duration: 1800
|
||||
}
|
||||
],
|
||||
performers: []
|
||||
}
|
||||
],
|
||||
count: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
const result = await importFromStashAPP(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.videosImported).toBe(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped blacklisted scene: Test Scene');
|
||||
});
|
||||
|
||||
it('should convert rating from 0-100 scale to 0-5 scale', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
findScenes: {
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
title: 'Test Scene',
|
||||
rating100: 80,
|
||||
paths: { screenshot: 'http://example.com/screenshot.jpg' },
|
||||
files: [{ path: '/videos/test.mp4', size: 1000000, duration: 1800 }],
|
||||
performers: []
|
||||
}
|
||||
],
|
||||
count: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromStashAPP(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.videosImported).toBe(1);
|
||||
});
|
||||
|
||||
it('should determine aspect ratio from file dimensions', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
findScenes: {
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
title: 'Test Scene',
|
||||
paths: { screenshot: 'http://example.com/screenshot.jpg' },
|
||||
files: [
|
||||
{
|
||||
path: '/videos/test.mp4',
|
||||
size: 1000000,
|
||||
duration: 1800,
|
||||
width: 1920,
|
||||
height: 1080
|
||||
}
|
||||
],
|
||||
performers: []
|
||||
}
|
||||
],
|
||||
count: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromStashAPP(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.videosImported).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateActorsFromStashAPP', () => {
|
||||
it('should successfully update actors from StashAPP', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
findPerformers: {
|
||||
performers: [
|
||||
{
|
||||
id: 'performer-1',
|
||||
name: 'Test Performer',
|
||||
image_path: 'http://example.com/photo.jpg',
|
||||
details: 'A test performer',
|
||||
birthdate: '1990-01-01',
|
||||
country: 'USA'
|
||||
}
|
||||
],
|
||||
count: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'cast-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await updateActorsFromStashAPP(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('complete');
|
||||
expect(result.actorsImported).toBe(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('Starting StashAPP actor update...');
|
||||
});
|
||||
|
||||
it('should update existing actors', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'cast-1', name: 'Test Performer', photo: 'old-photo.jpg' }
|
||||
]
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
findPerformers: {
|
||||
performers: [
|
||||
{
|
||||
id: 'performer-1',
|
||||
name: 'Test Performer',
|
||||
image_path: 'http://example.com/new-photo.jpg',
|
||||
details: 'Updated bio'
|
||||
}
|
||||
],
|
||||
count: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'cast-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await updateActorsFromStashAPP(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.actorsImported).toBe(1);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('✓ Updated actor: Test Performer');
|
||||
});
|
||||
|
||||
it('should handle connection errors', async () => {
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
|
||||
|
||||
const result = await updateActorsFromStashAPP(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('error');
|
||||
expect(result.errors).toContain('Connection failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StashAPPConfig', () => {
|
||||
it('should accept valid configuration', () => {
|
||||
const config: StashAPPConfig = {
|
||||
url: 'http://localhost:9999',
|
||||
apiKey: 'test-api-key'
|
||||
};
|
||||
|
||||
expect(config.url).toBe('http://localhost:9999');
|
||||
expect(config.apiKey).toBe('test-api-key');
|
||||
expect(config.blacklist).toBeUndefined();
|
||||
expect(config.updateExisting).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept configuration with optional fields', () => {
|
||||
const config: StashAPPConfig = {
|
||||
url: 'http://localhost:9999',
|
||||
apiKey: 'test-api-key',
|
||||
blacklist: ['/AI/', 'temp'],
|
||||
updateExisting: true
|
||||
};
|
||||
|
||||
expect(config.blacklist).toEqual(['/AI/', 'temp']);
|
||||
expect(config.updateExisting).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportProgress', () => {
|
||||
it('should have correct structure', () => {
|
||||
const progress: ImportProgress = {
|
||||
current: 5,
|
||||
total: 10,
|
||||
stage: 'importing',
|
||||
message: 'Importing...',
|
||||
videosImported: 5,
|
||||
actorsImported: 3,
|
||||
errors: []
|
||||
};
|
||||
|
||||
expect(progress.current).toBe(5);
|
||||
expect(progress.total).toBe(10);
|
||||
expect(progress.stage).toBe('importing');
|
||||
expect(progress.videosImported).toBe(5);
|
||||
expect(progress.actorsImported).toBe(3);
|
||||
expect(progress.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Tests for XBVR Importer
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { importFromXBVR, XBVRConfig, ImportProgress } from '../xbvrImporter';
|
||||
|
||||
// Mock global fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('xbvrImporter', () => {
|
||||
const mockConfig: XBVRConfig = {
|
||||
url: 'http://localhost:9999',
|
||||
apiKey: 'test-api-key',
|
||||
updateExisting: false
|
||||
};
|
||||
|
||||
const mockLogCallback = vi.fn();
|
||||
const mockProgressCallback = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetch).mockClear();
|
||||
});
|
||||
|
||||
describe('importFromXBVR', () => {
|
||||
it('should successfully import videos and actors from XBVR', async () => {
|
||||
// Mock existing media and cast check
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
// Mock scene list fetch
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
scenes: [
|
||||
{
|
||||
name: 'Recent',
|
||||
list: [
|
||||
{
|
||||
title: 'Test Video',
|
||||
videoLength: 1800,
|
||||
thumbnailUrl: 'http://example.com/thumb.jpg',
|
||||
video_url: 'http://example.com/api/video/1'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Favorites',
|
||||
list: []
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
// Mock video detail fetch
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 1,
|
||||
title: 'Test Video',
|
||||
description: 'A test VR video',
|
||||
date: 1704067200, // 2024-01-01
|
||||
thumbnailUrl: 'http://example.com/thumb.jpg',
|
||||
rating_avg: 8.5,
|
||||
screenType: '180',
|
||||
stereoMode: 'sbs',
|
||||
videoLength: 1800,
|
||||
paysite: { name: 'Test Studio' },
|
||||
actors: [
|
||||
{ id: 1, name: 'Actor 1' },
|
||||
{ id: 2, name: 'Actor 2' }
|
||||
],
|
||||
categories: [
|
||||
{ tag: { name: 'VR' } },
|
||||
{ tag: { name: '180°' } }
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
// Mock actor creation
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'cast-1' })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'cast-2' })
|
||||
} as Response);
|
||||
|
||||
// Mock media creation
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromXBVR(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('complete');
|
||||
expect(result.videosImported).toBe(1);
|
||||
expect(result.actorsImported).toBe(2);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('Starting DeoVR import...');
|
||||
});
|
||||
|
||||
it('should handle connection errors', async () => {
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
|
||||
|
||||
const result = await importFromXBVR(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('error');
|
||||
expect(result.errors).toContain('Connection failed');
|
||||
});
|
||||
|
||||
it('should handle API response errors', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Unauthorized'
|
||||
} as Response);
|
||||
|
||||
const result = await importFromXBVR(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.stage).toBe('error');
|
||||
expect(result.errors).toContain('Failed to connect to DeoVR API: Unauthorized');
|
||||
});
|
||||
|
||||
it('should skip videos starting with aka:', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
scenes: [
|
||||
{
|
||||
name: 'Recent',
|
||||
list: [
|
||||
{
|
||||
title: 'aka: Test Video',
|
||||
videoLength: 1800,
|
||||
thumbnailUrl: 'http://example.com/thumb.jpg',
|
||||
video_url: 'http://example.com/api/video/1'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 1,
|
||||
title: 'aka: Test Video',
|
||||
date: 1704067200,
|
||||
videoLength: 1800,
|
||||
actors: [],
|
||||
categories: []
|
||||
})
|
||||
} as Response);
|
||||
|
||||
const result = await importFromXBVR(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.videosImported).toBe(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped \'aka:\' video: aka: Test Video');
|
||||
});
|
||||
|
||||
it('should skip actors containing aka:', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
scenes: [
|
||||
{
|
||||
name: 'Recent',
|
||||
list: [
|
||||
{
|
||||
title: 'Test Video',
|
||||
videoLength: 1800,
|
||||
thumbnailUrl: 'http://example.com/thumb.jpg',
|
||||
video_url: 'http://example.com/api/video/1'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 1,
|
||||
title: 'Test Video',
|
||||
date: 1704067200,
|
||||
videoLength: 1800,
|
||||
actors: [
|
||||
{ id: 1, name: 'Actor 1' },
|
||||
{ id: 2, name: 'aka: Actor 2' }
|
||||
],
|
||||
categories: []
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'cast-1' })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromXBVR(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.actorsImported).toBe(1);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped \'aka:\' actor: aka: Actor 2');
|
||||
});
|
||||
|
||||
it('should skip existing videos when updateExisting is false', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'media-1', title: 'Test Video' }
|
||||
]
|
||||
}
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
scenes: [
|
||||
{
|
||||
name: 'Recent',
|
||||
list: [
|
||||
{
|
||||
title: 'Test Video',
|
||||
videoLength: 1800,
|
||||
thumbnailUrl: 'http://example.com/thumb.jpg',
|
||||
video_url: 'http://example.com/api/video/1'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 1,
|
||||
title: 'Test Video',
|
||||
date: 1704067200,
|
||||
videoLength: 1800,
|
||||
actors: [],
|
||||
categories: []
|
||||
})
|
||||
} as Response);
|
||||
|
||||
const result = await importFromXBVR(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.videosImported).toBe(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped duplicate: Test Video (updateExisting is false)');
|
||||
});
|
||||
|
||||
it('should determine aspect ratio based on screenType and stereoMode', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
scenes: [
|
||||
{
|
||||
name: 'Recent',
|
||||
list: [
|
||||
{
|
||||
title: '360 Video',
|
||||
videoLength: 1800,
|
||||
thumbnailUrl: 'http://example.com/thumb.jpg',
|
||||
video_url: 'http://example.com/api/video/1'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 1,
|
||||
title: '360 Video',
|
||||
date: 1704067200,
|
||||
videoLength: 1800,
|
||||
screenType: '360',
|
||||
stereoMode: 'sbs',
|
||||
actors: [],
|
||||
categories: []
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromXBVR(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.videosImported).toBe(1);
|
||||
});
|
||||
|
||||
it('should convert Unix timestamp to date', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
scenes: [
|
||||
{
|
||||
name: 'Recent',
|
||||
list: [
|
||||
{
|
||||
title: 'Test Video',
|
||||
videoLength: 1800,
|
||||
thumbnailUrl: 'http://example.com/thumb.jpg',
|
||||
video_url: 'http://example.com/api/video/1'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 1,
|
||||
title: 'Test Video',
|
||||
date: 1704067200, // 2024-01-01
|
||||
videoLength: 1800,
|
||||
actors: [],
|
||||
categories: []
|
||||
})
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'media-1' })
|
||||
} as Response);
|
||||
|
||||
const result = await importFromXBVR(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.videosImported).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle missing Recent scene group', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [] } })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
scenes: [
|
||||
{
|
||||
name: 'Favorites',
|
||||
list: []
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response);
|
||||
|
||||
const result = await importFromXBVR(
|
||||
mockConfig,
|
||||
mockLogCallback,
|
||||
mockProgressCallback
|
||||
);
|
||||
|
||||
expect(result.videosImported).toBe(0);
|
||||
expect(result.actorsImported).toBe(0);
|
||||
expect(mockLogCallback).toHaveBeenCalledWith('Found 0 videos in \'Recent\' scene group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XBVRConfig', () => {
|
||||
it('should accept valid configuration', () => {
|
||||
const config: XBVRConfig = {
|
||||
url: 'http://localhost:9999',
|
||||
apiKey: 'test-api-key'
|
||||
};
|
||||
|
||||
expect(config.url).toBe('http://localhost:9999');
|
||||
expect(config.apiKey).toBe('test-api-key');
|
||||
expect(config.updateExisting).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept configuration with optional fields', () => {
|
||||
const config: XBVRConfig = {
|
||||
url: 'http://localhost:9999',
|
||||
apiKey: 'test-api-key',
|
||||
updateExisting: true
|
||||
};
|
||||
|
||||
expect(config.updateExisting).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportProgress', () => {
|
||||
it('should have correct structure', () => {
|
||||
const progress: ImportProgress = {
|
||||
current: 5,
|
||||
total: 10,
|
||||
stage: 'importing',
|
||||
message: 'Importing...',
|
||||
videosImported: 5,
|
||||
actorsImported: 3,
|
||||
errors: []
|
||||
};
|
||||
|
||||
expect(progress.current).toBe(5);
|
||||
expect(progress.total).toBe(10);
|
||||
expect(progress.stage).toBe('importing');
|
||||
expect(progress.videosImported).toBe(5);
|
||||
expect(progress.actorsImported).toBe(3);
|
||||
expect(progress.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Staff, Media } from '../../types';
|
||||
import { ApiResponse, PaginatedResponse, ApiCastItem, CreateCastInput, UpdateCastInput } from './types';
|
||||
import { convertApiCastToStaff, convertApiToMedia } from './converters';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<Staff[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<ApiCastItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiCastToStaff);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching cast from API:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCastById(id: number | string): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching cast by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCastMedia(castId: number | string): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${castId}/media`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<any>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching cast media:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCast(cast: CreateCastInput): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(cast),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating cast:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCast(id: number | string, cast: UpdateCastInput): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(cast),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating cast:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCast(id: number | string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<{ message: string }> = await response.json();
|
||||
return data.success;
|
||||
} catch (error) {
|
||||
console.error('Error deleting cast:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy functions for compatibility
|
||||
export async function fetchAllActors(): Promise<Array<{id: number, name: string, photo: string | null}>> {
|
||||
try {
|
||||
const media = await (await import('./mediaApi')).fetchAllMedia(1, 1000);
|
||||
const actorMap = new Map<number, {id: number, name: string, photo: string | null}>();
|
||||
|
||||
media.forEach(item => {
|
||||
item.staff?.forEach(staffMember => {
|
||||
const id = parseInt(staffMember.id);
|
||||
if (!actorMap.has(id)) {
|
||||
actorMap.set(id, {
|
||||
id: id,
|
||||
name: staffMember.name,
|
||||
photo: staffMember.photo
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(actorMap.values());
|
||||
} catch (error) {
|
||||
console.error('Error fetching all actors:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
|
||||
try {
|
||||
const media = await (await import('./mediaApi')).fetchAllMedia(1, 1000);
|
||||
return media.filter(item =>
|
||||
item.staff?.some(staffMember =>
|
||||
staffMember.name.toLowerCase().includes(actorName.toLowerCase())
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by actor:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Media, Staff, UserSettings, MediaCategory } from '../../types';
|
||||
import { ApiMediaItem, ApiStaff, ApiCastItem, ApiSettingsItem, CreateSettingsInput } from './types';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
function normalizeUrl(url: string | null): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
|
||||
return `${BASE_URL}/${cleanPath}`;
|
||||
}
|
||||
|
||||
export function convertApiCastToStaff(apiItem: ApiCastItem): Staff {
|
||||
return {
|
||||
id: apiItem.id.toString(),
|
||||
name: apiItem.name,
|
||||
cleanname: apiItem.cleanname,
|
||||
role: apiItem.occupations?.[0] || 'Actor',
|
||||
photo: normalizeUrl(apiItem.photo) || `https://picsum.photos/seed/cast-${apiItem.id}/200/200`,
|
||||
bio: apiItem.bio || undefined,
|
||||
birthDate: apiItem.birthDate || undefined,
|
||||
birthPlace: apiItem.birthPlace || undefined,
|
||||
occupations: apiItem.occupations || ['Actor'],
|
||||
createdAt: apiItem.createdAt,
|
||||
updatedAt: apiItem.updatedAt,
|
||||
bust_size: apiItem.bust_size,
|
||||
cup_size: apiItem.cup_size,
|
||||
waist_size: apiItem.waist_size,
|
||||
hip_size: apiItem.hip_size,
|
||||
height: apiItem.height,
|
||||
weight: apiItem.weight,
|
||||
hair_color: apiItem.hair_color,
|
||||
eye_color: apiItem.eye_color,
|
||||
ethnicity: apiItem.ethnicity,
|
||||
filmography: apiItem.filmography?.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
poster: normalizeUrl(item.poster) || `https://picsum.photos/seed/${item.id}/400/600`,
|
||||
category: item.category,
|
||||
type: item.type,
|
||||
role: item.role,
|
||||
characterName: item.characterName
|
||||
})),
|
||||
media_types: apiItem.media_types,
|
||||
adult_specifics: apiItem.adult_specifics
|
||||
};
|
||||
}
|
||||
|
||||
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
const staff: Staff[] = (apiItem.staff || []).map((staffMember) => ({
|
||||
id: staffMember.id.toString(),
|
||||
name: staffMember.name,
|
||||
role: staffMember.role,
|
||||
photo: normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
||||
characterName: staffMember.characterName || staffMember.name,
|
||||
characterImage: normalizeUrl(staffMember.characterImage) || normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
||||
}));
|
||||
|
||||
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
if (apiItem.aspectRatio) {
|
||||
const ratio = apiItem.aspectRatio.toLowerCase();
|
||||
if (ratio.includes('16:9') || ratio.includes('16/9') || ratio.includes('1.78') || ratio.includes('2.39')) {
|
||||
aspectRatio = '16/9';
|
||||
} else if (ratio.includes('1:1') || ratio.includes('1/1') || ratio.includes('1.00')) {
|
||||
aspectRatio = '1/1';
|
||||
} else if (ratio.includes('2/3')) {
|
||||
aspectRatio = '2/3';
|
||||
}
|
||||
}
|
||||
|
||||
let mediaType: 'TV' | 'Movie' | 'OVA' | 'ONA' | 'Album' | 'Single' | 'Hardcover' | 'E-book' | 'Console' | 'Game' = 'Movie';
|
||||
const apiType = apiItem.type?.toLowerCase();
|
||||
if (apiType === 'tv' || apiType === 'episode') {
|
||||
mediaType = 'TV';
|
||||
} else if (apiType === 'album' || apiType === 'single') {
|
||||
mediaType = apiType === 'album' ? 'Album' : 'Single';
|
||||
} else if (apiType === 'game' || apiType === 'console') {
|
||||
mediaType = apiType === 'game' ? 'Game' : 'Console';
|
||||
} else if (apiType === 'ova') {
|
||||
mediaType = 'OVA';
|
||||
} else if (apiType === 'ona') {
|
||||
mediaType = 'ONA';
|
||||
} else if (apiType === 'hardcover' || apiType === 'e-book') {
|
||||
mediaType = apiType === 'hardcover' ? 'Hardcover' : 'E-book';
|
||||
}
|
||||
|
||||
let mediaCategory: 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games' = 'Movies';
|
||||
const apiCategory = apiItem.category?.toLowerCase();
|
||||
|
||||
if (apiCategory === 'anime') {
|
||||
mediaCategory = 'Anime';
|
||||
} else if (apiCategory === 'movie' || apiCategory === 'movies') {
|
||||
mediaCategory = 'Movies';
|
||||
} else if (apiCategory === 'tv' || apiCategory === 'series' || apiCategory === 'tv series' || apiType === 'tv' || apiType === 'episode') {
|
||||
mediaCategory = 'TV Series';
|
||||
} else if (apiCategory === 'music' || apiType === 'album' || apiType === 'single') {
|
||||
mediaCategory = 'Music';
|
||||
} else if (apiCategory === 'book' || apiCategory === 'books' || apiType === 'hardcover' || apiType === 'e-book') {
|
||||
mediaCategory = 'Books';
|
||||
} else if (apiCategory === 'adult') {
|
||||
mediaCategory = 'Adult';
|
||||
} else if (apiCategory === 'console' || apiCategory === 'consoles' || apiType === 'console') {
|
||||
mediaCategory = 'Consoles';
|
||||
} else if (apiCategory === 'game' || apiCategory === 'games' || apiType === 'game') {
|
||||
mediaCategory = 'Games';
|
||||
} else {
|
||||
console.warn('Unknown category:', apiItem.category, 'defaulting to Movies');
|
||||
mediaCategory = 'Movies';
|
||||
}
|
||||
|
||||
let mediaStatus: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold' = 'completed';
|
||||
const apiStatus = apiItem.status?.toLowerCase();
|
||||
if (apiStatus === 'ongoing' || apiStatus === 'watching') {
|
||||
mediaStatus = 'watching';
|
||||
} else if (apiStatus === 'upcoming' || apiStatus === 'planned') {
|
||||
mediaStatus = 'planned';
|
||||
} else if (apiStatus === 'dropped') {
|
||||
mediaStatus = 'dropped';
|
||||
} else if (apiStatus === 'reading') {
|
||||
mediaStatus = 'reading';
|
||||
} else if (apiStatus === 'listening') {
|
||||
mediaStatus = 'listening';
|
||||
} else if (apiStatus === 'playing') {
|
||||
mediaStatus = 'playing';
|
||||
} else if (apiStatus === 'on-hold') {
|
||||
mediaStatus = 'on-hold';
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiItem.id.toString(),
|
||||
title: apiItem.title,
|
||||
year: apiItem.year?.toString() || 'Unknown',
|
||||
poster: normalizeUrl(apiItem.poster) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
|
||||
category: mediaCategory,
|
||||
banner: normalizeUrl(apiItem.banner) || undefined,
|
||||
description: apiItem.description || undefined,
|
||||
rating: apiItem.rating || undefined,
|
||||
genres: apiItem.genres || [],
|
||||
tags: apiItem.tags || [],
|
||||
studios: apiItem.studios,
|
||||
type: mediaType,
|
||||
source: apiItem.source || undefined,
|
||||
status: mediaStatus,
|
||||
staff: staff.length > 0 ? staff : undefined,
|
||||
aspectRatio: aspectRatio,
|
||||
categories: apiItem.categories,
|
||||
series: apiItem.series,
|
||||
platforms: apiItem.platforms,
|
||||
developers: apiItem.developers,
|
||||
completionStatus: apiItem.completionStatus,
|
||||
playCount: apiItem.playCount,
|
||||
lastActivity: apiItem.lastActivity,
|
||||
playtime: apiItem.playtime,
|
||||
episodes: apiItem.episodes,
|
||||
tracks: apiItem.tracks
|
||||
};
|
||||
}
|
||||
|
||||
export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
|
||||
return {
|
||||
id: apiItem.id,
|
||||
enabledCategories: apiItem.enabled_categories as MediaCategory[],
|
||||
itemsPerPage: apiItem.items_per_page || 20,
|
||||
gridItemSize: apiItem.grid_item_size || 5,
|
||||
defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid',
|
||||
showAdultContent: apiItem.show_adult_content || false,
|
||||
autoPlayTrailers: apiItem.auto_play_trailers || false,
|
||||
language: apiItem.language || 'en',
|
||||
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
|
||||
jellyfinLibraryMappings: apiItem.jellyfin_library_mappings,
|
||||
|
||||
// Page Settings
|
||||
pageTitle: apiItem.page_title,
|
||||
favicon: apiItem.favicon,
|
||||
customColors: apiItem.custom_colors ? JSON.parse(apiItem.custom_colors) : undefined,
|
||||
|
||||
createdAt: apiItem.created_at,
|
||||
updatedAt: apiItem.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function convertSettingsToApi(settings: UserSettings): CreateSettingsInput {
|
||||
return {
|
||||
enabled_categories: settings.enabledCategories,
|
||||
items_per_page: settings.itemsPerPage,
|
||||
grid_item_size: settings.gridItemSize,
|
||||
default_view: settings.defaultView,
|
||||
show_adult_content: settings.showAdultContent,
|
||||
auto_play_trailers: settings.autoPlayTrailers,
|
||||
language: settings.language,
|
||||
theme: settings.theme,
|
||||
jellyfin_library_mappings: settings.jellyfinLibraryMappings,
|
||||
|
||||
// Page Settings
|
||||
page_title: settings.pageTitle,
|
||||
favicon: settings.favicon,
|
||||
custom_colors: settings.customColors ? JSON.stringify(settings.customColors) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Media } from '../../types';
|
||||
import { ApiResponse, PaginatedResponse, ApiMediaItem, CreateMediaInput, UpdateMediaInput } from './types';
|
||||
import { convertApiToMedia } from './converters';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching media from API:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaById(id: number | string): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMedia(media: CreateMediaInput): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(media),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating media:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMedia(id: number | string, media: UpdateMediaInput): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(media),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating media:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMedia(id: number | string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<{ message: string }> = await response.json();
|
||||
return data.success;
|
||||
} catch (error) {
|
||||
console.error('Error deleting media:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { UserSettings } from '../../types';
|
||||
import { ApiResponse, ApiSettingsItem, CreateSettingsInput, UpdateSettingsInput } from './types';
|
||||
import { convertApiToSettings, convertSettingsToApi } from './converters';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export async function fetchSettings(): Promise<UserSettings | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/settings`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||
try {
|
||||
const apiSettings = convertSettingsToApi(settings);
|
||||
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(apiSettings),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Create settings error response:', errorText);
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||
try {
|
||||
const apiSettings = convertSettingsToApi(settings);
|
||||
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(apiSettings),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return createSettings(settings);
|
||||
}
|
||||
const errorText = await response.text();
|
||||
console.error('Update settings error response:', errorText);
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// API Response Types
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
// Media Types
|
||||
export interface ApiEpisode {
|
||||
id: number;
|
||||
media_id: number;
|
||||
season: number;
|
||||
episode_number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
air_date: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ApiTrack {
|
||||
id: number;
|
||||
media_id: number;
|
||||
track_number: number;
|
||||
title: string;
|
||||
duration: number | null;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface ApiMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
poster: string | null;
|
||||
banner: string | null;
|
||||
description: string | null;
|
||||
rating: number | null;
|
||||
category: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
aspectRatio: string | null;
|
||||
runtime: number | null;
|
||||
director: string | null;
|
||||
writer: string | null;
|
||||
releaseDate: string | null;
|
||||
source?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
studios?: string[];
|
||||
staff?: ApiStaff[];
|
||||
categories?: string[];
|
||||
series?: string[];
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
playCount?: number;
|
||||
lastActivity?: string | null;
|
||||
playtime?: number;
|
||||
episodes?: ApiEpisode[];
|
||||
tracks?: ApiTrack[];
|
||||
}
|
||||
|
||||
export interface ApiStaff {
|
||||
id: number;
|
||||
name: string;
|
||||
photo: string | null;
|
||||
bio: string | null;
|
||||
birthDate: string | null;
|
||||
birthPlace: string | null;
|
||||
role: string;
|
||||
characterName: string | null;
|
||||
characterImage: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
export interface CreateMediaInput {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string | null;
|
||||
banner?: string | null;
|
||||
description?: string | null;
|
||||
rating?: number | null;
|
||||
category?: string | null;
|
||||
type?: string;
|
||||
status?: string;
|
||||
aspectRatio?: string | null;
|
||||
runtime?: number | null;
|
||||
director?: string | null;
|
||||
writer?: string | null;
|
||||
releaseDate?: string | null;
|
||||
source?: string | null;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
studios?: string[];
|
||||
staff?: CreateStaffInput[];
|
||||
}
|
||||
|
||||
export interface UpdateMediaInput extends Partial<CreateMediaInput> {}
|
||||
|
||||
export interface CreateStaffInput {
|
||||
name: string;
|
||||
photo?: string | null;
|
||||
bio?: string | null;
|
||||
birthDate?: string | null;
|
||||
birthPlace?: string | null;
|
||||
role: string;
|
||||
characterName?: string | null;
|
||||
characterImage?: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
// Cast Types
|
||||
export interface ApiCastItem {
|
||||
id: number;
|
||||
name: string;
|
||||
cleanname?: string;
|
||||
photo: string | null;
|
||||
bio: string | null;
|
||||
birthDate: string | null;
|
||||
birthPlace: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
occupations?: string[];
|
||||
filmography?: ApiCastMediaItem[];
|
||||
media_types?: string[];
|
||||
bust_size?: number | null;
|
||||
cup_size?: string | null;
|
||||
waist_size?: number | null;
|
||||
hip_size?: number | null;
|
||||
height?: number | null;
|
||||
weight?: number | null;
|
||||
hair_color?: string | null;
|
||||
eye_color?: string | null;
|
||||
ethnicity?: string | null;
|
||||
adult_specifics?: {
|
||||
id: number;
|
||||
cast_id: number;
|
||||
bust_size?: number | null;
|
||||
cup_size?: string | null;
|
||||
waist_size?: number | null;
|
||||
hip_size?: number | null;
|
||||
height?: number | null;
|
||||
weight?: number | null;
|
||||
hair_color?: string | null;
|
||||
eye_color?: string | null;
|
||||
ethnicity?: string | null;
|
||||
tattoos?: string | null;
|
||||
piercings?: string | null;
|
||||
measurements?: string | null;
|
||||
shoe_size?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiCastMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
poster: string | null;
|
||||
category: string | null;
|
||||
type: string;
|
||||
role: string;
|
||||
characterName?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateCastInput {
|
||||
name: string;
|
||||
photo?: string | null;
|
||||
bio?: string | null;
|
||||
birthDate?: string | null;
|
||||
birthPlace?: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateCastInput extends Partial<CreateCastInput> {}
|
||||
|
||||
// Settings Types
|
||||
export interface ApiSettingsItem {
|
||||
id?: number;
|
||||
enabled_categories: string[];
|
||||
items_per_page: number;
|
||||
grid_item_size?: number;
|
||||
default_view: string;
|
||||
show_adult_content: boolean;
|
||||
auto_play_trailers: boolean;
|
||||
language: string;
|
||||
theme: string;
|
||||
jellyfin_library_mappings?: string;
|
||||
|
||||
// Page Settings
|
||||
page_title?: string;
|
||||
favicon?: string;
|
||||
custom_colors?: string; // JSON string of CustomColors
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateSettingsInput {
|
||||
enabled_categories: string[];
|
||||
items_per_page?: number;
|
||||
grid_item_size?: number;
|
||||
default_view?: string;
|
||||
show_adult_content?: boolean;
|
||||
auto_play_trailers?: boolean;
|
||||
language?: string;
|
||||
theme?: string;
|
||||
jellyfin_library_mappings?: string;
|
||||
|
||||
// Page Settings
|
||||
page_title?: string;
|
||||
favicon?: string;
|
||||
custom_colors?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
|
||||
+156
-28
@@ -1,38 +1,82 @@
|
||||
/**
|
||||
* Jellyfin Importer Module
|
||||
*
|
||||
* This module provides functionality to import media from a Jellyfin media server into the Omnyx media database.
|
||||
* It supports importing movies, TV series (including episodes), music albums, and cast members.
|
||||
* The module handles library mapping to categorize content appropriately and supports both new imports
|
||||
* and updates to existing entries.
|
||||
*
|
||||
* @module jellyfinImporter
|
||||
*/
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff, Episode, Track } from '@/types';
|
||||
|
||||
/**
|
||||
* Configuration for connecting to a Jellyfin instance
|
||||
*/
|
||||
export interface JellyfinConfig {
|
||||
/** URL of the Jellyfin server */
|
||||
url: string;
|
||||
/** API key for authentication with Jellyfin */
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping configuration for Jellyfin libraries to Omnyx categories
|
||||
*/
|
||||
export interface LibraryMapping {
|
||||
/** Name of the Jellyfin library */
|
||||
libraryName: string;
|
||||
/** Category to map this library to (use 'skip' to exclude the library) */
|
||||
category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip';
|
||||
pathSegments?: string[]; // Additional path segments that map to this library
|
||||
/** Additional path segments that map to this library */
|
||||
pathSegments?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for controlling the Jellyfin import process
|
||||
*/
|
||||
export interface JellyfinImportOptions {
|
||||
/** Whether to import movies */
|
||||
importMovies?: boolean;
|
||||
/** Whether to import TV series */
|
||||
importSeries?: boolean;
|
||||
/** Whether to import music */
|
||||
importMusic?: boolean;
|
||||
/** Whether to import cast members */
|
||||
importCast?: boolean;
|
||||
/** Maximum number of items to import (optional) */
|
||||
limit?: number;
|
||||
/** Library to category mappings */
|
||||
libraryMappings?: LibraryMapping[];
|
||||
updateExisting?: boolean; // If true, update existing items; if false, only import new items
|
||||
/** If true, update existing items; if false, only import new items */
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking for the import operation
|
||||
*/
|
||||
export interface ImportProgress {
|
||||
/** Current number of items processed */
|
||||
current: number;
|
||||
/** Total number of items to process */
|
||||
total: number;
|
||||
/** Current stage of the import process */
|
||||
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
|
||||
/** Human-readable status message */
|
||||
message: string;
|
||||
/** Number of movies successfully imported */
|
||||
moviesImported: number;
|
||||
/** Number of series successfully imported */
|
||||
seriesImported: number;
|
||||
/** Number of music items successfully imported */
|
||||
musicImported: number;
|
||||
/** Number of cast members successfully imported */
|
||||
castImported: number;
|
||||
/** Array of error messages encountered during import */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
@@ -56,7 +100,7 @@ export interface JellyfinItem {
|
||||
Type: string;
|
||||
Role?: string;
|
||||
PrimaryImageTag?: string;
|
||||
ImageBlurHashes?: any;
|
||||
ImageBlurHashes?: Record<string, Record<string, string>>;
|
||||
}>;
|
||||
ImageTags?: {
|
||||
Primary?: string;
|
||||
@@ -96,7 +140,7 @@ export interface JellyfinPerson {
|
||||
Name: string;
|
||||
Type: string;
|
||||
PrimaryImageTag?: string;
|
||||
ImageBlurHashes?: any;
|
||||
ImageBlurHashes?: Record<string, Record<string, string>>;
|
||||
PremiereDate?: string;
|
||||
ProductionYear?: number;
|
||||
Overview?: string;
|
||||
@@ -105,10 +149,45 @@ export interface JellyfinPerson {
|
||||
PlaceOfBirth?: string;
|
||||
}
|
||||
|
||||
export interface JellyfinEpisode {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
PremiereDate?: string;
|
||||
RunTimeTicks?: number;
|
||||
ParentIndexNumber?: number;
|
||||
IndexNumber?: number;
|
||||
ImageTags?: {
|
||||
Primary?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface JellyfinTrack {
|
||||
Id: string;
|
||||
Name: string;
|
||||
IndexNumber?: number;
|
||||
RunTimeTicks?: number;
|
||||
AlbumArtist?: string;
|
||||
Artists?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for logging import progress messages
|
||||
* @param message - The log message to display
|
||||
*/
|
||||
export type LogCallback = (message: string) => void;
|
||||
|
||||
/**
|
||||
* Callback function for updating import progress
|
||||
* @param progress - Partial progress object with updated fields
|
||||
*/
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
|
||||
// Helper function to normalize URL (avoid double slashes)
|
||||
/**
|
||||
* Normalizes a URL by removing trailing slashes
|
||||
* @param url - The URL to normalize
|
||||
* @returns The normalized URL
|
||||
*/
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
@@ -135,12 +214,20 @@ function getJellyfinImageUrl(config: JellyfinConfig, itemId: string, imageTag: s
|
||||
return `${normalizeUrl(config.url)}/Items/${itemId}/Images/${imageType}?tag=${imageTag}`;
|
||||
}
|
||||
|
||||
// Helper function to convert ticks to minutes
|
||||
/**
|
||||
* Converts Jellyfin ticks (100ns units) to minutes
|
||||
* @param ticks - Time in ticks (100 nanosecond units)
|
||||
* @returns Time in minutes
|
||||
*/
|
||||
function ticksToMinutes(ticks: number): number {
|
||||
return Math.floor(ticks / 600000000);
|
||||
}
|
||||
|
||||
// Helper function to format date
|
||||
/**
|
||||
* Formats a date string to ISO format (YYYY-MM-DD)
|
||||
* @param dateString - The date string to format
|
||||
* @returns Formatted date string or null if invalid
|
||||
*/
|
||||
function formatDate(dateString?: string): string | null {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
@@ -151,7 +238,11 @@ function formatDate(dateString?: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get year from date
|
||||
/**
|
||||
* Extracts the year from a date string
|
||||
* @param dateString - The date string to extract year from
|
||||
* @returns The year as a number
|
||||
*/
|
||||
function getYear(dateString?: string): number {
|
||||
if (!dateString) return new Date().getFullYear();
|
||||
try {
|
||||
@@ -219,7 +310,11 @@ async function fetchWithAuth(url: string, apiKey: string, options: RequestInit =
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
|
||||
// Fetch libraries from Jellyfin
|
||||
/**
|
||||
* Fetches all libraries from a Jellyfin instance
|
||||
* @param config - Configuration for connecting to Jellyfin
|
||||
* @returns Promise resolving to an array of library information
|
||||
*/
|
||||
export async function fetchJellyfinLibraries(config: JellyfinConfig): Promise<Array<{ Id: string; Name: string; CollectionType: string }>> {
|
||||
const userId = await getJellyfinUserId(config);
|
||||
|
||||
@@ -575,10 +670,12 @@ async function convertJellyfinSeriesToMedia(
|
||||
const writers = item.People?.filter(p => p.Type === 'Writer').map(p => p.Name) || [];
|
||||
|
||||
// Fetch episodes for this series
|
||||
let episodes: any[] = [];
|
||||
let episodes: Episode[] = [];
|
||||
try {
|
||||
const jellyfinEpisodes = await fetchJellyfinSeriesEpisodes(config, item.Id);
|
||||
episodes = jellyfinEpisodes.map(ep => ({
|
||||
id: parseInt(ep.Id),
|
||||
media_id: parseInt(item.Id),
|
||||
season: ep.ParentIndexNumber || 1,
|
||||
episode_number: ep.IndexNumber || 1,
|
||||
title: ep.Name,
|
||||
@@ -682,14 +779,16 @@ async function convertJellyfinAlbumToMedia(
|
||||
}));
|
||||
|
||||
// Fetch tracks for this album
|
||||
let tracks: any[] = [];
|
||||
let tracks: Track[] = [];
|
||||
try {
|
||||
const jellyfinTracks = await fetchJellyfinAlbumTracks(config, item.Id);
|
||||
tracks = jellyfinTracks.map((track, index) => ({
|
||||
id: parseInt(track.Id),
|
||||
media_id: parseInt(item.Id),
|
||||
track_number: track.IndexNumber || (index + 1),
|
||||
title: track.Name,
|
||||
duration: track.RunTimeTicks ? `${Math.floor(track.RunTimeTicks / 600000000 / 60)}:${String(Math.floor((track.RunTimeTicks / 600000000) % 60)).padStart(2, '0')}` : null,
|
||||
artist: track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown'
|
||||
duration: track.RunTimeTicks ? Math.floor(track.RunTimeTicks / 600000000) : null,
|
||||
artist: (track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown') as string
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch tracks for album ${item.Name}:`, error);
|
||||
@@ -721,22 +820,51 @@ async function convertJellyfinAlbumToMedia(
|
||||
}
|
||||
|
||||
// Convert Jellyfin person to API cast format
|
||||
function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): any {
|
||||
function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): Staff {
|
||||
const photo = person.PrimaryImageTag
|
||||
? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary')
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: person.Id,
|
||||
name: person.Name,
|
||||
role: person.Type || 'Actor',
|
||||
photo: photo,
|
||||
bio: person.Overview || null,
|
||||
birthDate: person.BirthDate ? formatDate(person.BirthDate) : null,
|
||||
birthPlace: person.PlaceOfBirth || null,
|
||||
occupations: [person.Type === 'Actor' ? 'Actor' : person.Type || 'Person']
|
||||
occupations: ['Actor']
|
||||
};
|
||||
}
|
||||
|
||||
// Main import function
|
||||
/**
|
||||
* Imports media from a Jellyfin instance into the Omnyx media database
|
||||
*
|
||||
* This function performs the following steps:
|
||||
* 1. Fetches existing media and cast from Omnyx to check for duplicates
|
||||
* 2. Fetches Jellyfin libraries for category mapping (if library mappings are provided)
|
||||
* 3. Imports movies (if enabled)
|
||||
* 4. Imports TV series with episodes (if enabled)
|
||||
* 5. Imports music albums with tracks (if enabled)
|
||||
* 6. Imports cast members (if enabled)
|
||||
*
|
||||
* @param config - Configuration for connecting to Jellyfin
|
||||
* @param options - Import options to control behavior
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const progress = await importFromJellyfin(
|
||||
* { url: 'http://localhost:8096', apiKey: 'your-api-key' },
|
||||
* { importMovies: true, importSeries: true, libraryMappings: [...] },
|
||||
* (msg) => console.log(msg),
|
||||
* (prog) => updateUI(prog)
|
||||
* );
|
||||
* console.log(`Imported ${progress.moviesImported} movies and ${progress.seriesImported} series`);
|
||||
* ```
|
||||
*/
|
||||
export async function importFromJellyfin(
|
||||
config: JellyfinConfig,
|
||||
options: JellyfinImportOptions,
|
||||
@@ -767,19 +895,19 @@ export async function importFromJellyfin(
|
||||
logCallback('Starting Jellyfin import...');
|
||||
|
||||
// Step 0: Fetch existing media and cast to check for duplicates
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const existingMedia = new Map(
|
||||
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
|
||||
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
|
||||
);
|
||||
logCallback(`Found ${existingMedia.size} existing media items in database`);
|
||||
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingCast = new Map(
|
||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingCast.size} existing cast members in database`);
|
||||
|
||||
@@ -1169,18 +1297,18 @@ export async function cleanupJellyfinMedia(
|
||||
try {
|
||||
logCallback('Starting Jellyfin cleanup...');
|
||||
|
||||
// Fetch all existing media from Kyoo API
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
// Fetch all existing media from Omnyx API
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: any) => m.source === 'jellyfin');
|
||||
const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: Media) => m.source === 'jellyfin');
|
||||
logCallback(`Found ${jellyfinMedia.length} Jellyfin media items in database`);
|
||||
|
||||
// Fetch all existing cast from Kyoo API
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
// Fetch all existing cast from Omnyx API
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const jellyfinCast = (existingCastData.data?.items || []).filter((c: any) => c.photo && c.photo.includes(normalizeUrl(config.url)));
|
||||
const jellyfinCast = (existingCastData.data?.items || []).filter((c: Staff) => c.photo && c.photo.includes(normalizeUrl(config.url)));
|
||||
logCallback(`Found ${jellyfinCast.length} Jellyfin cast members in database`);
|
||||
|
||||
// Fetch current items from Jellyfin
|
||||
|
||||
+237
-14
@@ -1,71 +1,163 @@
|
||||
/**
|
||||
* Playnite Importer Module
|
||||
*
|
||||
* This module provides functionality to import games from a Playnite library into the Omnyx media database.
|
||||
* It fetches game data from the Playnite API, converts it to the Omnyx media format, and handles both
|
||||
* new imports and updates to existing entries.
|
||||
*
|
||||
* @module playniteImporter
|
||||
*/
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||
|
||||
/**
|
||||
* Configuration for connecting to a Playnite instance
|
||||
*/
|
||||
export interface PlayniteConfig {
|
||||
/** IP address of the Playnite server */
|
||||
ip: string;
|
||||
/** API token for authentication with Playnite */
|
||||
apiToken: string;
|
||||
/** Port number of the Playnite API (default: 19821) */
|
||||
port?: number;
|
||||
/** If true, update existing media entries; if false, only import new entries */
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for controlling the Playnite import process
|
||||
*/
|
||||
export interface PlayniteImportOptions {
|
||||
/** Maximum number of items to import (optional) */
|
||||
limit?: number;
|
||||
/** Filter items by name (case-insensitive, optional - for debugging) */
|
||||
nameFilter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking for the import operation
|
||||
*/
|
||||
export interface ImportProgress {
|
||||
/** Current number of items processed */
|
||||
current: number;
|
||||
/** Total number of items to process */
|
||||
total: number;
|
||||
/** Current stage of the import process */
|
||||
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
|
||||
/** Human-readable status message */
|
||||
message: string;
|
||||
/** Number of games successfully imported */
|
||||
gamesImported: number;
|
||||
/** Array of error messages encountered during import */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Game data structure as returned by the Playnite API
|
||||
*/
|
||||
export interface PlayniteGame {
|
||||
/** Unique identifier for the game */
|
||||
id: string;
|
||||
/** Game name */
|
||||
name: string;
|
||||
/** Alternate name for sorting purposes */
|
||||
sortingName?: string;
|
||||
/** Game description */
|
||||
description?: string;
|
||||
/** User notes */
|
||||
notes?: string;
|
||||
/** Game version */
|
||||
version?: string;
|
||||
/** Whether the game is hidden */
|
||||
hidden?: boolean;
|
||||
/** Whether the game is marked as favorite */
|
||||
favorite?: boolean;
|
||||
/** User rating (0-100) */
|
||||
userScore?: number;
|
||||
/** Community rating (0-100) */
|
||||
communityScore?: number;
|
||||
/** Critic rating (0-100) */
|
||||
criticScore?: number;
|
||||
/** Release date in ISO format */
|
||||
releaseDate?: string;
|
||||
/** Completion status (e.g., 'Completed', 'Playing', 'Abandoned') */
|
||||
completionStatus?: string;
|
||||
/** Game categories */
|
||||
categories?: string[];
|
||||
/** Game tags */
|
||||
tags?: string[];
|
||||
/** Game features */
|
||||
features?: string[];
|
||||
/** Game genres */
|
||||
genres?: string[];
|
||||
/** Developer names */
|
||||
developers?: string[];
|
||||
/** Publisher names */
|
||||
publishers?: string[];
|
||||
/** Series name */
|
||||
series?: string[];
|
||||
/** Platform names */
|
||||
platforms?: string[];
|
||||
/** Age rating names */
|
||||
ageRatings?: string[];
|
||||
/** Region names */
|
||||
regions?: string[];
|
||||
/** External links */
|
||||
links?: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
/** Total playtime in seconds */
|
||||
playtime?: number;
|
||||
/** Number of times played */
|
||||
playCount?: number;
|
||||
/** Last activity timestamp */
|
||||
lastActivity?: string;
|
||||
/** Date added to library */
|
||||
added?: string;
|
||||
/** Last played date */
|
||||
lastPlayed?: string;
|
||||
/** Source platform/library */
|
||||
source?: string;
|
||||
/** Whether the game is currently installed */
|
||||
isInstalled?: boolean;
|
||||
/** Cover image as base64 data URI */
|
||||
coverBase64?: string;
|
||||
/** Background image as base64 data URI */
|
||||
backgroundBase64?: string;
|
||||
/** Icon image as base64 data URI */
|
||||
iconBase64?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response structure for the Playnite games API endpoint
|
||||
*/
|
||||
export interface PlayniteGamesResponse {
|
||||
/** Total number of games available */
|
||||
total: number;
|
||||
/** Offset for pagination */
|
||||
offset: number;
|
||||
/** Limit for pagination */
|
||||
limit: number;
|
||||
/** Array of game objects */
|
||||
games: PlayniteGame[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for logging import progress messages
|
||||
* @param message - The log message to display
|
||||
*/
|
||||
export type LogCallback = (message: string) => void;
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
|
||||
/**
|
||||
* Callback function for updating import progress
|
||||
* @param progress - Partial progress object with updated fields
|
||||
*/
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
/*
|
||||
async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const coverResponse = await fetch(`${baseUrl}/api/games/${gameId}/cover`, {
|
||||
@@ -89,8 +181,80 @@ async function fetchGameCover(baseUrl: string, headers: Record<string, string>,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGameBackground(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const backgroundResponse = await fetch(`${baseUrl}/api/games/${gameId}/background`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!backgroundResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = await backgroundResponse.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
|
||||
const mimeType = blob.type || 'image/jpeg';
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const iconResponse = await fetch(`${baseUrl}/api/games/${gameId}/icon`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!iconResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = await iconResponse.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
|
||||
const mimeType = blob.type || 'image/png';
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
*/
|
||||
/**
|
||||
* Imports games from a Playnite library into the Omnyx media database
|
||||
*
|
||||
* This function performs the following steps:
|
||||
* 1. Fetches existing media from Omnyx to check for duplicates
|
||||
* 2. Fetches all games from the Playnite API
|
||||
* 3. Fetches detailed information for each game
|
||||
* 4. Converts Playnite game data to Omnyx media format
|
||||
* 5. Imports or updates each game in the Omnyx database
|
||||
*
|
||||
* @param config - Configuration for connecting to Playnite
|
||||
* @param options - Import options to control behavior
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const progress = await importFromPlaynite(
|
||||
* { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 },
|
||||
* { limit: 10, nameFilter: 'Reside' },
|
||||
* (msg) => console.log(msg),
|
||||
* (prog) => updateUI(prog)
|
||||
* );
|
||||
* console.log(`Imported ${progress.gamesImported} games`);
|
||||
* ```
|
||||
*/
|
||||
export async function importFromPlaynite(
|
||||
config: PlayniteConfig,
|
||||
options: PlayniteImportOptions,
|
||||
logCallback: LogCallback,
|
||||
progressCallback: ProgressCallback
|
||||
): Promise<ImportProgress> {
|
||||
@@ -103,6 +267,8 @@ export async function importFromPlaynite(
|
||||
errors: []
|
||||
};
|
||||
|
||||
const { limit, nameFilter } = options;
|
||||
|
||||
const baseUrl = `http://${config.ip}:${config.port || 19821}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -113,11 +279,14 @@ export async function importFromPlaynite(
|
||||
logCallback('Starting Playnite import...');
|
||||
|
||||
// Step 0: Fetch existing media to check for duplicates and enable updates
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const existingMedia = new Map(
|
||||
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
|
||||
(existingMediaData.data?.items || []).map((m: Media) => [
|
||||
m.cleanname || m.title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-'),
|
||||
m
|
||||
])
|
||||
);
|
||||
logCallback(`Found ${existingMedia.size} existing games in database`);
|
||||
|
||||
@@ -125,7 +294,7 @@ export async function importFromPlaynite(
|
||||
logCallback(`Fetching games from ${baseUrl}/api/games...`);
|
||||
progressCallback({ message: 'Fetching games from Playnite...' });
|
||||
|
||||
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=5000`, {
|
||||
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=${limit || 5000}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
@@ -135,22 +304,49 @@ export async function importFromPlaynite(
|
||||
}
|
||||
|
||||
const gamesData: PlayniteGamesResponse = await gamesResponse.json();
|
||||
const games = gamesData.games || [];
|
||||
let games = gamesData.games || [];
|
||||
|
||||
// Apply name filter if provided (case-insensitive)
|
||||
if (nameFilter) {
|
||||
const filterLower = nameFilter.toLowerCase();
|
||||
games = games.filter(game => game.name?.toLowerCase().includes(filterLower));
|
||||
logCallback(`Filtered to ${games.length} games matching "${nameFilter}"`);
|
||||
}
|
||||
|
||||
// Apply limit if provided (after name filter)
|
||||
if (limit && games.length > limit) {
|
||||
games = games.slice(0, limit);
|
||||
logCallback(`Limited to ${games.length} games`);
|
||||
}
|
||||
|
||||
logCallback(`Found ${games.length} games in Playnite`);
|
||||
|
||||
// Deduplicate games by name (case-insensitive, trimmed)
|
||||
const uniqueGamesMap = new Map<string, PlayniteGame>();
|
||||
for (const game of games) {
|
||||
const normalizedName = game.name.toLowerCase().trim();
|
||||
if (!uniqueGamesMap.has(normalizedName)) {
|
||||
uniqueGamesMap.set(normalizedName, game);
|
||||
}
|
||||
}
|
||||
const uniqueGames = Array.from(uniqueGamesMap.values());
|
||||
if (uniqueGames.length !== games.length) {
|
||||
logCallback(`Deduplicated: ${games.length} → ${uniqueGames.length} unique games`);
|
||||
}
|
||||
|
||||
// Step 2: Fetch detailed information for each game
|
||||
progressCallback({
|
||||
total: games.length,
|
||||
total: uniqueGames.length,
|
||||
current: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Fetching game details...'
|
||||
});
|
||||
|
||||
const detailedGames: PlayniteGame[] = [];
|
||||
for (let i = 0; i < games.length; i++) {
|
||||
const game = games[i];
|
||||
for (let i = 0; i < uniqueGames.length; i++) {
|
||||
const game = uniqueGames[i];
|
||||
try {
|
||||
logCallback(`Fetching details for: ${game.name} (${i + 1}/${games.length})`);
|
||||
logCallback(`Fetching details for: ${game.name} (${i + 1}/${uniqueGames.length})`);
|
||||
|
||||
const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, {
|
||||
method: 'GET',
|
||||
@@ -159,6 +355,18 @@ export async function importFromPlaynite(
|
||||
|
||||
if (detailResponse.ok) {
|
||||
const detailData: PlayniteGame = await detailResponse.json();
|
||||
/*
|
||||
// Fetch images
|
||||
const [cover, background, icon] = await Promise.all([
|
||||
fetchGameCover(baseUrl, headers, game.id),
|
||||
fetchGameBackground(baseUrl, headers, game.id),
|
||||
fetchGameIcon(baseUrl, headers, game.id)
|
||||
]);
|
||||
|
||||
detailData.coverBase64 = cover;
|
||||
detailData.backgroundBase64 = background;
|
||||
detailData.iconBase64 = icon;
|
||||
*/
|
||||
detailedGames.push(detailData);
|
||||
logCallback(`✓ Fetched details for: ${game.name}`);
|
||||
} else {
|
||||
@@ -192,9 +400,24 @@ export async function importFromPlaynite(
|
||||
for (let i = 0; i < detailedGames.length; i++) {
|
||||
const game = detailedGames[i];
|
||||
|
||||
const existingGame = existingMedia.get(game.name);
|
||||
const cleanName = game.name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-');
|
||||
const existingGame = existingMedia.get(cleanName);
|
||||
const isUpdate = existingGame !== undefined;
|
||||
|
||||
if (!isUpdate) {
|
||||
// Debug: show similar titles from database for games not found
|
||||
const similarTitles = Array.from(existingMedia.keys()).filter((key): key is string =>
|
||||
typeof key === 'string' && (key.includes(cleanName.substring(0, 10)) || cleanName.includes(key.substring(0, 10)))
|
||||
).slice(0, 5);
|
||||
if (similarTitles.length > 0) {
|
||||
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND. Similar titles in DB: ${similarTitles.join(', ')}`);
|
||||
} else {
|
||||
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND (will import)`);
|
||||
}
|
||||
} else {
|
||||
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - FOUND (will update)`);
|
||||
}
|
||||
|
||||
// Skip if updateExisting is false and item already exists
|
||||
if (!config.updateExisting && isUpdate) {
|
||||
logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`);
|
||||
@@ -231,7 +454,7 @@ export async function importFromPlaynite(
|
||||
}
|
||||
|
||||
// Staff is for actors/performers only - leave empty for games
|
||||
const staff: any[] = [];
|
||||
const staff: Staff[] = [];
|
||||
// Determine type based on genres/features
|
||||
let type = 'Game';
|
||||
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
|
||||
|
||||
+133
-16
@@ -1,36 +1,77 @@
|
||||
/**
|
||||
* StashAPP Importer Module
|
||||
*
|
||||
* This module provides functionality to import adult video content and performers from a StashAPP instance
|
||||
* into the Omnyx media database. It fetches scene and performer data via GraphQL, converts it to the Omnyx
|
||||
* media format, and handles both new imports and updates to existing entries.
|
||||
*
|
||||
* @module stashappImporter
|
||||
*/
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||
|
||||
/**
|
||||
* Configuration for connecting to a StashAPP instance
|
||||
*/
|
||||
export interface StashAPPConfig {
|
||||
/** URL of the StashAPP server */
|
||||
url: string;
|
||||
/** API key for authentication (optional) */
|
||||
apiKey?: string;
|
||||
blacklist?: ['/AI/', 'temp', 'backup'];
|
||||
/** List of path patterns to blacklist during import */
|
||||
blacklist?: string[];
|
||||
/** If true, update existing media entries; if false, only import new entries */
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking for the import operation
|
||||
*/
|
||||
export interface ImportProgress {
|
||||
/** Current number of items processed */
|
||||
current: number;
|
||||
/** Total number of items to process */
|
||||
total: number;
|
||||
/** Current stage of the import process */
|
||||
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
|
||||
/** Human-readable status message */
|
||||
message: string;
|
||||
/** Number of videos successfully imported */
|
||||
videosImported: number;
|
||||
/** Number of actors successfully imported */
|
||||
actorsImported: number;
|
||||
/** Array of error messages encountered during import */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene data structure as returned by the StashAPP GraphQL API
|
||||
*/
|
||||
export interface StashAPPScene {
|
||||
/** Unique identifier for the scene */
|
||||
id: string;
|
||||
/** Scene title */
|
||||
title: string;
|
||||
/** Scene description/details */
|
||||
details: string;
|
||||
/** Scene URL */
|
||||
url: string;
|
||||
/** Release date in ISO format */
|
||||
date: string;
|
||||
/** Rating on a 0-100 scale */
|
||||
rating100: number;
|
||||
/** Whether the scene is organized */
|
||||
organized: boolean;
|
||||
/** O-counter value */
|
||||
o_counter: number;
|
||||
/** Creation timestamp */
|
||||
created_at: string;
|
||||
/** Last update timestamp */
|
||||
updated_at: string;
|
||||
/** File paths for various media assets */
|
||||
paths: {
|
||||
screenshot: string;
|
||||
preview: string;
|
||||
@@ -41,6 +82,7 @@ export interface StashAPPScene {
|
||||
funscript: string;
|
||||
caption: string;
|
||||
};
|
||||
/** Array of file information */
|
||||
files: Array<{
|
||||
size: number;
|
||||
duration: number;
|
||||
@@ -50,6 +92,7 @@ export interface StashAPPScene {
|
||||
height: number;
|
||||
path: string;
|
||||
}>;
|
||||
/** Array of performers in the scene */
|
||||
performers: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -81,7 +124,30 @@ export interface StashAPPScene {
|
||||
export interface StashAPPScenePerformer {
|
||||
id: string;
|
||||
name: string;
|
||||
disambiguation: string;
|
||||
url: string;
|
||||
gender: string;
|
||||
birthdate: string;
|
||||
ethnicity: string;
|
||||
country: string;
|
||||
eye_color: string;
|
||||
height_cm: number;
|
||||
measurements: string;
|
||||
fake_tits: boolean;
|
||||
career_length: string;
|
||||
tattoos: string;
|
||||
piercings: string;
|
||||
alias_list: string[];
|
||||
favorite: boolean;
|
||||
ignore_auto_tag: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
details: string;
|
||||
death_date: string;
|
||||
hair_color: string;
|
||||
weight: number;
|
||||
image_path: string;
|
||||
scene_count: number;
|
||||
}
|
||||
|
||||
export interface StashAPPPerformer {
|
||||
@@ -131,9 +197,24 @@ export interface StashAPPPerformersResponse {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for logging import progress messages
|
||||
* @param message - The log message to display
|
||||
*/
|
||||
export type LogCallback = (message: string) => void;
|
||||
|
||||
/**
|
||||
* Callback function for updating import progress
|
||||
* @param progress - Partial progress object with updated fields
|
||||
*/
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
|
||||
/**
|
||||
* Checks if a file path matches any blacklist pattern
|
||||
* @param filePath - The file path to check
|
||||
* @param blacklist - Array of blacklist patterns
|
||||
* @returns True if the path is blacklisted, false otherwise
|
||||
*/
|
||||
function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
|
||||
if (!blacklist || blacklist.length === 0) {
|
||||
return false;
|
||||
@@ -141,6 +222,17 @@ function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
|
||||
return blacklist.some(pattern => filePath.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or creates actor entries from StashAPP performers
|
||||
*
|
||||
* This function fetches all performers from StashAPP and updates or creates
|
||||
* corresponding actor entries in the Omnyx database.
|
||||
*
|
||||
* @param config - Configuration for connecting to StashAPP
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
*/
|
||||
export async function updateActorsFromStashAPP(
|
||||
config: StashAPPConfig,
|
||||
logCallback: LogCallback,
|
||||
@@ -159,12 +251,12 @@ export async function updateActorsFromStashAPP(
|
||||
try {
|
||||
logCallback('Starting StashAPP actor update...');
|
||||
|
||||
// Fetch existing cast from Kyoo API
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
// Fetch existing cast from Omnyx API
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingActors = new Map(
|
||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||
const existingActors = new Map<string, Staff>(
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
@@ -249,12 +341,12 @@ export async function updateActorsFromStashAPP(
|
||||
|
||||
for (let i = 0; i < performers.length; i++) {
|
||||
const performer = performers[i];
|
||||
const existingActor: any = existingActors.get(performer.name);
|
||||
const existingActor: Staff | undefined = existingActors.get(performer.name);
|
||||
|
||||
try {
|
||||
if (existingActor) {
|
||||
// Update existing actor
|
||||
const updateData: any = {
|
||||
const updateData: Partial<Staff> = {
|
||||
name: performer.name,
|
||||
};
|
||||
|
||||
@@ -363,6 +455,31 @@ export async function updateActorsFromStashAPP(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports scenes and performers from a StashAPP instance into the Omnyx media database
|
||||
*
|
||||
* This function performs the following steps:
|
||||
* 1. Fetches existing media and cast from Omnyx to check for duplicates
|
||||
* 2. Fetches all scenes from StashAPP via GraphQL
|
||||
* 3. Extracts unique performers from all scenes
|
||||
* 4. Imports or updates performers first
|
||||
* 5. Imports or updates scenes with their associated performers
|
||||
*
|
||||
* @param config - Configuration for connecting to StashAPP
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const progress = await importFromStashAPP(
|
||||
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
|
||||
* (msg) => console.log(msg),
|
||||
* (prog) => updateUI(prog)
|
||||
* );
|
||||
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
|
||||
* ```
|
||||
*/
|
||||
export async function importFromStashAPP(
|
||||
config: StashAPPConfig,
|
||||
logCallback: LogCallback,
|
||||
@@ -382,19 +499,19 @@ export async function importFromStashAPP(
|
||||
logCallback('Starting StashAPP import...');
|
||||
|
||||
// Step 0: Fetch existing media and cast to check for duplicates
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const existingTitles = new Set(
|
||||
existingMediaData.data?.items?.map((m: any) => m.title) || []
|
||||
existingMediaData.data?.items?.map((m: Media) => m.title) || []
|
||||
);
|
||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingActors = new Map(
|
||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||
const existingActors = new Map<string, Staff>(
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
@@ -525,12 +642,12 @@ export async function importFromStashAPP(
|
||||
|
||||
for (let i = 0; i < uniquePerformers.length; i++) {
|
||||
const performer = uniquePerformers[i];
|
||||
const existingActor: any = existingActors.get(performer.name);
|
||||
const existingActor: Staff | undefined = existingActors.get(performer.name);
|
||||
|
||||
try {
|
||||
if (existingActor) {
|
||||
// Update existing actor
|
||||
const updateData: any = {
|
||||
const updateData: Partial<Staff> = {
|
||||
name: performer.name,
|
||||
};
|
||||
|
||||
|
||||
+97
-6
@@ -1,48 +1,96 @@
|
||||
/**
|
||||
* XBVR Importer Module
|
||||
*
|
||||
* This module provides functionality to import VR adult video content from an XBVR instance into the Omnyx media database.
|
||||
* It fetches scene data from the DeoVR API endpoint, extracts actors and video details, and handles both new imports
|
||||
* and updates to existing entries. The module specifically filters for content in the 'Recent' scene group.
|
||||
*
|
||||
* @module xbvrImporter
|
||||
*/
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||
|
||||
/**
|
||||
* Configuration for connecting to an XBVR instance
|
||||
*/
|
||||
export interface XBVRConfig {
|
||||
/** URL of the XBVR server */
|
||||
url: string;
|
||||
/** API key for authentication (optional) */
|
||||
apiKey?: string;
|
||||
/** If true, update existing media entries; if false, only import new entries */
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking for the import operation
|
||||
*/
|
||||
export interface ImportProgress {
|
||||
/** Current number of items processed */
|
||||
current: number;
|
||||
/** Total number of items to process */
|
||||
total: number;
|
||||
/** Current stage of the import process */
|
||||
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
|
||||
/** Human-readable status message */
|
||||
message: string;
|
||||
/** Number of videos successfully imported */
|
||||
videosImported: number;
|
||||
/** Number of actors successfully imported */
|
||||
actorsImported: number;
|
||||
/** Array of error messages encountered during import */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic video information from the DeoVR scene list
|
||||
*/
|
||||
export interface XBVRVideo {
|
||||
/** Video title */
|
||||
title: string;
|
||||
/** Video length in seconds */
|
||||
videoLength: number;
|
||||
/** URL to the video thumbnail */
|
||||
thumbnailUrl: string;
|
||||
/** URL to fetch detailed video information */
|
||||
video_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed video information as returned by the XBVR API
|
||||
*/
|
||||
export interface XBVRVideoDetail {
|
||||
/** Unique video identifier */
|
||||
id: number;
|
||||
/** Video title */
|
||||
title: string;
|
||||
/** Video description */
|
||||
description: string;
|
||||
/** Release date as Unix timestamp */
|
||||
date: number;
|
||||
/** URL to the video thumbnail */
|
||||
thumbnailUrl: string;
|
||||
/** Average rating */
|
||||
rating_avg: number;
|
||||
/** Screen type (e.g., '180', '360', 'dome') */
|
||||
screenType: string;
|
||||
/** Stereo mode (e.g., 'sbs', 'tb') */
|
||||
stereoMode: string;
|
||||
/** Video length in seconds */
|
||||
videoLength: number;
|
||||
/** Pay site information */
|
||||
paysite?: {
|
||||
name: string;
|
||||
};
|
||||
/** Array of actors in the video */
|
||||
actors: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
}>;
|
||||
/** Array of category tags */
|
||||
categories: Array<{
|
||||
tag: {
|
||||
name: string;
|
||||
@@ -50,16 +98,59 @@ export interface XBVRVideoDetail {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene list structure as returned by the DeoVR API
|
||||
*/
|
||||
export interface XBVRSceneList {
|
||||
/** Array of scene groups */
|
||||
scenes: Array<{
|
||||
/** Name of the scene group (e.g., 'Recent', 'Favorites') */
|
||||
name: string;
|
||||
/** List of videos in this group */
|
||||
list: XBVRVideo[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for logging import progress messages
|
||||
* @param message - The log message to display
|
||||
*/
|
||||
export type LogCallback = (message: string) => void;
|
||||
|
||||
/**
|
||||
* Callback function for updating import progress
|
||||
* @param progress - Partial progress object with updated fields
|
||||
*/
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
|
||||
/**
|
||||
* Imports VR adult videos and actors from an XBVR instance into the Omnyx media database
|
||||
*
|
||||
* This function performs the following steps:
|
||||
* 1. Fetches existing media and cast from Omnyx to check for duplicates
|
||||
* 2. Fetches the scene list from the DeoVR API endpoint
|
||||
* 3. Extracts videos from the 'Recent' scene group
|
||||
* 4. Fetches detailed information for each video
|
||||
* 5. Imports or updates actors first
|
||||
* 6. Imports or updates videos with their associated actors
|
||||
*
|
||||
* Videos and actors containing 'aka:' in their name are automatically skipped.
|
||||
*
|
||||
* @param config - Configuration for connecting to XBVR
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const progress = await importFromXBVR(
|
||||
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
|
||||
* (msg) => console.log(msg),
|
||||
* (prog) => updateUI(prog)
|
||||
* );
|
||||
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
|
||||
* ```
|
||||
*/
|
||||
export async function importFromXBVR(
|
||||
config: XBVRConfig,
|
||||
logCallback: LogCallback,
|
||||
@@ -79,19 +170,19 @@ export async function importFromXBVR(
|
||||
logCallback('Starting DeoVR import...');
|
||||
|
||||
// Step 0: Fetch existing media and cast to check for duplicates
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const existingTitles = new Set(
|
||||
existingMediaData.data?.items?.map((m: any) => m.title) || []
|
||||
existingMediaData.data?.items?.map((m: Media) => m.title) || []
|
||||
);
|
||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingActors = new Map(
|
||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
|
||||
+4
-1
@@ -2,9 +2,12 @@ import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import { Media, Staff, MediaCategory, UserSettings } from '../types';
|
||||
import { DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from '../constants';
|
||||
|
||||
interface AppState {
|
||||
// Media state
|
||||
apiMedia: Media[];
|
||||
customMedia: Media[];
|
||||
adultMedia: Media[];
|
||||
mediaLoading: boolean;
|
||||
|
||||
// Selection state
|
||||
selectedMedia: Media | null;
|
||||
selectedPerson: Staff | null;
|
||||
|
||||
// Category state
|
||||
activeCategory: MediaCategory;
|
||||
enabledCategories: MediaCategory[];
|
||||
|
||||
// Search state
|
||||
searchQuery: string;
|
||||
|
||||
// Settings state
|
||||
settings: UserSettings | null;
|
||||
|
||||
// Actions
|
||||
setApiMedia: (media: Media[]) => void;
|
||||
setCustomMedia: (media: Media[]) => void;
|
||||
setAdultMedia: (media: Media[]) => void;
|
||||
setMediaLoading: (loading: boolean) => void;
|
||||
setSelectedMedia: (media: Media | null) => void;
|
||||
setSelectedPerson: (person: Staff | null) => void;
|
||||
setActiveCategory: (category: MediaCategory) => void;
|
||||
setEnabledCategories: (categories: MediaCategory[]) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSettings: (settings: UserSettings | null) => void;
|
||||
resetMedia: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
// Initial state
|
||||
apiMedia: [],
|
||||
customMedia: [],
|
||||
adultMedia: [],
|
||||
mediaLoading: true,
|
||||
selectedMedia: null,
|
||||
selectedPerson: null,
|
||||
activeCategory: 'Anime',
|
||||
enabledCategories: DEFAULT_ENABLED_CATEGORIES,
|
||||
searchQuery: '',
|
||||
settings: null,
|
||||
|
||||
// Actions
|
||||
setApiMedia: (media) => set({ apiMedia: media }),
|
||||
setCustomMedia: (media) => set({ customMedia: media }),
|
||||
setAdultMedia: (media) => set({ adultMedia: media }),
|
||||
setMediaLoading: (loading) => set({ mediaLoading: loading }),
|
||||
setSelectedMedia: (media) => set({ selectedMedia: media }),
|
||||
setSelectedPerson: (person) => set({ selectedPerson: person }),
|
||||
setActiveCategory: (category) => set({ activeCategory: category }),
|
||||
setEnabledCategories: (categories) => set({ enabledCategories: categories }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSettings: (settings) => set({ settings }),
|
||||
resetMedia: () => set({
|
||||
apiMedia: [],
|
||||
customMedia: [],
|
||||
adultMedia: [],
|
||||
mediaLoading: true
|
||||
}),
|
||||
}));
|
||||
@@ -3,6 +3,7 @@ export type MediaCategory = 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books'
|
||||
export interface Media {
|
||||
id: string;
|
||||
title: string;
|
||||
cleanname?: string;
|
||||
year: string;
|
||||
poster: string;
|
||||
category: MediaCategory;
|
||||
@@ -19,6 +20,7 @@ export interface Media {
|
||||
tracks?: Track[];
|
||||
staff?: Staff[];
|
||||
categories?: string[];
|
||||
series?: string[];
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
@@ -119,10 +121,26 @@ export interface UserSettings {
|
||||
language: string;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[]
|
||||
|
||||
// Page Settings
|
||||
pageTitle?: string; // Custom page title
|
||||
favicon?: string; // Base64 encoded favicon/image
|
||||
customColors?: CustomColors; // Custom color scheme
|
||||
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CustomColors {
|
||||
primary?: string; // Primary accent color (hex)
|
||||
secondary?: string; // Secondary accent color (hex)
|
||||
background?: string; // Background color (hex)
|
||||
surface?: string; // Surface/card color (hex)
|
||||
text?: string; // Text color (hex)
|
||||
muted?: string; // Muted text color (hex)
|
||||
border?: string; // Border color (hex)
|
||||
}
|
||||
|
||||
// Source to Category mapping - ensures sources are only used with appropriate categories
|
||||
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
|
||||
'xbvr': ['Adult'],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://typedoc.org/schema.json",
|
||||
"entryPoints": [
|
||||
"./src/lib/playniteImporter.ts",
|
||||
"./src/lib/stashappImporter.ts",
|
||||
"./src/lib/jellyfinImporter.ts",
|
||||
"./src/lib/xbvrImporter.ts"
|
||||
],
|
||||
"out": "docs",
|
||||
"name": "Omnyx Importer Documentation",
|
||||
"theme": "default",
|
||||
"excludePrivate": true,
|
||||
"excludeProtected": false,
|
||||
"excludeInternal": true,
|
||||
"hideGenerator": true,
|
||||
"sort": ["source-order"],
|
||||
"categorizeByGroup": true,
|
||||
"defaultCategory": "Other",
|
||||
"categoryOrder": [
|
||||
"Configuration",
|
||||
"Types",
|
||||
"Functions",
|
||||
"Other"
|
||||
],
|
||||
"readme": "README.md"
|
||||
}
|
||||
+6
-1
@@ -17,8 +17,13 @@ export default defineConfig(({mode}) => {
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
// Do not modify—file watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user