Compare commits
30 Commits
dda118a2f7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34bb4a27be | ||
|
|
e5cdd6b383 | ||
|
|
63c5d0a7c0 | ||
|
|
432416cfc5 | ||
|
|
a407b57006 | ||
|
|
b57b22c30b | ||
|
|
a6d153ac1e | ||
|
|
6250164656 | ||
|
|
9c7e5a2b19 | ||
|
|
dff599e5af | ||
|
|
6c316fbf84 | ||
|
|
0d530ea99c | ||
|
|
555209ed4b | ||
|
|
52d272c701 | ||
|
|
b36b72b8e0 | ||
|
|
53c6f5c555 | ||
|
|
f482807387 | ||
|
|
444c908449 | ||
|
|
b29732a653 | ||
|
|
96593a6235 | ||
|
|
07c3270e12 | ||
|
|
04156486e2 | ||
|
|
f5c3e96823 | ||
|
|
a610ce304e | ||
|
|
6438a23301 | ||
|
|
d6ad4c80b3 | ||
|
|
73c578f1ec | ||
|
|
1caadd12e1 | ||
|
|
6d5397505a | ||
|
|
d6a0aac5f7 |
16
.env.example
16
.env.example
@@ -2,3 +2,19 @@
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# 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"
|
||||
|
||||
# Importer Configurations
|
||||
# XBVR Importer
|
||||
VITE_XBVR_URL=""
|
||||
|
||||
# StashAPP Importer
|
||||
VITE_STASHAPP_URL=""
|
||||
VITE_STASHAPP_API_KEY=""
|
||||
|
||||
# Playnite Importer
|
||||
VITE_PLAYNITE_IP="localhost"
|
||||
VITE_PLAYNITE_PORT="19821"
|
||||
VITE_PLAYNITE_API_TOKEN=""
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ coverage/
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
/docs
|
||||
|
||||
198
.windsurf/skills/react/SKILL.md
Normal file
198
.windsurf/skills/react/SKILL.md
Normal file
@@ -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.
|
||||
338
AGENTS.md
Normal file
338
AGENTS.md
Normal file
@@ -0,0 +1,338 @@
|
||||
---
|
||||
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 / Styled-components
|
||||
- **Testing Framework**: Vitest + React Testing Library
|
||||
- **Code Quality**: ESLint + Prettier + Husky
|
||||
|
||||
## 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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
## 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)
|
||||
170
README.md
170
README.md
@@ -1,20 +1,164 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||

|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
# Omnyx - Media Discovery Platform
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||

|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/e9b611d4-7585-400f-9a12-81904b28ce1c
|
||||
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.
|
||||
|
||||
## Run Locally
|
||||
## Features
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
- **Multi-Category Media Management**: Browse and organize Anime, Movies, TV Series, Music, Books, Consoles, Games, and Adult content
|
||||
- **Detailed Media Views**: Rich information display including descriptions, genres, studios, cast, and ratings
|
||||
- **Cast & Staff Directory**: Explore actors, directors, and other staff across your media library
|
||||
- **Search & Filter**: Quickly find media by title, year, genre, or studio
|
||||
- **Custom Media**: Add your own media entries manually
|
||||
- **External Importers**: Import libraries from popular media managers:
|
||||
- **Playnite**: Import your game library with playtime, achievements, and metadata
|
||||
- **StashAPP**: Import adult content with scene and performer details
|
||||
- **XBVR**: Import VR content with scene information
|
||||
- **Category Toggles**: Enable/disable categories to customize your view
|
||||
- **Responsive Design**: Beautiful UI built with Tailwind CSS and shadcn/ui components
|
||||
|
||||
## Tech Stack
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
- **Frontend Framework**: React 19 with TypeScript
|
||||
- **Build Tool**: Vite 6
|
||||
- **Styling**: Tailwind CSS 4
|
||||
- **UI Components**: shadcn/ui, Base UI
|
||||
- **Icons**: Lucide React
|
||||
- **Animations**: Motion (Framer Motion)
|
||||
- **Fonts**: Geist Variable Font
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (v18 or higher)
|
||||
- npm or yarn
|
||||
- Backend API server (configured via environment variables)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configure environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and configure your settings (see [Configuration](#configuration) below).
|
||||
|
||||
4. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:3000`
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a `.env` file based on `.env.example`:
|
||||
|
||||
```env
|
||||
# App URL (auto-injected by AI Studio in production)
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# XBVR Importer
|
||||
VITE_XBVR_URL="http://localhost:9999"
|
||||
|
||||
# StashAPP Importer
|
||||
VITE_STASHAPP_URL="http://localhost:9999"
|
||||
VITE_STASHAPP_API_KEY="your-api-key"
|
||||
|
||||
# Playnite Importer
|
||||
VITE_PLAYNITE_IP="localhost"
|
||||
VITE_PLAYNITE_PORT="19821"
|
||||
VITE_PLAYNITE_API_TOKEN="your-api-token"
|
||||
```
|
||||
|
||||
### Importer Configuration
|
||||
|
||||
#### Playnite
|
||||
1. Install the [Playnite Bridge extension](https://github.com/JosefNemec/PlayniteExtensions) in Playnite
|
||||
2. Enable the HTTP API server in Playnite settings
|
||||
3. Generate an API token in the Playnite Bridge settings
|
||||
4. Configure the IP, port, and token in your `.env` file
|
||||
5. See [playnite_skill.md](playnite_skill.md) for detailed API documentation
|
||||
|
||||
#### StashAPP
|
||||
1. Ensure StashAPP is running and accessible
|
||||
2. Generate an API key in StashAPP settings
|
||||
3. Configure the URL and API key in your `.env` file
|
||||
|
||||
#### XBVR
|
||||
1. Ensure XBVR is running and accessible
|
||||
2. Configure the URL in your `.env` file
|
||||
|
||||
## Usage
|
||||
|
||||
### Browsing Media
|
||||
- Navigate between categories using the header navigation
|
||||
- Toggle categories on/off using the category buttons
|
||||
- Search for media using the search bar
|
||||
- Click on any media item to view detailed information
|
||||
|
||||
### Managing Media
|
||||
- **Add Media**: Click the "+" button to add custom media entries
|
||||
- **Import**: Use the import feature to bring in libraries from Playnite, StashAPP, or XBVR
|
||||
- **View Cast**: Browse the cast directory to see all staff across your library
|
||||
|
||||
### Importing from External Sources
|
||||
1. Click the import button in the header
|
||||
2. Select the importer you want to use
|
||||
3. Follow the on-screen instructions to configure and run the import
|
||||
4. Progress will be displayed in real-time
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
- `npm run lint` - Run TypeScript type checking
|
||||
- `npm run clean` - Remove build artifacts
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # React components
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ ├── AddMediaView.tsx
|
||||
│ ├── BrowseView.tsx
|
||||
│ ├── CastDetailView.tsx
|
||||
│ ├── CastView.tsx
|
||||
│ ├── DetailView.tsx
|
||||
│ └── ImporterView.tsx
|
||||
├── lib/ # Utility functions
|
||||
│ ├── playniteImporter.ts
|
||||
│ ├── stashappImporter.ts
|
||||
│ └── utils.ts
|
||||
├── App.tsx # Main application component
|
||||
├── api.ts # API client functions
|
||||
├── data.ts # Mock data
|
||||
└── types.ts # TypeScript type definitions
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
For detailed API documentation, see [api.md](api.md).
|
||||
|
||||
## License
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
## Support
|
||||
|
||||
For issues, questions, or contributions, please refer to the project repository.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
23
api_examples/create_adult_cast.json
Normal file
23
api_examples/create_adult_cast.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Jane Smith",
|
||||
"photo": "https://example.com/jane-smith.jpg",
|
||||
"bio": "Adult film actress and model",
|
||||
"birthDate": "1998-03-20",
|
||||
"birthPlace": "Miami, Florida",
|
||||
"occupations": ["Actress", "Model"],
|
||||
"adult_specifics": {
|
||||
"bust_size": "36",
|
||||
"cup_size": "DD",
|
||||
"waist_size": "26",
|
||||
"hip_size": "38",
|
||||
"height": "170",
|
||||
"weight": "58",
|
||||
"hair_color": "Brunette",
|
||||
"eye_color": "Green",
|
||||
"ethnicity": "Latina",
|
||||
"tattoos": "Lower back",
|
||||
"piercings": "None",
|
||||
"measurements": "36-26-38",
|
||||
"shoe_size": "8"
|
||||
}
|
||||
}
|
||||
28
api_examples/create_album.json
Normal file
28
api_examples/create_album.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Thriller",
|
||||
"year": 1982,
|
||||
"poster": "https://example.com/thriller-cover.jpg",
|
||||
"description": "Sixth studio album by Michael Jackson",
|
||||
"rating": 9.0,
|
||||
"category": "Music",
|
||||
"type": "Album",
|
||||
"status": "Released",
|
||||
"genres": ["Pop", "Funk", "Rock"],
|
||||
"tags": ["Classic", "Best-selling"],
|
||||
"studios": ["Epic Records"],
|
||||
"staff": [],
|
||||
"tracks": [
|
||||
{
|
||||
"track_number": 1,
|
||||
"title": "Wanna Be Startin' Somethin'",
|
||||
"duration": "6:03",
|
||||
"artist": "Michael Jackson"
|
||||
},
|
||||
{
|
||||
"track_number": 2,
|
||||
"title": "Baby Be Mine",
|
||||
"duration": "4:20",
|
||||
"artist": "Michael Jackson"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
api_examples/create_cast.json
Normal file
8
api_examples/create_cast.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Tom Hardy",
|
||||
"photo": "https://example.com/tom.jpg",
|
||||
"bio": "English actor known for versatile roles",
|
||||
"birthDate": "1977-09-15",
|
||||
"birthPlace": "Hammersmith, London, England",
|
||||
"occupations": ["Actor", "Producer", "Writer"]
|
||||
}
|
||||
9
api_examples/create_episode.json
Normal file
9
api_examples/create_episode.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"season": 1,
|
||||
"episode_number": 3,
|
||||
"title": "...And the Bag's in the River",
|
||||
"description": "Walter and Jesse deal with the aftermath.",
|
||||
"air_date": "2008-02-03",
|
||||
"duration": 47,
|
||||
"thumbnail": "https://example.com/ep3.jpg"
|
||||
}
|
||||
32
api_examples/create_movie.json
Normal file
32
api_examples/create_movie.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"title": "The Matrix",
|
||||
"year": 1999,
|
||||
"poster": "https://example.com/matrix-poster.jpg",
|
||||
"banner": "https://example.com/matrix-banner.jpg",
|
||||
"description": "A computer hacker learns about the true nature of reality.",
|
||||
"rating": 8.7,
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"status": "Released",
|
||||
"aspectRatio": "2.39:1",
|
||||
"runtime": 136,
|
||||
"director": "The Wachowskis",
|
||||
"writer": "The Wachowskis",
|
||||
"releaseDate": "1999-03-31",
|
||||
"genres": ["Sci-Fi", "Action"],
|
||||
"tags": ["Cyberpunk", "AI", "Simulation"],
|
||||
"studios": ["Warner Bros."],
|
||||
"staff": [
|
||||
{
|
||||
"name": "Keanu Reeves",
|
||||
"photo": "https://example.com/keanu.jpg",
|
||||
"bio": "Canadian actor",
|
||||
"birthDate": "1964-09-02",
|
||||
"birthPlace": "Beirut, Lebanon",
|
||||
"role": "Actor",
|
||||
"characterName": "Neo",
|
||||
"characterImage": null,
|
||||
"occupations": ["Actor"]
|
||||
}
|
||||
]
|
||||
}
|
||||
6
api_examples/create_track.json
Normal file
6
api_examples/create_track.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"track_number": 3,
|
||||
"title": "On the Run",
|
||||
"duration": "3:35",
|
||||
"artist": "Pink Floyd"
|
||||
}
|
||||
29
api_examples/create_tv.json
Normal file
29
api_examples/create_tv.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"title": "Stranger Things",
|
||||
"year": 2016,
|
||||
"poster": "https://example.com/st-poster.jpg",
|
||||
"description": "When a young boy disappears, his mother uncovers a mystery.",
|
||||
"rating": 8.7,
|
||||
"category": "TV",
|
||||
"type": "TV",
|
||||
"status": "Ongoing",
|
||||
"runtime": 50,
|
||||
"director": "The Duffer Brothers",
|
||||
"writer": "The Duffer Brothers",
|
||||
"releaseDate": "2016-07-15",
|
||||
"genres": ["Sci-Fi", "Horror", "Drama"],
|
||||
"tags": ["80s", "Supernatural", "Government Conspiracy"],
|
||||
"studios": ["Netflix"],
|
||||
"staff": [],
|
||||
"episodes": [
|
||||
{
|
||||
"season": 1,
|
||||
"episode_number": 1,
|
||||
"title": "Chapter One: The Vanishing of Will Byers",
|
||||
"description": "On his way home from a friend's house, young Will sees something terrifying.",
|
||||
"air_date": "2016-07-15",
|
||||
"duration": 47,
|
||||
"thumbnail": "https://example.com/st-ep1.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
30
api_examples/get_adult_cast.json
Normal file
30
api_examples/get_adult_cast.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Jane Doe",
|
||||
"photo": "https://example.com/jane.jpg",
|
||||
"bio": "Adult film actress",
|
||||
"birthDate": "1995-05-15",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"occupations": ["Actress"],
|
||||
"bust_size": "34",
|
||||
"cup_size": "D",
|
||||
"waist_size": "24",
|
||||
"hip_size": "34",
|
||||
"height": "165",
|
||||
"weight": "52",
|
||||
"hair_color": "Blonde",
|
||||
"eye_color": "Blue",
|
||||
"ethnicity": "Caucasian"
|
||||
}
|
||||
],
|
||||
"total": 25,
|
||||
"page": 1,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
32
api_examples/get_adult_cast_single.json
Normal file
32
api_examples/get_adult_cast_single.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 10,
|
||||
"name": "Jane Doe",
|
||||
"photo": "https://example.com/jane.jpg",
|
||||
"bio": "Adult film actress",
|
||||
"birthDate": "1995-05-15",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"occupations": ["Actress"],
|
||||
"filmography": [],
|
||||
"adult_specifics": {
|
||||
"id": 5,
|
||||
"cast_id": 10,
|
||||
"bust_size": "34",
|
||||
"cup_size": "D",
|
||||
"waist_size": "24",
|
||||
"hip_size": "34",
|
||||
"height": "165",
|
||||
"weight": "52",
|
||||
"hair_color": "Blonde",
|
||||
"eye_color": "Blue",
|
||||
"ethnicity": "Caucasian",
|
||||
"tattoos": "None",
|
||||
"piercings": "Ears",
|
||||
"measurements": "34-24-34",
|
||||
"shoe_size": "7"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
api_examples/get_cast.json
Normal file
20
api_examples/get_cast.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Leonardo DiCaprio",
|
||||
"photo": "https://example.com/leo.jpg",
|
||||
"bio": "American actor and film producer",
|
||||
"birthDate": "1974-11-11",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00"
|
||||
}
|
||||
],
|
||||
"total": 5,
|
||||
"page": 1,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
27
api_examples/get_cast_media.json
Normal file
27
api_examples/get_cast_media.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Dom Cobb"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "The Revenant",
|
||||
"year": 2015,
|
||||
"poster": "https://example.com/revenant.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Hugh Glass"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
36
api_examples/get_cast_single.json
Normal file
36
api_examples/get_cast_single.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "Leonardo DiCaprio",
|
||||
"photo": "https://example.com/leo.jpg",
|
||||
"bio": "American actor and film producer",
|
||||
"birthDate": "1974-11-11",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"occupations": ["Actor", "Producer"],
|
||||
"filmography": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Dom Cobb"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "The Revenant",
|
||||
"year": 2015,
|
||||
"poster": "https://example.com/revenant.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Hugh Glass"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
29
api_examples/get_episodes.json
Normal file
29
api_examples/get_episodes.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"media_id": 2,
|
||||
"season": 1,
|
||||
"episode_number": 1,
|
||||
"title": "Pilot",
|
||||
"description": "Walter White is diagnosed with lung cancer.",
|
||||
"air_date": "2008-01-20",
|
||||
"duration": 49,
|
||||
"thumbnail": "https://example.com/ep1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"media_id": 2,
|
||||
"season": 1,
|
||||
"episode_number": 2,
|
||||
"title": "Cat's in the Bag...",
|
||||
"description": "Walter and Jesse attempt to dispose of the body.",
|
||||
"air_date": "2008-01-27",
|
||||
"duration": 48,
|
||||
"thumbnail": "https://example.com/ep2.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
30
api_examples/get_media.json
Normal file
30
api_examples/get_media.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"banner": null,
|
||||
"description": "A thief who steals corporate secrets through dream-sharing technology.",
|
||||
"rating": 8.8,
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"status": "Released",
|
||||
"aspectRatio": "2.39:1",
|
||||
"runtime": 148,
|
||||
"director": "Christopher Nolan",
|
||||
"writer": "Christopher Nolan",
|
||||
"releaseDate": "2010-07-16",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00"
|
||||
}
|
||||
],
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"totalPages": 15
|
||||
}
|
||||
}
|
||||
39
api_examples/get_media_single.json
Normal file
39
api_examples/get_media_single.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"banner": null,
|
||||
"description": "A thief who steals corporate secrets through dream-sharing technology.",
|
||||
"rating": 8.8,
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"status": "Released",
|
||||
"aspectRatio": "2.39:1",
|
||||
"runtime": 148,
|
||||
"director": "Christopher Nolan",
|
||||
"writer": "Christopher Nolan",
|
||||
"releaseDate": "2010-07-16",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"genres": ["Sci-Fi", "Action", "Thriller"],
|
||||
"tags": ["Mind-bending", "Dream", "Heist"],
|
||||
"studios": ["Warner Bros.", "Legendary Pictures"],
|
||||
"staff": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Leonardo DiCaprio",
|
||||
"photo": "https://example.com/leo.jpg",
|
||||
"bio": "American actor and film producer",
|
||||
"birthDate": "1974-11-11",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"role": "Actor",
|
||||
"characterName": "Dom Cobb",
|
||||
"characterImage": null,
|
||||
"occupations": ["Actor", "Producer"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
23
api_examples/get_tracks.json
Normal file
23
api_examples/get_tracks.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"media_id": 3,
|
||||
"track_number": 1,
|
||||
"title": "Speak to Me",
|
||||
"duration": "1:30",
|
||||
"artist": "Pink Floyd"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"media_id": 3,
|
||||
"track_number": 2,
|
||||
"title": "Breathe",
|
||||
"duration": "2:43",
|
||||
"artist": "Pink Floyd"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
8
api_examples/update_adult_cast.json
Normal file
8
api_examples/update_adult_cast.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Jane Smith (Updated)",
|
||||
"bio": "Updated bio",
|
||||
"adult_specifics": {
|
||||
"hair_color": "Red",
|
||||
"weight": "56"
|
||||
}
|
||||
}
|
||||
4
api_examples/update_cast.json
Normal file
4
api_examples/update_cast.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Tom Hardy (Updated)",
|
||||
"bio": "Updated bio description"
|
||||
}
|
||||
4
api_examples/update_episode.json
Normal file
4
api_examples/update_episode.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Updated Episode Title",
|
||||
"description": "Updated description"
|
||||
}
|
||||
32
api_examples/update_game.json
Normal file
32
api_examples/update_game.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"type": "Game",
|
||||
"title": "1-2-Switch",
|
||||
"playtime": 120,
|
||||
"completionStatus": "Completed",
|
||||
"favorite": true,
|
||||
"communityScore": 55,
|
||||
"userScore": 80,
|
||||
"achievements": [
|
||||
{
|
||||
"name": "First Victory",
|
||||
"description": "Win your first game",
|
||||
"icon": "https://example.com/achievement-icon.png",
|
||||
"unlocked": true,
|
||||
"unlocked_date": "2026-04-09T18:00:00"
|
||||
},
|
||||
{
|
||||
"name": "Master Player",
|
||||
"description": "Win 100 games",
|
||||
"icon": "https://example.com/master-icon.png",
|
||||
"unlocked": true,
|
||||
"unlocked_date": "2026-04-09T20:30:00"
|
||||
},
|
||||
{
|
||||
"name": "Champion",
|
||||
"description": "Win 1000 games",
|
||||
"icon": "https://example.com/champion-icon.png",
|
||||
"unlocked": false,
|
||||
"unlocked_date": null
|
||||
}
|
||||
]
|
||||
}
|
||||
5
api_examples/update_media.json
Normal file
5
api_examples/update_media.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "The Matrix (Updated)",
|
||||
"rating": 8.8,
|
||||
"status": "Released"
|
||||
}
|
||||
4
api_examples/update_track.json
Normal file
4
api_examples/update_track.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Updated Track Title",
|
||||
"duration": "4:00"
|
||||
}
|
||||
BIN
img/banner.png
Normal file
BIN
img/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
BIN
img/logo.png
Normal file
BIN
img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
@@ -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,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": []
|
||||
}
|
||||
|
||||
1213
package-lock.json
generated
1213
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -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",
|
||||
@@ -24,18 +29,24 @@
|
||||
"motion": "^12.38.0",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
3752
public/adult.json
3752
public/adult.json
File diff suppressed because one or more lines are too long
433
src/App.tsx
433
src/App.tsx
@@ -5,80 +5,271 @@
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { LayoutGroup } from 'motion/react';
|
||||
import Header from './components/Header';
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
|
||||
import Sidebar from './components/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';
|
||||
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 } from './types';
|
||||
import { fetchMediaFromLocalJson, fetchMediaById } from './api';
|
||||
import { Media, Staff, MediaCategory, UserSettings } from './types';
|
||||
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
|
||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
|
||||
import { CATEGORY_PATHS, PATH_TO_CATEGORY, DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from './constants';
|
||||
import { useAppStore } from './store/appStore';
|
||||
|
||||
export default function App() {
|
||||
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail'>('browse');
|
||||
const [activeCategory, setActiveCategory] = useState<MediaCategory>('Anime');
|
||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
||||
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
||||
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
// Zustand store
|
||||
const {
|
||||
apiMedia,
|
||||
customMedia,
|
||||
adultMedia,
|
||||
mediaLoading,
|
||||
selectedMedia,
|
||||
selectedPerson,
|
||||
activeCategory,
|
||||
enabledCategories,
|
||||
searchQuery,
|
||||
settings,
|
||||
setApiMedia,
|
||||
setCustomMedia,
|
||||
setAdultMedia,
|
||||
setMediaLoading,
|
||||
setSelectedMedia,
|
||||
setSelectedPerson,
|
||||
setActiveCategory,
|
||||
setEnabledCategories,
|
||||
setSearchQuery,
|
||||
setSettings,
|
||||
} = useAppStore();
|
||||
|
||||
// Load adult media on component mount
|
||||
|
||||
// Set category from URL path on mount or location change
|
||||
useEffect(() => {
|
||||
const loadAdultMedia = async () => {
|
||||
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 {
|
||||
const media = await fetchMediaFromLocalJson();
|
||||
// Add category to adult media
|
||||
const categorizedMedia = media.map(m => ({ ...m, category: 'Adult' as MediaCategory }));
|
||||
setAdultMedia(categorizedMedia);
|
||||
const loadedSettings = await fetchSettings();
|
||||
if (loadedSettings) {
|
||||
setSettings(loadedSettings);
|
||||
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 adult media:', error);
|
||||
console.error('Failed to load settings from API:', error);
|
||||
}
|
||||
};
|
||||
loadAdultMedia();
|
||||
}, []);
|
||||
|
||||
const toggleCategory = (category: MediaCategory) => {
|
||||
setEnabledCategories(prev => {
|
||||
const isEnabling = !prev.includes(category);
|
||||
const newList = isEnabling
|
||||
? [...prev, category]
|
||||
: prev.filter(c => c !== category);
|
||||
loadSettingsFromApi();
|
||||
}, [setTheme]);
|
||||
|
||||
// Apply custom colors when settings change
|
||||
useEffect(() => {
|
||||
if (settings?.customColors) {
|
||||
const root = document.documentElement;
|
||||
const colors = settings.customColors;
|
||||
|
||||
// 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);
|
||||
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();
|
||||
if (loadedSettings) {
|
||||
setSettings(loadedSettings);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadMediaFromApi = async () => {
|
||||
setMediaLoading(true);
|
||||
try {
|
||||
const media = await fetchAllMedia();
|
||||
setApiMedia(media);
|
||||
} catch (error) {
|
||||
console.error('Failed to load media from API:', error);
|
||||
} finally {
|
||||
setMediaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Only load media if not on cast routes
|
||||
if (!location.pathname.startsWith('/cast')) {
|
||||
loadMediaFromApi();
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggleCategory = async (category: 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);
|
||||
}
|
||||
return newList;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: MediaCategory) => {
|
||||
setActiveCategory(category);
|
||||
setCurrentView('browse');
|
||||
navigate(`/${CATEGORY_PATHS[category]}`);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleAddMediaView = () => {
|
||||
navigate('/add');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleImporterView = () => {
|
||||
navigate('/import');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const allMedia = useMemo(() => {
|
||||
// Merge mock media, adult media, detail media and custom media
|
||||
const list = [...MOCK_MEDIA, ...adultMedia, ...customMedia];
|
||||
// Use API data if available, otherwise fall back to mock data
|
||||
let list: Media[] = [];
|
||||
|
||||
if (apiMedia.length > 0) {
|
||||
// API has data, use it
|
||||
list = [...apiMedia];
|
||||
} else {
|
||||
// API is empty, use mock data as fallback
|
||||
list = [...MOCK_MEDIA];
|
||||
}
|
||||
|
||||
// Add custom media and detail media
|
||||
list = [...list, ...customMedia];
|
||||
if (!list.find(m => m.id === DETAIL_MEDIA.id)) {
|
||||
list.push(DETAIL_MEDIA);
|
||||
}
|
||||
|
||||
// Filter by active category AND ensure it's enabled
|
||||
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
|
||||
}, [activeCategory, enabledCategories, customMedia, adultMedia]);
|
||||
}, [activeCategory, enabledCategories, customMedia, apiMedia]);
|
||||
|
||||
const handleAddMedia = (newMedia: Media) => {
|
||||
setCustomMedia(prev => [...prev, newMedia]);
|
||||
const handleAddMedia = async () => {
|
||||
// Reload all media from API to get the newly added item
|
||||
try {
|
||||
const media = await fetchAllMedia();
|
||||
setApiMedia(media);
|
||||
} catch (error) {
|
||||
console.error('Failed to reload media from API:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGridItemSizeChange = async (size: number) => {
|
||||
const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories };
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
gridItemSize: size,
|
||||
};
|
||||
updateSettings(updatedSettings).then(saved => {
|
||||
if (saved) {
|
||||
setSettings(saved);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const allStaff = useMemo(() => {
|
||||
const staff: Staff[] = [];
|
||||
// Use all available media (mock + adult + custom + detail) but filter by enabled categories
|
||||
const baseList = [...MOCK_MEDIA, ...adultMedia, ...customMedia];
|
||||
// Use API data if available, otherwise fall back to mock data
|
||||
let baseList: Media[] = [];
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -95,7 +286,7 @@ export default function App() {
|
||||
});
|
||||
});
|
||||
return staff;
|
||||
}, [enabledCategories, customMedia, adultMedia]);
|
||||
}, [enabledCategories, customMedia, apiMedia]);
|
||||
|
||||
const filteredMedia = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allMedia;
|
||||
@@ -127,17 +318,17 @@ export default function App() {
|
||||
// For non-adult media, use the original media
|
||||
setSelectedMedia(media);
|
||||
}
|
||||
setCurrentView('detail');
|
||||
navigate(`/media/${media.id}`);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentView('browse');
|
||||
navigate('/');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleCastClick = () => {
|
||||
setCurrentView('cast');
|
||||
navigate('/cast');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
@@ -151,85 +342,121 @@ export default function App() {
|
||||
occupations: ['Voice Actor', 'Singer', 'Narrator']
|
||||
};
|
||||
setSelectedPerson(enrichedPerson);
|
||||
setCurrentView('castDetail');
|
||||
navigate(`/cast/${person.id}`);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
if (currentView !== 'browse' && currentView !== 'cast') {
|
||||
setCurrentView('browse');
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (query) {
|
||||
params.set('search', query);
|
||||
} else {
|
||||
params.delete('search');
|
||||
}
|
||||
setSearchParams(params);
|
||||
navigate('/browse');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
|
||||
<Header
|
||||
onBrowse={handleBack}
|
||||
onCast={handleCastClick}
|
||||
onSearch={handleSearch}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
<div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9] flex">
|
||||
<Sidebar
|
||||
enabledCategories={enabledCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
transparent={currentView === 'detail' || currentView === 'castDetail'}
|
||||
pageTitle={settings?.pageTitle}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<main className="flex-1 lg:ml-72 flex flex-col">
|
||||
<LayoutGroup>
|
||||
{currentView === 'browse' ? (
|
||||
<BrowseView
|
||||
mediaList={filteredMedia}
|
||||
onMediaClick={handleMediaClick}
|
||||
onAddMedia={handleAddMedia}
|
||||
activeCategory={activeCategory}
|
||||
/>
|
||||
) : currentView === 'cast' ? (
|
||||
<CastView
|
||||
staffList={allStaff}
|
||||
onPersonClick={handlePersonClick}
|
||||
/>
|
||||
) : currentView === 'castDetail' ? (
|
||||
selectedPerson && (
|
||||
<CastDetailView
|
||||
person={selectedPerson}
|
||||
onBack={handleCastClick}
|
||||
onMediaClick={(id) => {
|
||||
const media = allMedia.find(m => m.id === id);
|
||||
if (media) handleMediaClick(media);
|
||||
}}
|
||||
relatedMedia={allMedia.filter(m => m.staff?.some(s => s.id === selectedPerson.id))}
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
selectedMedia && (
|
||||
<DetailView
|
||||
media={selectedMedia}
|
||||
onBack={handleBack}
|
||||
} />
|
||||
<Route path="/browse" element={
|
||||
<BrowseView
|
||||
mediaList={filteredMedia}
|
||||
onMediaClick={handleMediaClick}
|
||||
activeCategory={activeCategory}
|
||||
itemsPerPage={settings?.itemsPerPage}
|
||||
gridItemSize={settings?.gridItemSize}
|
||||
onGridItemSizeChange={handleGridItemSizeChange}
|
||||
loading={mediaLoading}
|
||||
/>
|
||||
} />
|
||||
<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>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-6 border-t border-zinc-100 bg-zinc-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-zinc-400">
|
||||
<div className="w-5 h-5 bg-zinc-300 rounded-full" />
|
||||
kyoo
|
||||
{/* Footer */}
|
||||
<footer className="py-8 px-6 border-t border-border/50 bg-muted/30 backdrop-blur-sm mt-auto">
|
||||
<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-lg font-black text-muted-foreground">
|
||||
<div className="w-5 h-5 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-full" />
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">{settings?.pageTitle || 'omnyx'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm font-bold text-muted-foreground">
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Terms</a>
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Privacy</a>
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Contact</a>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
© 2026 Omnyx Media Discovery. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 text-sm font-bold text-zinc-400">
|
||||
<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-zinc-400">
|
||||
© 2026 Kyoo Media Discovery. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
303
src/api.ts
303
src/api.ts
@@ -1,290 +1,49 @@
|
||||
import { Media, Staff } 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 = 'http://192.168.1.102:57000';
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
items: ApiMediaItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
overview: string;
|
||||
poster_url: string;
|
||||
poster_aspect_ratio: string | null;
|
||||
backdrop_url: string | null;
|
||||
backdrop_aspect_ratio: string | null;
|
||||
rating: string;
|
||||
runtime_minutes: number;
|
||||
release_date: string;
|
||||
director: string | null;
|
||||
writer: string | null;
|
||||
cast: string | null;
|
||||
genre: string | null;
|
||||
metadata: string;
|
||||
actors?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail_path: string | null;
|
||||
metadata?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ApiMetadata {
|
||||
xbvr_id: number;
|
||||
xbvr_url: string | null;
|
||||
cast: string[];
|
||||
actors: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail_path: string | null;
|
||||
}>;
|
||||
tags: string[];
|
||||
is_available: boolean;
|
||||
is_watched: boolean;
|
||||
watch_count: number;
|
||||
video_length: number;
|
||||
video_width: number | null;
|
||||
video_height: number | null;
|
||||
video_codec: string | null;
|
||||
file_path: string | null;
|
||||
cover_url: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
let metadata: ApiMetadata;
|
||||
// Legacy functions for compatibility
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
try {
|
||||
metadata = JSON.parse(apiItem.metadata);
|
||||
} catch (e) {
|
||||
metadata = {
|
||||
xbvr_id: 0,
|
||||
xbvr_url: null,
|
||||
cast: [],
|
||||
actors: [],
|
||||
tags: [],
|
||||
is_available: false,
|
||||
is_watched: false,
|
||||
watch_count: 0,
|
||||
video_length: 0,
|
||||
video_width: null,
|
||||
video_height: null,
|
||||
video_codec: null,
|
||||
file_path: null,
|
||||
cover_url: apiItem.poster_url,
|
||||
};
|
||||
}
|
||||
|
||||
// Use actors from the main item if available, otherwise from metadata
|
||||
const actors = apiItem.actors || metadata.actors || [];
|
||||
const staff: Staff[] = actors.map((actor, index) => ({
|
||||
id: `actor-${actor.id}`,
|
||||
name: actor.name,
|
||||
role: 'Actor',
|
||||
photo: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
|
||||
characterName: actor.name,
|
||||
characterImage: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
|
||||
}));
|
||||
|
||||
|
||||
// Determine aspect ratio from poster_aspect_ratio or default to 2/3
|
||||
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
if (apiItem.poster_aspect_ratio) {
|
||||
const ratio = apiItem.poster_aspect_ratio.toLowerCase();
|
||||
if (ratio.includes('16:9') || ratio.includes('1.78')) {
|
||||
aspectRatio = '16/9';
|
||||
} else if (ratio.includes('1:1') || ratio.includes('1.00')) {
|
||||
aspectRatio = '1/1';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiItem.id.toString() || undefined,
|
||||
title: apiItem.title || undefined,
|
||||
year: apiItem.release_date ? new Date(apiItem.release_date).getFullYear().toString() : 'Unknown',
|
||||
poster: normalizeUrl(apiItem.poster_url) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
|
||||
banner: normalizeUrl(apiItem.backdrop_url) || undefined,
|
||||
description: apiItem.overview || undefined,
|
||||
rating: apiItem.rating ? parseFloat(apiItem.rating) : undefined,
|
||||
genres: metadata.tags || [],
|
||||
tags: metadata.tags || [],
|
||||
studios: apiItem.director ? [apiItem.director] : undefined,
|
||||
type: 'Movie',
|
||||
status: 'completed',
|
||||
staff: staff.length > 0 ? staff : undefined,
|
||||
runtime: apiItem.runtime_minutes,
|
||||
director: apiItem.director || undefined,
|
||||
writer: apiItem.writer || undefined,
|
||||
releaseDate: apiItem.release_date || undefined,
|
||||
aspectRatio: aspectRatio
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMediaFromApi(apiUrl: string = `${BASE_URL}/api/adult`): Promise<Media[]> {
|
||||
console.error('Error fetching');
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
const tagSet = new Set<string>();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
media.forEach(item => {
|
||||
item.tags?.forEach(tag => tagSet.add(tag));
|
||||
item.genres?.forEach(genre => tagSet.add(genre));
|
||||
});
|
||||
|
||||
return Array.from(tagSet).sort();
|
||||
} catch (error) {
|
||||
console.error('Error fetching media from API:', error);
|
||||
console.error('Error fetching all tags:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
|
||||
export async function fetchMediaByTag(tag: string) {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching media from local JSON:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaById(id: number): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
const item = data.data.items.find(item => item.id === id);
|
||||
return item ? convertApiToMedia(item) : null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
return data.data.items
|
||||
.filter(item => item.actors?.some(actor => actor.name.toLowerCase().includes(actorName.toLowerCase())))
|
||||
.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by actor:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
return data.data.items
|
||||
.filter(item => {
|
||||
try {
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
return metadata.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase()));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
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())) ||
|
||||
item.genres?.some(g => g.toLowerCase().includes(tag.toLowerCase()))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by tag:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllActors(): Promise<Array<{id: number, name: string, thumbnail_path: string | null}>> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
const actorMap = new Map();
|
||||
data.data.items.forEach(item => {
|
||||
item.actors?.forEach(actor => {
|
||||
if (!actorMap.has(actor.id)) {
|
||||
actorMap.set(actor.id, {
|
||||
id: actor.id,
|
||||
name: actor.name,
|
||||
thumbnail_path: actor.thumbnail_path
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(actorMap.values());
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching all actors:', error);
|
||||
return [];
|
||||
}
|
||||
export async function fetchMediaFromApi(apiUrl?: string) {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
const tagSet = new Set<string>();
|
||||
data.data.items.forEach(item => {
|
||||
try {
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
metadata.tags?.forEach((tag: string) => tagSet.add(tag));
|
||||
} catch {
|
||||
// Ignore metadata parsing errors
|
||||
}
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching all tags:', error);
|
||||
return [];
|
||||
}
|
||||
export async function fetchMediaFromLocalJson() {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
652
src/components/AddMediaView.tsx
Normal file
652
src/components/AddMediaView.tsx
Normal file
@@ -0,0 +1,652 @@
|
||||
import { MediaCategory } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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, 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 {
|
||||
activeCategory: MediaCategory;
|
||||
enabledCategories: MediaCategory[];
|
||||
onAddComplete: () => void;
|
||||
}
|
||||
|
||||
export default function AddMediaView({ activeCategory, enabledCategories, onAddComplete }: AddMediaViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [newMedia, setNewMedia] = useState({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
banner: '',
|
||||
description: '',
|
||||
rating: '',
|
||||
category: activeCategory,
|
||||
type: 'Movie' as string,
|
||||
status: 'Released' as string,
|
||||
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1',
|
||||
runtime: '',
|
||||
director: '',
|
||||
writer: '',
|
||||
releaseDate: '',
|
||||
source: '' as string,
|
||||
genres: '' as string,
|
||||
tags: '' as string,
|
||||
studios: '' as string
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [staff, setStaff] = useState<Array<{ name: string; role: string; characterName?: string; photo?: string }>>([]);
|
||||
|
||||
const addStaffMember = () => {
|
||||
const nameInput = document.getElementById('staffName') as HTMLInputElement;
|
||||
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
|
||||
const characterInput = document.getElementById('staffCharacter') as HTMLInputElement;
|
||||
const photoInput = document.getElementById('staffPhoto') as HTMLInputElement;
|
||||
|
||||
if (nameInput?.value && roleInput?.value) {
|
||||
setStaff(prev => [...prev, {
|
||||
name: nameInput.value,
|
||||
role: roleInput.value,
|
||||
characterName: characterInput?.value || undefined,
|
||||
photo: photoInput?.value || undefined
|
||||
}]);
|
||||
|
||||
// Clear the form
|
||||
if (nameInput) nameInput.value = '';
|
||||
if (roleInput) roleInput.value = '';
|
||||
if (characterInput) characterInput.value = '';
|
||||
if (photoInput) photoInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Update category, default aspect ratio, and default type when activeCategory changes
|
||||
useEffect(() => {
|
||||
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
let defaultType = 'Movie';
|
||||
|
||||
if (activeCategory === 'Music') {
|
||||
defaultAspect = '1/1';
|
||||
defaultType = 'Album';
|
||||
} else if (activeCategory === 'Games') {
|
||||
defaultAspect = '16/9';
|
||||
defaultType = 'Game';
|
||||
} else if (activeCategory === 'Adult') {
|
||||
defaultAspect = '16/9';
|
||||
defaultType = 'Movie';
|
||||
} else if (activeCategory === 'Anime') {
|
||||
defaultType = 'TV';
|
||||
} else if (activeCategory === 'TV Series') {
|
||||
defaultType = 'TV';
|
||||
} else if (activeCategory === 'Books') {
|
||||
defaultType = 'Hardcover';
|
||||
} else if (activeCategory === 'Consoles') {
|
||||
defaultType = 'Console';
|
||||
}
|
||||
|
||||
setNewMedia(prev => ({
|
||||
...prev,
|
||||
category: activeCategory,
|
||||
aspectRatio: defaultAspect,
|
||||
type: defaultType
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMedia.title || !newMedia.poster) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitStatus('idle');
|
||||
setErrorMessage('');
|
||||
|
||||
// Convert category from plural to singular to match API format
|
||||
const categoryMap: Record<string, string> = {
|
||||
'Anime': 'Anime',
|
||||
'Movies': 'Movie',
|
||||
'TV Series': 'TV',
|
||||
'Music': 'Music',
|
||||
'Books': 'Book',
|
||||
'Consoles': 'Console',
|
||||
'Games': 'Game',
|
||||
'Adult': 'Adult'
|
||||
};
|
||||
|
||||
const mediaInput: CreateMediaInput = {
|
||||
title: newMedia.title,
|
||||
year: parseInt(newMedia.year) || new Date().getFullYear(),
|
||||
poster: newMedia.poster,
|
||||
banner: newMedia.banner || null,
|
||||
description: newMedia.description || null,
|
||||
rating: newMedia.rating ? parseFloat(newMedia.rating) : null,
|
||||
category: categoryMap[newMedia.category] || newMedia.category,
|
||||
type: newMedia.type,
|
||||
status: newMedia.status,
|
||||
aspectRatio: newMedia.aspectRatio,
|
||||
runtime: newMedia.runtime ? parseInt(newMedia.runtime) : null,
|
||||
director: newMedia.director || null,
|
||||
writer: newMedia.writer || null,
|
||||
releaseDate: newMedia.releaseDate || null,
|
||||
source: newMedia.source || null,
|
||||
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
|
||||
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
|
||||
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : [],
|
||||
staff: staff.length > 0 ? staff.map(s => ({
|
||||
name: s.name,
|
||||
role: s.role,
|
||||
characterName: s.characterName || null,
|
||||
photo: s.photo || null
|
||||
})) : undefined
|
||||
};
|
||||
|
||||
try {
|
||||
const createdMedia = await createMedia(mediaInput);
|
||||
|
||||
if (createdMedia) {
|
||||
setSubmitStatus('success');
|
||||
onAddComplete();
|
||||
|
||||
// Reset form after successful submission
|
||||
setNewMedia({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
banner: '',
|
||||
description: '',
|
||||
rating: '',
|
||||
category: activeCategory,
|
||||
type: 'Movie',
|
||||
status: 'Released',
|
||||
aspectRatio: '2/3',
|
||||
runtime: '',
|
||||
director: '',
|
||||
writer: '',
|
||||
releaseDate: '',
|
||||
source: '',
|
||||
genres: '',
|
||||
tags: '',
|
||||
studios: ''
|
||||
});
|
||||
setStaff([]);
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to add media. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/')}
|
||||
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/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-[#6d28d9] to-[#8b5cf6] flex items-center justify-center shadow-lg shadow-[#6d28d9]/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 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 backdrop-blur-sm">
|
||||
<p className="text-red-500 font-bold">✗ Error: {errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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-[#6d28d9] shadow-sm">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
|
||||
</div>
|
||||
<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-[#6d28d9]/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-[#6d28d9]/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-[#6d28d9]/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-[#6d28d9]/50 outline-none"
|
||||
>
|
||||
{newMedia.category === 'Music' ? (
|
||||
<>
|
||||
<option value="Album">Album</option>
|
||||
<option value="Single">Single</option>
|
||||
</>
|
||||
) : newMedia.category === 'Books' ? (
|
||||
<>
|
||||
<option value="Hardcover">Hardcover</option>
|
||||
<option value="E-book">E-book</option>
|
||||
</>
|
||||
) : newMedia.category === 'Games' ? (
|
||||
<>
|
||||
<option value="Game">Game</option>
|
||||
</>
|
||||
) : newMedia.category === 'Consoles' ? (
|
||||
<>
|
||||
<option value="Console">Console</option>
|
||||
</>
|
||||
) : newMedia.category === 'TV Series' || newMedia.category === 'Anime' ? (
|
||||
<>
|
||||
<option value="TV">TV</option>
|
||||
<option value="OVA">OVA</option>
|
||||
<option value="ONA">ONA</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="Movie">Movie</option>
|
||||
</>
|
||||
)}
|
||||
</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-[#6d28d9]/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>
|
||||
|
||||
{/* 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-[#6d28d9] 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-[#6d28d9]/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-[#6d28d9]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<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-[#6d28d9]/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-[#6d28d9]/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-[#6d28d9]/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-[#6d28d9] 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-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<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-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/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-[#6d28d9]/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-[#6d28d9]/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 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-[#6d28d9] 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-[#6d28d9]/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-[#6d28d9]/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-[#6d28d9]/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-[#6d28d9]/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cast/Staff Card */}
|
||||
{(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 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-[#6d28d9] shadow-sm">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Cast & Crew</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Staff List */}
|
||||
<div className="space-y-2">
|
||||
{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-background rounded-xl border border-border/50">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffName" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Name</Label>
|
||||
<Input
|
||||
id="staffName"
|
||||
placeholder="Actor name"
|
||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||
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">
|
||||
<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-[#6d28d9]/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-[#6d28d9]/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffPhoto" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Photo URL (optional)</Label>
|
||||
<Input
|
||||
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-[#6d28d9]/50"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStaffMember}
|
||||
variant="outline"
|
||||
className="w-full border-border/50 text-sm font-bold hover:border-[#6d28d9]/50 hover:bg-[#6d28d9]/10 rounded-xl transition-all duration-300"
|
||||
>
|
||||
+ Add Cast Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Submit Button - Full Width */}
|
||||
<div className="lg:col-span-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import MediaListItem from './MediaListItem';
|
||||
import { Filter, LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Plus } from 'lucide-react';
|
||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Loading from '@/components/ui/loading';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,98 +11,67 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
|
||||
interface BrowseViewProps {
|
||||
mediaList: Media[];
|
||||
onMediaClick: (media: Media) => void;
|
||||
onAddMedia: (media: Media) => void;
|
||||
activeCategory: MediaCategory;
|
||||
itemsPerPage?: number;
|
||||
gridItemSize?: number;
|
||||
onGridItemSizeChange?: (size: number) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function BrowseView({ mediaList, onMediaClick, onAddMedia, activeCategory }: BrowseViewProps) {
|
||||
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12, gridItemSize: initialGridItemSize = 5, onGridItemSizeChange, loading = false }: BrowseViewProps) {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||
const [sortBy, setSortBy] = useState<string>('default');
|
||||
|
||||
// Add Media Dialog State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [newMedia, setNewMedia] = useState({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: activeCategory as MediaCategory,
|
||||
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1'
|
||||
});
|
||||
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
|
||||
|
||||
// Update category and default aspect ratio when activeCategory changes
|
||||
// Sync itemsPerPage with prop when API settings are loaded
|
||||
useEffect(() => {
|
||||
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
if (activeCategory === 'Music') defaultAspect = '1/1';
|
||||
if (activeCategory === 'Games' || activeCategory === 'Adult') defaultAspect = '16/9';
|
||||
|
||||
setNewMedia(prev => ({
|
||||
...prev,
|
||||
category: activeCategory,
|
||||
aspectRatio: defaultAspect
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
if (initialItemsPerPage) {
|
||||
setItemsPerPage(initialItemsPerPage);
|
||||
}
|
||||
}, [initialItemsPerPage]);
|
||||
|
||||
const handleAddSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMedia.title || !newMedia.poster) return;
|
||||
// Sync gridItemSize with prop when API settings are loaded
|
||||
useEffect(() => {
|
||||
if (initialGridItemSize !== undefined) {
|
||||
setGridItemSize(initialGridItemSize);
|
||||
}
|
||||
}, [initialGridItemSize]);
|
||||
|
||||
onAddMedia({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
title: newMedia.title,
|
||||
year: newMedia.year || new Date().getFullYear().toString(),
|
||||
poster: newMedia.poster,
|
||||
category: newMedia.category,
|
||||
aspectRatio: newMedia.aspectRatio,
|
||||
status: 'planned'
|
||||
});
|
||||
|
||||
setNewMedia({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: activeCategory,
|
||||
aspectRatio: '2/3'
|
||||
});
|
||||
setIsAddDialogOpen(false);
|
||||
};
|
||||
|
||||
// Filter states
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
|
||||
// Extract unique values for filters
|
||||
const allTypes = useMemo(() => Array.from(new Set(mediaList.map(m => m.type).filter(Boolean))), [mediaList]);
|
||||
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 (selectedType && media.type !== selectedType) return false;
|
||||
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 (selectedSource && media.source !== selectedSource) return false;
|
||||
return true;
|
||||
});
|
||||
}, [mediaList, selectedType, selectedGenre, selectedStudio]);
|
||||
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory, selectedSource]);
|
||||
|
||||
// Reset to first page when mediaList or filters change
|
||||
useEffect(() => {
|
||||
@@ -119,6 +89,23 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
return list;
|
||||
}, [filteredMedia, sortBy]);
|
||||
|
||||
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',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
|
||||
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6',
|
||||
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
|
||||
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
|
||||
7: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
|
||||
8: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
|
||||
9: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
|
||||
10: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-12',
|
||||
};
|
||||
return `grid ${colsMap[gridItemSize] || colsMap[5]}`;
|
||||
}, [gridItemSize]);
|
||||
|
||||
const totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
|
||||
|
||||
const paginatedMedia = useMemo(() => {
|
||||
@@ -137,33 +124,17 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
};
|
||||
|
||||
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">
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Type Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedType ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<Filter size={16} />
|
||||
{selectedType || 'Media Type'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setSelectedType(null)}>All Types</DropdownMenuItem>
|
||||
{allTypes.map(type => (
|
||||
<DropdownMenuItem key={type} onClick={() => setSelectedType(type!)}>{type}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Genre Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<button type="button" className={cn("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 duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
<Star size={16} />
|
||||
{selectedGenre || 'Genres'}
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
|
||||
@@ -176,9 +147,9 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
{/* Studio Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<button type="button" className={cn("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 duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
Studios
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
|
||||
@@ -188,15 +159,90 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{(selectedType || selectedGenre || selectedStudio) && (
|
||||
{/* 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-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
<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-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
<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-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
<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-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
<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-zinc-400 font-bold"
|
||||
className="text-muted-foreground font-bold hover:text-[#6d28d9] transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedType(null);
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
setSelectedPlatform(null);
|
||||
setSelectedDeveloper(null);
|
||||
setSelectedCategory(null);
|
||||
setSelectedSource(null);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
@@ -204,99 +250,31 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black rounded-full px-6 h-11 shadow-lg shadow-[#6d28d9]/20 gap-2">
|
||||
<Plus size={20} />
|
||||
ADD NEW
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
|
||||
<form onSubmit={handleAddSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-black text-zinc-900">Add New Media</DialogTitle>
|
||||
<DialogDescription className="text-zinc-500 font-medium">
|
||||
Manually add a new item to your {activeCategory} library.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 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-zinc-700">Year</Label>
|
||||
<Input
|
||||
id="year"
|
||||
value={newMedia.year}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||
placeholder="2024"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category" className="text-sm font-black text-zinc-700">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={newMedia.category}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">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-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="2/3">2:3 (Standard Poster - Anime/Movies)</option>
|
||||
<option value="16/9">16:9 (Wide Thumbnail - Games/Adult)</option>
|
||||
<option value="1/1">1:1 (Square - Music)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="poster" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20">
|
||||
SAVE TO LIBRARY
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Grid item size slider */}
|
||||
<div className="flex items-center gap-3 bg-muted/50 backdrop-blur-sm rounded-xl px-4 py-2.5 border border-border/50">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-zinc-600 font-bold gap-2">
|
||||
<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 duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 text-muted-foreground font-bold backdrop-blur-sm border-border/50">
|
||||
<ArrowUpDown size={16} />
|
||||
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem>
|
||||
@@ -305,13 +283,13 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center bg-zinc-100 rounded-md p-1">
|
||||
<div className="flex items-center bg-muted/50 backdrop-blur-sm rounded-xl p-1 border border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all",
|
||||
viewMode === 'grid' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
|
||||
"h-8 w-8 transition-all rounded-lg",
|
||||
viewMode === 'grid' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
@@ -321,8 +299,8 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all",
|
||||
viewMode === 'list' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
|
||||
"h-8 w-8 transition-all rounded-lg",
|
||||
viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
|
||||
)}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
@@ -333,18 +311,20 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{mediaList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
|
||||
<div className="w-16 h-16 bg-zinc-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Filter size={32} />
|
||||
{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} />
|
||||
</div>
|
||||
<p className="text-lg font-bold">No results found</p>
|
||||
<p className="text-sm">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-x-4 gap-y-8"
|
||||
viewMode === 'grid'
|
||||
? cn(gridColsClass, "gap-x-4 gap-y-8")
|
||||
: "flex flex-col gap-2"
|
||||
)}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
@@ -369,18 +349,18 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{mediaList.length > 0 && (
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-zinc-100 pt-8">
|
||||
<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-zinc-500 font-medium">Items per page:</span>
|
||||
<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-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
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"
|
||||
>
|
||||
{[8, 12, 16, 24, 48].map(size => (
|
||||
{[12, 20, 36, 48, 60].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -392,7 +372,7 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
@@ -400,8 +380,8 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
|
||||
<span className="text-sm text-zinc-400 font-medium">of</span>
|
||||
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</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
|
||||
@@ -409,7 +389,7 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
className="gap-2 font-bold border-border"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
|
||||
@@ -1,35 +1,54 @@
|
||||
import { Staff, Media } from '@/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'motion/react';
|
||||
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User } from 'lucide-react';
|
||||
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye, ChevronDown, ListFilter } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CastDetailViewProps {
|
||||
person: Staff;
|
||||
onBack: () => void;
|
||||
onMediaClick: (mediaId: string) => void;
|
||||
relatedMedia: Media[];
|
||||
}
|
||||
|
||||
export default function CastDetailView({ person, onBack, onMediaClick, relatedMedia }: CastDetailViewProps) {
|
||||
export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [sortBy, setSortBy] = useState<'year' | 'title' | 'role'>('role');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const handleMediaClick = (mediaId: string) => {
|
||||
navigate(`/media/${mediaId}`);
|
||||
};
|
||||
|
||||
const sortedFilmography = [...(person.filmography || [])].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
if (sortBy === 'year') {
|
||||
comparison = (a.year || 0) - (b.year || 0);
|
||||
} else if (sortBy === 'title') {
|
||||
comparison = (a.title || '').localeCompare(b.title || '');
|
||||
} else if (sortBy === 'role') {
|
||||
comparison = (a.role || '').localeCompare(b.role || '');
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-20">
|
||||
<div className="min-h-screen bg-background pb-20">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-[40vh] md:h-[50vh] overflow-hidden bg-zinc-900">
|
||||
<div className="relative h-[50vh] md:h-[60vh] 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"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
|
||||
<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">
|
||||
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-48 h-48 md:w-64 md:h-64 rounded-2xl overflow-hidden border-4 border-white shadow-2xl shrink-0"
|
||||
className="h-48 md:h-72 rounded-2xl overflow-hidden border-4 border-background shadow-2xl shrink-0"
|
||||
>
|
||||
<img
|
||||
src={person.photo}
|
||||
@@ -45,15 +64,20 @@ export default function CastDetailView({ person, onBack, onMediaClick, relatedMe
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-zinc-900 mb-4 drop-shadow-sm">
|
||||
<h1 className="text-5xl md:text-7xl font-black text-foreground mb-4 drop-shadow-sm">
|
||||
{person.name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-3">
|
||||
{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-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 font-bold px-4 py-1.5 backdrop-blur-sm">
|
||||
{occ}
|
||||
</Badge>
|
||||
))}
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold px-4 py-1.5">
|
||||
{person.filmography.length} Role{person.filmography.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -63,140 +87,286 @@ export default function CastDetailView({ person, onBack, onMediaClick, relatedMe
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="absolute top-24 left-6 bg-white/20 hover:bg-white/40 text-white rounded-full backdrop-blur-md"
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute top-24 left-6 bg-white/30 hover:bg-white/50 text-white rounded-2xl backdrop-blur-md transition-all duration-300 hover:scale-110 border border-white/20"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
</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">
|
||||
<div className="max-w-[1920px] 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-zinc-50 rounded-3xl p-8 space-y-6">
|
||||
<h3 className="text-xl font-black text-zinc-900">Personal Info</h3>
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
|
||||
<h3 className="text-2xl 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-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-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Date</p>
|
||||
<p className="font-bold text-zinc-700">{person.birthDate || 'Unknown'}</p>
|
||||
<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-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-[#6d28d9] shadow-sm border border-border/50">
|
||||
<MapPin size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Place</p>
|
||||
<p className="font-bold text-zinc-700">{person.birthPlace || 'Unknown'}</p>
|
||||
<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-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-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Briefcase size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Known For</p>
|
||||
<p className="font-bold text-zinc-700">{person.role}</p>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Known For</p>
|
||||
<p className="font-bold text-foreground">{person.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(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/50">
|
||||
<User size={20} />
|
||||
</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>
|
||||
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
|
||||
<h3 className="text-2xl 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/50">
|
||||
<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>
|
||||
</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/50">
|
||||
<Ruler size={20} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{(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/50">
|
||||
<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">
|
||||
{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}`}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(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/50">
|
||||
<Palette size={20} />
|
||||
</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>
|
||||
</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/50">
|
||||
<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/50">
|
||||
<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/50">
|
||||
<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">
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
|
||||
Biography
|
||||
</h2>
|
||||
<p className="text-zinc-600 leading-relaxed text-lg">
|
||||
{person.bio || `${person.name} is a talented ${person.role} known for their work in various media productions. They have brought numerous characters to life with their unique performances.`}
|
||||
</p>
|
||||
</section>
|
||||
{person.bio && (
|
||||
<section>
|
||||
<h2 className="text-3xl 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>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-zinc-900 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">
|
||||
{relatedMedia.map(media => {
|
||||
const character = media.staff?.find(s => s.id === person.id);
|
||||
if (!character) return null;
|
||||
return (
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-3xl 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={`${media.id}-char`}
|
||||
className="flex items-center gap-4 p-4 rounded-2xl bg-zinc-50 border border-zinc-100"
|
||||
key={`${item.id}-char`}
|
||||
className="flex items-center gap-4 p-5 rounded-2xl bg-muted/50 border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-white">
|
||||
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-background">
|
||||
<img
|
||||
src={character.characterImage}
|
||||
alt={character.characterName}
|
||||
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-zinc-400 uppercase tracking-widest mb-1">Character</p>
|
||||
<h4 className="font-black text-zinc-900 truncate">{character.characterName}</h4>
|
||||
<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={() => onMediaClick(media.id)}
|
||||
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left"
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left transition-colors"
|
||||
>
|
||||
in {media.title}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
|
||||
<Film className="text-[#6d28d9]" />
|
||||
Filmography
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{relatedMedia.map(media => (
|
||||
<div
|
||||
key={media.id}
|
||||
onClick={() => onMediaClick(media.id)}
|
||||
className="group flex items-center gap-4 p-4 rounded-2xl bg-white border border-zinc-100 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={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
{media.title}
|
||||
</h4>
|
||||
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-1">
|
||||
{media.year}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-zinc-200">
|
||||
{person.role}
|
||||
</Badge>
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl 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-xl border-border hover:border-[#6d28d9]/50 transition-all duration-300"
|
||||
>
|
||||
<ListFilter size={16} />
|
||||
</Button>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as 'year' | 'title' | 'role')}
|
||||
className="bg-muted/50 backdrop-blur-sm border border-border/50 rounded-xl px-4 py-2 text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||
>
|
||||
<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/50 hover:border-[#6d28d9]/30 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300 cursor-pointer"
|
||||
>
|
||||
<div className="w-16 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border border-border/30">
|
||||
<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 duration-300">
|
||||
{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/50">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,172 @@
|
||||
import { Staff } from '@/types';
|
||||
import { Staff, MediaCategory } from '@/types';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import Loading from '@/components/ui/loading';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { fetchAllCast } from '@/api';
|
||||
|
||||
interface CastViewProps {
|
||||
staffList: Staff[];
|
||||
onPersonClick: (person: Staff) => void;
|
||||
enabledCategories: MediaCategory[];
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
export default function CastView({ staffList, onPersonClick }: CastViewProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'name' | 'role'>('name');
|
||||
export default function CastView({ onPersonClick, enabledCategories, itemsPerPage: initialItemsPerPage = 12 }: CastViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [staffList, setStaffList] = useState<Staff[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState(() => {
|
||||
return localStorage.getItem('castSearchQuery') || '';
|
||||
});
|
||||
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height' | 'roleCount'>(() => {
|
||||
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height' | 'roleCount') || 'roleCount';
|
||||
});
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
|
||||
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
|
||||
});
|
||||
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
|
||||
return localStorage.getItem('castFilterOccupation') || '';
|
||||
});
|
||||
const [filterMediaType, setFilterMediaType] = useState<string>(() => {
|
||||
return localStorage.getItem('castFilterMediaType') || '';
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Sync itemsPerPage with prop when API settings are loaded
|
||||
useEffect(() => {
|
||||
if (initialItemsPerPage) {
|
||||
setItemsPerPage(initialItemsPerPage);
|
||||
}
|
||||
}, [initialItemsPerPage]);
|
||||
|
||||
// Persist filters and sorts
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castSearchQuery', searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castSortBy', sortBy);
|
||||
}, [sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castSortOrder', sortOrder);
|
||||
}, [sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castFilterOccupation', filterOccupation);
|
||||
}, [filterOccupation]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castFilterMediaType', filterMediaType);
|
||||
}, [filterMediaType]);
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSortBy('roleCount');
|
||||
setSortOrder('desc');
|
||||
setFilterOccupation('');
|
||||
setFilterMediaType('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
||||
|
||||
useEffect(() => {
|
||||
const loadCast = async () => {
|
||||
try {
|
||||
const cast = await fetchAllCast();
|
||||
setStaffList(cast);
|
||||
} catch (error) {
|
||||
console.error('Failed to load cast:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadCast();
|
||||
}, []);
|
||||
|
||||
const filteredStaff = useMemo(() => {
|
||||
let list = staffList.filter(s =>
|
||||
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.role.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.mediaTitle?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
let list = staffList.filter(s => {
|
||||
// Hide actors without linked media
|
||||
if (!s.filmography || s.filmography.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by enabled categories based on media_types
|
||||
if (s.media_types && s.media_types.length > 0) {
|
||||
const hasEnabledMediaType = s.media_types.some(type => {
|
||||
const category = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
return enabledCategories.includes(category as MediaCategory);
|
||||
});
|
||||
if (!hasEnabledMediaType) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by occupation
|
||||
if (filterOccupation && !s.occupations?.includes(filterOccupation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by media type
|
||||
if (filterMediaType && !s.media_types?.includes(filterMediaType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.role.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.mediaTitle?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
return list.sort((a, b) => a[sortBy].localeCompare(b[sortBy]));
|
||||
}, [staffList, searchQuery, sortBy]);
|
||||
// Sort
|
||||
list.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortBy === 'name' || sortBy === 'role') {
|
||||
comparison = a[sortBy].localeCompare(b[sortBy]);
|
||||
} else if (sortBy === 'birthDate') {
|
||||
const dateA = a.birthDate ? new Date(a.birthDate).getTime() : 0;
|
||||
const dateB = b.birthDate ? new Date(b.birthDate).getTime() : 0;
|
||||
comparison = dateA - dateB;
|
||||
} else if (sortBy === 'height') {
|
||||
const heightA = a.height || 0;
|
||||
const heightB = b.height || 0;
|
||||
comparison = heightA - heightB;
|
||||
} else if (sortBy === 'roleCount') {
|
||||
const roleCountA = a.filmography?.length || 0;
|
||||
const roleCountB = b.filmography?.length || 0;
|
||||
comparison = roleCountA - roleCountB;
|
||||
}
|
||||
|
||||
return sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
|
||||
return list;
|
||||
}, [staffList, searchQuery, sortBy, sortOrder, filterOccupation, filterMediaType, enabledCategories]);
|
||||
|
||||
// Get unique occupations and media types for filters
|
||||
const uniqueOccupations = useMemo(() => {
|
||||
const occupations = new Set<string>();
|
||||
staffList.forEach(s => s.occupations?.forEach(o => occupations.add(o)));
|
||||
return Array.from(occupations).sort();
|
||||
}, [staffList]);
|
||||
|
||||
const uniqueMediaTypes = useMemo(() => {
|
||||
const mediaTypes = new Set<string>();
|
||||
staffList.forEach(s => s.media_types?.forEach(m => mediaTypes.add(m)));
|
||||
return Array.from(mediaTypes).sort();
|
||||
}, [staffList]);
|
||||
|
||||
// Reset to first page when filters or sorting change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, sortBy, itemsPerPage]);
|
||||
}, [searchQuery, sortBy, sortOrder, filterOccupation, filterMediaType, itemsPerPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredStaff.length / itemsPerPage);
|
||||
|
||||
@@ -50,54 +186,165 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] 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-zinc-900 mb-2">Cast & Staff</h1>
|
||||
<p className="text-zinc-500 font-medium">Discover the people behind your favorite media</p>
|
||||
<h1 className="text-5xl font-black text-foreground mb-3 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
||||
Cast & Staff
|
||||
</h1>
|
||||
<p className="text-muted-foreground font-medium text-lg">Discover the people behind your favorite media</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
|
||||
<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-zinc-100 border-none rounded-full h-11"
|
||||
className="pl-10 w-full md:w-[300px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={showFilters ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
className={`rounded-xl h-11 w-11 transition-all duration-300 ${showFilters ? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white border-[#6d28d9]' : 'border-border hover:border-[#6d28d9]/50'}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full h-11 w-11 border-zinc-200"
|
||||
onClick={() => setSortBy(prev => prev === 'name' ? 'role' : 'name')}
|
||||
className="rounded-xl h-11 w-11 border-border hover:border-[#6d28d9]/50 transition-all duration-300"
|
||||
onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||
>
|
||||
<ArrowUpDown size={20} />
|
||||
</Button>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-xl h-11 w-11 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-300"
|
||||
onClick={handleResetFilters}
|
||||
title="Reset filters"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredStaff.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
|
||||
<User size={48} className="mb-4 opacity-20" />
|
||||
<p className="text-lg font-bold">No cast members found</p>
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 mb-6 border border-border/50"
|
||||
>
|
||||
<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/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 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/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 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/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 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 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
||||
Search: {searchQuery}
|
||||
<button onClick={() => setSearchQuery('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{filterOccupation && (
|
||||
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
||||
Occupation: {filterOccupation}
|
||||
<button onClick={() => setFilterOccupation('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{filterMediaType && (
|
||||
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
||||
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 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
||||
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-32 text-muted-foreground">
|
||||
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-sm border border-border/50">
|
||||
<User size={40} />
|
||||
</div>
|
||||
<p className="text-xl 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}-${person.mediaId}`}
|
||||
key={person.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="group bg-white rounded-2xl p-4 shadow-sm border border-zinc-100 hover:shadow-xl hover:border-[#6d28d9]/20 transition-all duration-300 cursor-pointer"
|
||||
className="group bg-card rounded-2xl p-5 shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 hover:shadow-[#6d28d9]/10 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-zinc-100 group-hover:border-[#6d28d9] transition-colors">
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-border/50 group-hover:border-[#6d28d9] transition-colors duration-300">
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
@@ -105,31 +352,38 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
|
||||
{person.name}
|
||||
</h3>
|
||||
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider">
|
||||
<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>
|
||||
|
||||
<div className="bg-zinc-50 rounded-xl p-3 flex items-center gap-3">
|
||||
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-white">
|
||||
<img
|
||||
src={person.characterImage}
|
||||
alt={person.characterName}
|
||||
className="w-full h-full object-contain"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-3 flex items-center gap-3 border border-border/30">
|
||||
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background border border-border/30">
|
||||
<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-[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>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest leading-none mb-1">Character</p>
|
||||
<p className="text-xs font-bold text-zinc-700 truncate">{person.characterName}</p>
|
||||
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">in {person.mediaTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@@ -138,17 +392,17 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredStaff.length > 0 && (
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-zinc-100 pt-8">
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border/50 pt-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
|
||||
<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-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
className="bg-muted/50 backdrop-blur-sm border-none rounded-xl px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
||||
>
|
||||
{[8, 12, 16, 24, 48].map(size => (
|
||||
{[12, 20, 36, 48, 60].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -160,7 +414,7 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
@@ -168,8 +422,8 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
|
||||
<span className="text-sm text-zinc-400 font-medium">of</span>
|
||||
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</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
|
||||
@@ -177,7 +431,7 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
|
||||
279
src/components/DashboardView.tsx
Normal file
279
src/components/DashboardView.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import { Film, Tv, Music, Book, Gamepad2, Users, Star, TrendingUp, Clock, Hash, Play, Award } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import Loading from '@/components/ui/loading';
|
||||
|
||||
interface DashboardViewProps {
|
||||
mediaList: Media[];
|
||||
onMediaClick: (media: Media) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function DashboardView({ mediaList, onMediaClick, loading = false }: DashboardViewProps) {
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const totalMedia = mediaList.length;
|
||||
const categories = mediaList.reduce((acc, media) => {
|
||||
acc[media.category] = (acc[media.category] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<MediaCategory, number>);
|
||||
|
||||
const totalRating = mediaList.reduce((sum, media) => sum + (media.rating || 0), 0);
|
||||
const avgRating = totalRating > 0 ? (totalRating / mediaList.filter(m => m.rating).length).toFixed(1) : '0.0';
|
||||
|
||||
const totalPlaytime = mediaList.reduce((sum, media) => sum + (media.playtime || 0), 0);
|
||||
const totalPlayCount = mediaList.reduce((sum, media) => sum + (media.playCount || 0), 0);
|
||||
|
||||
return {
|
||||
totalMedia,
|
||||
categories,
|
||||
avgRating,
|
||||
totalPlaytime,
|
||||
totalPlayCount
|
||||
};
|
||||
}, [mediaList]);
|
||||
|
||||
// Get recently added media (sorted by some indicator - using index as proxy)
|
||||
const recentMedia = useMemo(() => {
|
||||
return [...mediaList].slice(0, 8);
|
||||
}, [mediaList]);
|
||||
|
||||
// Get top rated media
|
||||
const topRatedMedia = useMemo(() => {
|
||||
return [...mediaList]
|
||||
.filter(m => m.rating && m.rating > 0)
|
||||
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
||||
.slice(0, 8);
|
||||
}, [mediaList]);
|
||||
|
||||
// Get most played media
|
||||
const mostPlayedMedia = useMemo(() => {
|
||||
return [...mediaList]
|
||||
.filter(m => m.playCount && m.playCount > 0)
|
||||
.sort((a, b) => (b.playCount || 0) - (a.playCount || 0))
|
||||
.slice(0, 8);
|
||||
}, [mediaList]);
|
||||
|
||||
// Category icons mapping
|
||||
const categoryIcons: Record<MediaCategory, any> = {
|
||||
'Anime': Tv,
|
||||
'Movies': Film,
|
||||
'TV Series': Tv,
|
||||
'Music': Music,
|
||||
'Books': Book,
|
||||
'Games': Gamepad2,
|
||||
'Consoles': Gamepad2,
|
||||
'Adult': Users
|
||||
};
|
||||
|
||||
// Category colors
|
||||
const categoryColors: Record<MediaCategory, string> = {
|
||||
'Anime': 'bg-purple-500/10 text-purple-500 border-purple-500/20',
|
||||
'Movies': 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||
'TV Series': 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||
'Music': 'bg-pink-500/10 text-pink-500 border-pink-500/20',
|
||||
'Books': 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
|
||||
'Games': 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||
'Consoles': 'bg-orange-500/10 text-orange-500 border-orange-500/20',
|
||||
'Adult': 'bg-gray-500/10 text-gray-500 border-gray-500/20'
|
||||
};
|
||||
|
||||
const formatPlaytime = (minutes: number) => {
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading message="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-5xl font-black text-foreground mb-3 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground font-medium text-lg">Overview of your media collection</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-[#6d28d9]/10 to-[#8b5cf6]/5 border border-[#6d28d9]/20 hover:border-[#6d28d9]/40 transition-all duration-300 hover:shadow-lg hover:shadow-[#6d28d9]/10"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-[#6d28d9]/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Hash className="w-10 h-10 text-[#6d28d9]" />
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
|
||||
</div>
|
||||
<div className="text-4xl font-black text-foreground">{stats.totalMedia}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium mt-1">Media Items</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-yellow-500/10 to-amber-500/5 border border-yellow-500/20 hover:border-yellow-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-yellow-500/10"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-yellow-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Star className="w-10 h-10 text-yellow-500" />
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Average</span>
|
||||
</div>
|
||||
<div className="text-4xl font-black text-foreground">{stats.avgRating}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium mt-1">Rating</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-green-500/10 to-emerald-500/5 border border-green-500/20 hover:border-green-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-green-500/10"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-green-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Play className="w-10 h-10 text-green-500" />
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
|
||||
</div>
|
||||
<div className="text-4xl font-black text-foreground">{stats.totalPlayCount}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium mt-1">Play Count</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-blue-500/10 to-cyan-500/5 border border-blue-500/20 hover:border-blue-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/10"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Clock className="w-10 h-10 text-blue-500" />
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
|
||||
</div>
|
||||
<div className="text-4xl font-black text-foreground">{formatPlaytime(stats.totalPlaytime)}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium mt-1">Playtime</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="relative overflow-hidden rounded-2xl p-8 bg-gradient-to-br from-muted/50 to-muted/30 border border-border mb-10"
|
||||
>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<TrendingUp className="w-6 h-6 text-[#6d28d9]" />
|
||||
Category Breakdown
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-4">
|
||||
{(Object.keys(stats.categories) as MediaCategory[]).map((category) => {
|
||||
const Icon = categoryIcons[category];
|
||||
const count = stats.categories[category] || 0;
|
||||
const percentage = stats.totalMedia > 0 ? ((count / stats.totalMedia) * 100).toFixed(1) : '0';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className={`rounded-xl p-5 border backdrop-blur-sm transition-all duration-300 hover:scale-105 hover:shadow-lg ${categoryColors[category]} flex flex-col items-center justify-center gap-2`}
|
||||
>
|
||||
<Icon className="w-7 h-7" />
|
||||
<div className="text-xs font-bold uppercase tracking-wider">{category}</div>
|
||||
<div className="text-3xl font-black">{count}</div>
|
||||
<div className="text-xs font-medium opacity-75">{percentage}%</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Recent Media */}
|
||||
{recentMedia.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="mb-10"
|
||||
>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<Clock className="w-6 h-6 text-[#6d28d9]" />
|
||||
Recent Additions
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
|
||||
{recentMedia.map((media) => (
|
||||
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Top Rated Media */}
|
||||
{topRatedMedia.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="mb-10"
|
||||
>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<Award className="w-6 h-6 text-[#6d28d9]" />
|
||||
Top Rated
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
|
||||
{topRatedMedia.map((media) => (
|
||||
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Most Played Media */}
|
||||
{mostPlayedMedia.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
className="mb-10"
|
||||
>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<Play className="w-6 h-6 text-[#6d28d9]" />
|
||||
Most Played
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
|
||||
{mostPlayedMedia.map((media) => (
|
||||
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
|
||||
))}
|
||||
</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/50 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-sm border border-border/50">
|
||||
<Hash size={40} />
|
||||
</div>
|
||||
<p className="text-xl font-bold">No media found</p>
|
||||
<p className="text-sm">Start by adding media to your collection</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Media, Staff } from '@/types';
|
||||
import { Media, Staff, Track } from '@/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Bookmark,
|
||||
@@ -7,7 +9,11 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
ListFilter
|
||||
ListFilter,
|
||||
ChevronDown,
|
||||
Calendar,
|
||||
Clock,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -17,39 +23,101 @@ import { motion } from 'motion/react';
|
||||
|
||||
interface DetailViewProps {
|
||||
media: Media;
|
||||
onBack: () => void;
|
||||
onPersonClick: (person: Staff) => void;
|
||||
}
|
||||
|
||||
export default function DetailView({ media, onBack, onPersonClick }: DetailViewProps) {
|
||||
export default function DetailView({ media, 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, setProgress] = useState(70.8);
|
||||
|
||||
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 tabs = [
|
||||
'Overview',
|
||||
...(hasCast ? ['Cast'] : []),
|
||||
'Actions',
|
||||
'History',
|
||||
...(hasEpisodes ? ['Seasons'] : []),
|
||||
...(hasTracks ? ['Tracks'] : []),
|
||||
'Reviews',
|
||||
'Suggestions',
|
||||
'Watch On'
|
||||
];
|
||||
|
||||
const [activeTab, setActiveTab] = useState(tabs[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]);
|
||||
|
||||
// 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;
|
||||
});
|
||||
};
|
||||
|
||||
const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []);
|
||||
const hasMoreCast = (media.staff?.length || 0) > castLimit;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50">
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Banner */}
|
||||
<div className="relative h-[400px] w-full overflow-hidden">
|
||||
<img
|
||||
src={media.banner || media.poster}
|
||||
<div className="relative h-[450px] 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-zinc-50 via-zinc-50/40 to-transparent" />
|
||||
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-24 left-6 p-2 bg-black/20 hover:bg-black/40 text-white rounded-full transition-colors z-10"
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" />
|
||||
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute top-24 left-6 p-3 bg-black/30 hover:bg-black/50 backdrop-blur-md text-white rounded-2xl transition-all duration-300 hover:scale-110 z-10 border border-white/20 lg:left-80"
|
||||
>
|
||||
<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-8">
|
||||
{/* Left Column: Poster */}
|
||||
<div className="w-full md:w-[300px] shrink-0">
|
||||
<div className="max-w-[1920px] mx-auto px-6 py-8 pb-24 -mt-32 relative z-10">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Left Column: Cover Image */}
|
||||
<div className="w-full lg:w-[400px] shrink-0">
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className={`rounded-xl overflow-hidden shadow-2xl bg-zinc-800 ${
|
||||
className={`rounded-2xl overflow-hidden shadow-2xl bg-card border border-border/50 ${
|
||||
media.aspectRatio === '16/9' ? 'aspect-video' :
|
||||
media.aspectRatio === '1/1' ? 'aspect-square' :
|
||||
'aspect-[2/3]'
|
||||
@@ -65,156 +133,246 @@ export default function DetailView({ media, onBack, onPersonClick }: DetailViewP
|
||||
</div>
|
||||
|
||||
{/* Right Column: Info */}
|
||||
<div className="flex-1 pt-32 md:pt-40">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-zinc-900 mb-2">
|
||||
{media.title} <span className="text-zinc-400 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-zinc-300">
|
||||
<Bookmark size={20} />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="rounded-full border-zinc-300">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-zinc-600 font-bold">
|
||||
<Star size={18} className="text-yellow-500" fill="currentColor" />
|
||||
{media.rating} / 10
|
||||
</div>
|
||||
</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-zinc-600 hover:text-[#6d28d9] cursor-pointer transition-colors">
|
||||
• {genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-zinc-600 leading-relaxed mb-8 max-w-3xl">
|
||||
{media.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{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}
|
||||
<div className="flex-1">
|
||||
{/* Header with tags */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<h1 className="text-4xl lg:text-5xl font-black text-foreground">
|
||||
{media.title}
|
||||
</h1>
|
||||
{media.status && (
|
||||
<Badge className={
|
||||
media.status === 'watching' || media.status === 'reading' || media.status === 'listening' || media.status === 'playing'
|
||||
? 'bg-green-500/20 text-green-400 border-green-500/30 font-bold'
|
||||
: media.status === 'completed'
|
||||
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 font-bold'
|
||||
: 'bg-gray-500/20 text-gray-400 border-gray-500/30 font-bold'
|
||||
}>
|
||||
{media.status.toUpperCase()}
|
||||
</Badge>
|
||||
))}
|
||||
)}
|
||||
{media.completionStatus && (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30 font-bold">{media.completionStatus.toUpperCase()}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Studios:</span>
|
||||
{media.studios?.join(', ')}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-bold text-zinc-400 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>
|
||||
{/* Show Details */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar size={16} />
|
||||
<span>{media.year}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{media.status ? media.status.charAt(0).toUpperCase() + media.status.slice(1) : 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock size={16} />
|
||||
<span>{media.playtime ? `${media.playtime}h` : '12h 30m'}</span>
|
||||
</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-zinc-900">Cast & Crew</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" className="rounded-full border-zinc-200">
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="rounded-full border-zinc-200">
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-foreground">Progress</span>
|
||||
<span className="text-sm font-bold text-[#6d28d9]">{progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{media.staff.map(person => (
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex items-center gap-4 bg-white p-3 rounded-xl shadow-sm border border-zinc-100 hover:shadow-md transition-shadow cursor-pointer group"
|
||||
onClick={() => onPersonClick(person)}
|
||||
className="h-full bg-gradient-to-r from-[#6d28d9] to-[#8b5cf6] transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex flex-wrap gap-2 mb-6 border-b border-border/50 pb-4">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<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-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
|
||||
<p className="text-xs text-zinc-500 truncate">{person.role}</p>
|
||||
</div>
|
||||
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-zinc-50">
|
||||
<img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
</div>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Episodes Section - Only show if episodes data exists */}
|
||||
{media.episodes && media.episodes.length > 0 && (
|
||||
{/* Genre Tags */}
|
||||
{activeTab === 'Overview' && (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{media.genres?.map(genre => (
|
||||
<Badge key={genre} variant="secondary" className="bg-muted/50 text-foreground hover:bg-muted/80 border border-border/50 px-3 py-1 font-bold text-sm">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{activeTab === 'Overview' && (
|
||||
<div
|
||||
className="text-foreground leading-relaxed mb-8 max-w-4xl prose prose-sm dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: media.description || '' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Acting Section - Horizontal Scrollable */}
|
||||
{media.staff && media.staff.length > 0 && activeTab === 'Cast' && (
|
||||
<section className="mt-12">
|
||||
<h2 className="text-2xl font-black text-foreground mb-6">Acting</h2>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
{displayedCast.map(person => (
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 cursor-pointer group"
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
<div className="w-full h-56 rounded-xl overflow-hidden mb-3 border border-border/30">
|
||||
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4>
|
||||
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
|
||||
</div>
|
||||
))}
|
||||
{hasMoreCast && (
|
||||
<button
|
||||
onClick={() => setShowAllCast(!showAllCast)}
|
||||
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 flex items-center justify-center"
|
||||
>
|
||||
<span className="font-bold text-[#6d28d9]">
|
||||
{showAllCast ? 'Show Less' : `+${media.staff!.length - castLimit} more`}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{/* Episodes Section - Only show if episodes data exists and Seasons tab is active */}
|
||||
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
|
||||
<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">
|
||||
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
|
||||
<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-zinc-400" size={16} />
|
||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-zinc-100 border-none rounded-full h-9 text-sm" />
|
||||
<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/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-zinc-400">
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-zinc-400">
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
||||
<ListFilter size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{media.episodes.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-zinc-900 group-hover:text-[#6d28d9] transition-colors">
|
||||
S1:E{episode.number} • {episode.title}
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-zinc-400">{episode.date} • {episode.duration}</span>
|
||||
<div className="space-y-4">
|
||||
{Object.keys(episodesBySeason)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(season => (
|
||||
<div key={season} className="border border-border/50 rounded-2xl overflow-hidden bg-card/50 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={() => toggleSeason(season)}
|
||||
className="w-full flex items-center justify-between p-6 bg-card/50 hover:bg-muted/50 transition-colors duration-300"
|
||||
>
|
||||
<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>
|
||||
<p className="text-sm text-zinc-500 leading-relaxed line-clamp-3">
|
||||
{episode.description}
|
||||
</p>
|
||||
</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-2xl overflow-hidden shadow-sm relative border border-border/30">
|
||||
<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 duration-300" />
|
||||
</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 duration-300">
|
||||
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/50" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="mt-6 bg-zinc-200" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tracks Section - Only show if tracks data exists and Tracks tab is active */}
|
||||
{media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
|
||||
<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-2xl">
|
||||
<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/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
||||
<ListFilter size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{media.tracks.map(track => (
|
||||
<div key={track.id} className="group cursor-pointer flex items-center gap-4 p-4 rounded-2xl hover:bg-muted/50 transition-colors duration-300 border border-transparent hover:border-border/30">
|
||||
<span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
|
||||
{track.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{track.artist}</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Search, User, X } from 'lucide-react';
|
||||
import { Search, User, X, Plus, Download, Settings, Menu } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, NavLink, useLocation } from 'react-router-dom';
|
||||
import { MediaCategory } from '@/types';
|
||||
import LibrarySettings from './LibrarySettings';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
interface HeaderProps {
|
||||
onBrowse: () => void;
|
||||
onCast: () => void;
|
||||
onSearch: (query: string) => void;
|
||||
activeCategory: MediaCategory;
|
||||
onCategoryChange: (category: MediaCategory) => void;
|
||||
@@ -15,18 +14,41 @@ interface HeaderProps {
|
||||
transparent?: boolean;
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
onBrowse,
|
||||
onCast,
|
||||
onSearch,
|
||||
export default function Header({
|
||||
onSearch,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
enabledCategories,
|
||||
onToggleCategory,
|
||||
transparent
|
||||
transparent
|
||||
}: HeaderProps) {
|
||||
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 = () => {
|
||||
setScrolled(window.scrollY > 10);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
@@ -43,77 +65,198 @@ export default function Header({
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
<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",
|
||||
transparent ? "bg-transparent" : "bg-[#6d28d9]"
|
||||
"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-xl bg-background/70 border-b border-border/30"
|
||||
: "backdrop-blur-xl bg-gradient-to-r from-[#6d28d9]/90 via-[#8b5cf6]/90 to-[#6d28d9]/90 border-b border-white/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-8">
|
||||
<div
|
||||
className="text-2xl font-black text-white cursor-pointer flex items-center gap-1"
|
||||
onClick={onBrowse}
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
"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="w-6 h-6 bg-white rounded-full flex items-center justify-center">
|
||||
<div className="w-3 h-3 bg-[#6d28d9] rounded-full" />
|
||||
<div className={cn(
|
||||
"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-[#6d28d9] to-[#8b5cf6] shadow-[#6d28d9]/30"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white" : "bg-white"
|
||||
)} />
|
||||
</div>
|
||||
kyoo
|
||||
</div>
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
|
||||
omnyx
|
||||
</span>
|
||||
</Link>
|
||||
<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",
|
||||
activeCategory === cat ? "text-white" : "text-white/60 hover:text-white"
|
||||
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
|
||||
? isActive
|
||||
? "text-white bg-white/10"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
: isActive
|
||||
? "text-foreground bg-[#6d28d9]/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="w-px h-4 bg-white/20 mx-2" />
|
||||
<button
|
||||
onClick={onCast}
|
||||
className="text-sm font-bold text-white/60 hover:text-white transition-colors uppercase tracking-wider"
|
||||
<div className={cn(
|
||||
"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-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? isActive ? "text-white bg-white/10" : "text-white/70 hover:text-white hover:bg-white/5"
|
||||
: isActive ? "text-foreground bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
CAST
|
||||
</button>
|
||||
</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 bg-white/10 rounded-full px-3 py-1" : "w-0"
|
||||
"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
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="bg-transparent border-none outline-none text-white text-sm w-full placeholder:text-white/50"
|
||||
className={cn(
|
||||
"bg-transparent border-none outline-none text-sm w-full placeholder:opacity-60",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white placeholder:text-white"
|
||||
: "text-foreground placeholder:text-muted-foreground"
|
||||
)}
|
||||
autoFocus={isSearchOpen}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onClick={toggleSearch}
|
||||
className="p-2 text-white/90 hover:text-white transition-colors"
|
||||
className={cn(
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "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>
|
||||
<LibrarySettings
|
||||
enabledCategories={enabledCategories}
|
||||
onToggleCategory={onToggleCategory}
|
||||
/>
|
||||
<button className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20">
|
||||
<img
|
||||
src="https://picsum.photos/seed/user/100/100"
|
||||
alt="User"
|
||||
<Link
|
||||
to="/add"
|
||||
className={cn(
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/import"
|
||||
className={cn(
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Download size={18} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
className={cn(
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</Link>
|
||||
<button className={cn(
|
||||
"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-[#6d28d9]/50"
|
||||
)}>
|
||||
<img
|
||||
src="https://picsum.photos/seed/user/100/100"
|
||||
alt="User"
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</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-[#6d28d9] bg-[#6d28d9]/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-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
CAST
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
1062
src/components/ImporterView.tsx
Normal file
1062
src/components/ImporterView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 variant="ghost" size="icon" className="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>
|
||||
</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-[#6d28d9]/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-[#6d28d9] 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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Media } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'motion/react';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
interface MediaCardProps {
|
||||
key?: string;
|
||||
@@ -48,34 +49,58 @@ export default function MediaCard({ media, onClick }: MediaCardProps) {
|
||||
layoutId={`media-${media.id}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => onClick(media)}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
whileHover={{ y: -8, scale: 1.02 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative rounded-lg overflow-hidden shadow-lg bg-zinc-800 transition-all duration-300",
|
||||
"relative rounded-2xl overflow-hidden bg-card transition-all duration-500 shadow-lg group-hover:shadow-2xl group-hover:shadow-[#6d28d9]/20",
|
||||
aspectRatioClass
|
||||
)}>
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
{/* Rating Badge */}
|
||||
{media.rating && (
|
||||
<div className="absolute top-3 right-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-full flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-all duration-500 transform translate-y-[-10px] group-hover:translate-y-0">
|
||||
<Star size={12} className="text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-xs font-bold text-white">{media.rating}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{media.status && (
|
||||
<div className={cn(
|
||||
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
|
||||
"absolute top-3 left-3 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-lg z-10",
|
||||
statusColors[media.status]
|
||||
)} />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
|
||||
|
||||
{/* Glow Effect on Hover */}
|
||||
<div className="absolute inset-0 rounded-2xl ring-2 ring-[#6d28d9]/0 group-hover:ring-[#6d28d9]/50 transition-all duration-500 pointer-events-none" />
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<h3 className="text-sm font-bold text-zinc-900 line-clamp-1 group-hover:text-[#6d28d9] transition-colors">
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<h3 className="text-sm font-bold text-foreground line-clamp-2 group-hover:text-[#6d28d9] transition-colors duration-300">
|
||||
{media.title}
|
||||
</h3>
|
||||
<p className="text-xs font-medium text-zinc-500">
|
||||
{media.year}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{media.year}
|
||||
</p>
|
||||
{media.genres && media.genres.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground/50">•</span>
|
||||
<p className="text-xs font-medium text-muted-foreground/70 line-clamp-1">
|
||||
{media.genres[0]}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -44,11 +44,11 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
|
||||
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-zinc-50 transition-colors cursor-pointer border border-transparent hover:border-zinc-200"
|
||||
className="group flex items-center gap-6 p-5 rounded-xl hover:bg-muted/50 transition-all duration-300 cursor-pointer border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg hover:shadow-[#6d28d9]/10"
|
||||
onClick={() => onClick(media)}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative rounded-lg overflow-hidden shrink-0 shadow-md bg-zinc-800 transition-all duration-300",
|
||||
"relative rounded-xl overflow-hidden shrink-0 shadow-md bg-card transition-all duration-300 group-hover:scale-105 border border-border/30",
|
||||
aspectRatioClass
|
||||
)}>
|
||||
<img
|
||||
@@ -57,6 +57,7 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
|
||||
{media.status && (
|
||||
<div className={cn(
|
||||
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
|
||||
@@ -67,32 +68,32 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-lg font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
<h3 className="text-lg font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
|
||||
{media.title}
|
||||
</h3>
|
||||
<span className="text-sm font-bold text-zinc-400">({media.year})</span>
|
||||
<span className="text-sm font-bold text-muted-foreground">({media.year})</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className="flex items-center gap-1 text-xs font-bold text-zinc-500">
|
||||
<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-zinc-400 uppercase tracking-wider">
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{media.genres?.slice(0, 3).join(' • ') || 'Anime'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500 line-clamp-2 max-w-2xl">
|
||||
<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-zinc-400 hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
|
||||
<Button size="icon" variant="ghost" className="rounded-xl text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10 transition-all duration-300">
|
||||
<Play size={18} fill="currentColor" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="rounded-full text-zinc-400 hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
|
||||
<Button size="icon" variant="ghost" className="rounded-xl text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10 transition-all duration-300">
|
||||
<Bookmark size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
499
src/components/SettingsView.tsx
Normal file
499
src/components/SettingsView.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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, Type, Image, Palette } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { fetchSettings, updateSettings } from '@/api';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
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 ITEMS_PER_PAGE_OPTIONS = [12, 20, 36, 48, 60];
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
];
|
||||
|
||||
interface SettingsViewProps {
|
||||
onSettingsSaved?: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
const { setTheme } = useTheme();
|
||||
const [settings, setSettings] = useState<UserSettings>({
|
||||
enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'],
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid',
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveStatus('idle');
|
||||
try {
|
||||
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');
|
||||
// Sync theme with theme context
|
||||
setTheme(savedSettings.theme);
|
||||
onSettingsSaved?.();
|
||||
} else {
|
||||
setSaveStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
setSaveStatus('error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (category: MediaCategory) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
enabledCategories: prev.enabledCategories.includes(category)
|
||||
? prev.enabledCategories.filter(c => c !== category)
|
||||
: [...prev.enabledCategories, category]
|
||||
}));
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="text-muted-foreground font-medium">Loading settings...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pt-20">
|
||||
{/* Content */}
|
||||
<div className="max-w-[1920px] 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 hover:bg-muted/50 px-3 py-1 rounded-xl transition-all duration-300"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back to home
|
||||
</Link>
|
||||
<h1 className="text-4xl font-black text-foreground">Settings</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-bold px-6 py-3 h-12 rounded-xl flex items-center gap-2 transition-all duration-300 hover:scale-[1.02] shadow-lg shadow-[#6d28d9]/30 disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSaving ? (
|
||||
'Saving...'
|
||||
) : (
|
||||
<>
|
||||
<Save size={16} />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveStatus === 'success' && (
|
||||
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl text-green-500 font-medium backdrop-blur-sm">
|
||||
Settings saved successfully!
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-500 font-medium backdrop-blur-sm">
|
||||
Failed to save settings. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-8">
|
||||
{/* Library Settings */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6">Library Settings</h2>
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||
<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/50 transition-all hover:border-[#6d28d9]/30 hover:bg-muted/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-muted flex items-center justify-center text-[#6d28d9] border border-border/30">
|
||||
{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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Display Settings */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6">Display Settings</h2>
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50 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-xl text-sm font-bold transition-all ${
|
||||
settings.itemsPerPage === option
|
||||
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
|
||||
}`}
|
||||
>
|
||||
{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-xl text-sm font-bold transition-all ${
|
||||
settings.defaultView === 'grid'
|
||||
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
|
||||
}`}
|
||||
>
|
||||
<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-xl text-sm font-bold transition-all ${
|
||||
settings.defaultView === 'list'
|
||||
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
|
||||
}`}
|
||||
>
|
||||
<List size={18} />
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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-xl text-sm font-bold transition-all ${
|
||||
settings.theme === theme
|
||||
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Content Settings */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6">Content Settings</h2>
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50 space-y-4">
|
||||
{/* Show adult content */}
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-background border border-border/50 hover:border-[#6d28d9]/30 transition-all">
|
||||
<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>
|
||||
</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/50 hover:border-[#6d28d9]/30 transition-all">
|
||||
<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>
|
||||
<Switch
|
||||
id="autoPlay"
|
||||
checked={settings.autoPlayTrailers}
|
||||
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Language Settings */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6">Language</h2>
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||
<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-xl text-sm font-bold transition-all ${
|
||||
settings.language === option.value
|
||||
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Page Settings */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6">Page Settings</h2>
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50 space-y-6">
|
||||
{/* Page Title */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Type size={18} className="text-[#6d28d9]" />
|
||||
<Label className="text-sm font-black text-foreground">Custom Page Title</Label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={pageTitle}
|
||||
onChange={(e) => setPageTitle(e.target.value)}
|
||||
placeholder="Leave empty for default title"
|
||||
className="w-full px-4 py-3 rounded-xl bg-background border border-border/50 text-foreground placeholder:text-muted-foreground/50 focus:border-[#6d28d9] focus:outline-none transition-all"
|
||||
/>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-2">
|
||||
Custom title for your page. Leave empty to use the default title.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Favicon Upload */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Image size={18} className="text-[#6d28d9]" />
|
||||
<Label className="text-sm font-black text-foreground">Favicon / Icon</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{faviconPreview && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={faviconPreview}
|
||||
alt="Favicon preview"
|
||||
className="w-16 h-16 rounded-xl object-cover border border-border/50"
|
||||
/>
|
||||
<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"
|
||||
className="inline-flex items-center gap-2 px-4 py-3 rounded-xl bg-background border border-border/50 text-foreground hover:bg-muted hover:border-[#6d28d9]/30 cursor-pointer transition-all"
|
||||
>
|
||||
<Image size={16} />
|
||||
{favicon ? 'Change favicon' : 'Upload favicon'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-2">
|
||||
Upload a custom favicon or icon. The image will be converted to Base64 format.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Colors */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Palette size={18} className="text-[#6d28d9]" />
|
||||
<Label className="text-sm font-black text-foreground">Custom Colors</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[
|
||||
{ key: 'primary', label: 'Primary Color' },
|
||||
{ key: 'secondary', label: 'Secondary Color' },
|
||||
{ key: 'background', label: 'Background Color' },
|
||||
{ key: 'surface', label: 'Surface Color' },
|
||||
{ key: 'text', label: 'Text Color' },
|
||||
{ key: 'muted', label: 'Muted Text Color' },
|
||||
{ key: 'border', label: 'Border Color' },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-3 p-3 rounded-xl bg-background border border-border/50">
|
||||
<input
|
||||
type="color"
|
||||
value={customColors[key as keyof CustomColors] || '#6d28d9'}
|
||||
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
|
||||
className="w-10 h-10 rounded-lg cursor-pointer border-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs font-black text-foreground">{label}</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={customColors[key as keyof CustomColors] || ''}
|
||||
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
|
||||
placeholder="#6d28d9"
|
||||
className="w-full mt-1 px-2 py-1 rounded-lg bg-muted border border-border/30 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-[#6d28d9] focus:outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-2">
|
||||
Leave color fields empty to use the default theme colors.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
src/components/Sidebar.tsx
Normal file
212
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Film,
|
||||
Tv,
|
||||
Gamepad2,
|
||||
Users,
|
||||
Tag,
|
||||
Music as MusicIcon,
|
||||
Monitor,
|
||||
Eye,
|
||||
Dumbbell,
|
||||
Calendar,
|
||||
FolderKanban,
|
||||
Settings,
|
||||
Sun,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
X,
|
||||
Plus
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { MediaCategory } from '@/types';
|
||||
import { CATEGORY_PATHS } from '@/constants';
|
||||
|
||||
interface SidebarProps {
|
||||
enabledCategories: MediaCategory[];
|
||||
onToggleCategory: (category: MediaCategory) => void;
|
||||
pageTitle?: string;
|
||||
}
|
||||
|
||||
export default function Sidebar({ enabledCategories, onToggleCategory, pageTitle }: SidebarProps) {
|
||||
const [isMediaExpanded, setIsMediaExpanded] = useState(true);
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
const categoryIcons: Record<string, any> = {
|
||||
'Audio Book': <BookOpen size={18} />,
|
||||
'Book': <BookOpen size={18} />,
|
||||
'Movie': <Film size={18} />,
|
||||
'Music': <MusicIcon size={18} />,
|
||||
'Show': <Tv size={18} />,
|
||||
'Video Game': <Gamepad2 size={18} />,
|
||||
'Consoles': <Monitor size={18} />,
|
||||
'Adult': <Eye size={18} />,
|
||||
'Groups': <Users size={18} />,
|
||||
'People': <Users size={18} />,
|
||||
'Genres': <Tag size={18} />
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ icon: <LayoutDashboard size={18} />, label: 'Dashboard', path: '/' },
|
||||
{
|
||||
icon: <Film size={18} />,
|
||||
label: 'Media',
|
||||
hasSubmenu: true,
|
||||
submenu: [
|
||||
...(enabledCategories.includes('Anime') ? [{ label: 'Anime', path: '/anime' }] : []),
|
||||
...(enabledCategories.includes('Books') ? [{ label: 'Book', path: '/books' }] : []),
|
||||
...(enabledCategories.includes('Movies') ? [{ label: 'Movie', path: '/movies' }] : []),
|
||||
...(enabledCategories.includes('Music') ? [{ label: 'Music', path: '/music' }] : []),
|
||||
...(enabledCategories.includes('TV Series') ? [{ label: 'Show', path: '/tv-series' }] : []),
|
||||
...(enabledCategories.includes('Games') ? [{ label: 'Video Game', path: '/games' }] : []),
|
||||
...(enabledCategories.includes('Consoles') ? [{ label: 'Consoles', path: '/consoles' }] : []),
|
||||
...(enabledCategories.includes('Adult') ? [{ label: 'Adult', path: '/adult' }] : []),
|
||||
{ label: 'People', path: '/cast' },
|
||||
{ label: 'Genres', path: '/browse' }
|
||||
].filter(Boolean)
|
||||
},
|
||||
//{ icon: <Dumbbell size={18} />, label: 'Fitness', path: '/fitness' },
|
||||
//{ icon: <Calendar size={18} />, label: 'Calendar', path: '/calendar' },
|
||||
//{ icon: <FolderKanban size={18} />, label: 'Collections', path: '/collections' },
|
||||
{ icon: <Plus size={18} />, label: 'Add Media', path: '/add' },
|
||||
{ icon: <Settings size={18} />, label: 'Settings', path: '/settings' },
|
||||
{ icon: <FolderKanban size={18} />, label: 'Import', path: '/import' }
|
||||
];
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
console.log('Logout clicked');
|
||||
};
|
||||
|
||||
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-72 bg-card border-r border-border/50 z-50 flex flex-col transition-transform duration-300',
|
||||
isMobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-xl flex items-center justify-center shadow-lg shadow-[#6d28d9]/30">
|
||||
<div className="w-5 h-5 rounded-full bg-white" />
|
||||
</div>
|
||||
<span className="text-xl font-black text-foreground">{pageTitle || 'omnyx'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<div key={item.label}>
|
||||
{item.hasSubmenu ? (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsMediaExpanded(!isMediaExpanded)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="font-bold text-foreground">{item.label}</span>
|
||||
</div>
|
||||
{isMediaExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
{isMediaExpanded && item.submenu && (
|
||||
<div className="ml-4 mt-1 space-y-1">
|
||||
{item.submenu.map((subItem) => (
|
||||
<NavLink
|
||||
key={subItem.label}
|
||||
to={subItem.path}
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
)
|
||||
}
|
||||
>
|
||||
{categoryIcons[subItem.label]}
|
||||
{subItem.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NavLink
|
||||
to={item.path}
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl transition-colors group',
|
||||
isActive
|
||||
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className={cn('transition-colors', location.pathname === item.path ? 'text-[#6d28d9]' : 'group-hover:text-foreground')}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="font-bold">{item.label}</span>
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="p-4 border-t border-border/50 space-y-2">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<Sun size={18} />
|
||||
<span className="font-medium">{theme === 'dark' ? 'Light theme' : 'Dark theme'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span className="font-medium">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
src/components/routes/CastDetailRoute.tsx
Normal file
46
src/components/routes/CastDetailRoute.tsx
Normal file
@@ -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={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
src/components/routes/CategoryBrowseRoute.tsx
Normal file
49
src/components/routes/CategoryBrowseRoute.tsx
Normal file
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
src/components/routes/MediaDetailRoute.tsx
Normal file
50
src/components/routes/MediaDetailRoute.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
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}
|
||||
onPersonClick={onPersonClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/components/ui/loading.tsx
Normal file
14
src/components/ui/loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
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" />
|
||||
<p className="text-lg font-bold">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/constants.ts
Normal file
49
src/constants.ts
Normal file
@@ -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,
|
||||
};
|
||||
74
src/contexts/ThemeContext.tsx
Normal file
74
src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
effectiveTheme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme;
|
||||
return stored || 'system';
|
||||
});
|
||||
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
const applyTheme = () => {
|
||||
let resolved: 'light' | 'dark';
|
||||
|
||||
if (theme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
} else {
|
||||
resolved = theme;
|
||||
}
|
||||
|
||||
setEffectiveTheme(resolved);
|
||||
|
||||
if (resolved === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
// Listen for system theme changes when in system mode
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
if (theme === 'system') {
|
||||
applyTheme();
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, effectiveTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
10
src/data.ts
10
src/data.ts
@@ -127,7 +127,14 @@ export const MOCK_MEDIA: Media[] = [
|
||||
studios: ['Example Studio'],
|
||||
}
|
||||
];
|
||||
|
||||
export const DETAIL_MEDIA: Media = {
|
||||
id: '',
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: 'Movies'
|
||||
}
|
||||
/*
|
||||
export const DETAIL_MEDIA: Media = {
|
||||
id: 'mob-psycho',
|
||||
title: 'Mob Psycho 100',
|
||||
@@ -220,3 +227,4 @@ export const DETAIL_MEDIA: Media = {
|
||||
},
|
||||
]
|
||||
};
|
||||
*/
|
||||
@@ -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,60 @@
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
/* Custom gradient colors */
|
||||
--gradient-purple: linear-gradient(135deg, #6d28d9 0%, #8b5cf6 50%, #a78bfa 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%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--background: oklch(0.12 0.01 264);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card: oklch(0.18 0.02 264);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.18 0.02 264);
|
||||
--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: oklch(0.269 0.01 264);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted: oklch(0.25 0.01 264);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent: oklch(0.269 0.01 264);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--border: oklch(0.985 0 0 / 15%);
|
||||
--input: oklch(0.985 0 0 / 20%);
|
||||
--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: oklch(0.18 0.02 264);
|
||||
--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-border: oklch(0.985 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
|
||||
/* Custom gradient colors for dark mode - more vibrant */
|
||||
--gradient-purple: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 50%, #a78bfa 100%);
|
||||
--gradient-blue: linear-gradient(135deg, #2563eb 0%, #3b82f6 50%, #60a5fa 100%);
|
||||
--gradient-green: linear-gradient(135deg, #16a34a 0%, #22c55e 50%, #4ade80 100%);
|
||||
--gradient-yellow: linear-gradient(135deg, #ca8a04 0%, #eab308 50%, #facc15 100%);
|
||||
--gradient-pink: linear-gradient(135deg, #db2777 0%, #ec4899 50%, #f472b6 100%);
|
||||
--gradient-orange: linear-gradient(135deg, #ea580c 0%, #f97316 50%, #fb923c 100%);
|
||||
--gradient-cyan: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
|
||||
|
||||
/* Background gradients for dark mode */
|
||||
--bg-gradient-subtle: radial-gradient(circle at top right, rgba(124, 58, 237, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at bottom left, rgba(139, 92, 246, 0.1) 0%, transparent 50%);
|
||||
--bg-gradient-mesh: linear-gradient(135deg, rgba(124, 58, 237, 0.05) 0%, rgba(139, 92, 246, 0.05) 50%, rgba(167, 139, 250, 0.05) 100%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -138,4 +158,41 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
453
src/lib/__tests__/jellyfinImporter.test.ts
Normal file
453
src/lib/__tests__/jellyfinImporter.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
364
src/lib/__tests__/playniteImporter.test.ts
Normal file
364
src/lib/__tests__/playniteImporter.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
431
src/lib/__tests__/stashappImporter.test.ts
Normal file
431
src/lib/__tests__/stashappImporter.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
524
src/lib/__tests__/xbvrImporter.test.ts
Normal file
524
src/lib/__tests__/xbvrImporter.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
163
src/lib/api/castApi.ts
Normal file
163
src/lib/api/castApi.ts
Normal file
@@ -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 [];
|
||||
}
|
||||
}
|
||||
201
src/lib/api/converters.ts
Normal file
201
src/lib/api/converters.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
105
src/lib/api/mediaApi.ts
Normal file
105
src/lib/api/mediaApi.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
83
src/lib/api/settingsApi.ts
Normal file
83
src/lib/api/settingsApi.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
223
src/lib/api/types.ts
Normal file
223
src/lib/api/types.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
// 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> {}
|
||||
|
||||
// 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> {}
|
||||
1456
src/lib/jellyfinImporter.ts
Normal file
1456
src/lib/jellyfinImporter.ts
Normal file
File diff suppressed because it is too large
Load Diff
515
src/lib/playniteImporter.ts
Normal file
515
src/lib/playniteImporter.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* 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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!coverResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = await coverResponse.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
|
||||
// Determine MIME type from blob
|
||||
const mimeType = blob.type || 'image/jpeg';
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 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 },
|
||||
* (msg) => console.log(msg),
|
||||
* (prog) => updateUI(prog)
|
||||
* );
|
||||
* console.log(`Imported ${progress.gamesImported} games`);
|
||||
* ```
|
||||
*/
|
||||
export async function importFromPlaynite(
|
||||
config: PlayniteConfig,
|
||||
logCallback: LogCallback,
|
||||
progressCallback: ProgressCallback
|
||||
): Promise<ImportProgress> {
|
||||
const progress: ImportProgress = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Connecting to Playnite API...',
|
||||
gamesImported: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
const baseUrl = `http://${config.ip}:${config.port || 19821}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiToken}`
|
||||
};
|
||||
|
||||
try {
|
||||
logCallback('Starting Playnite import...');
|
||||
|
||||
// Step 0: Fetch existing media to check for duplicates and enable updates
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const existingMedia = new Map(
|
||||
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
|
||||
);
|
||||
logCallback(`Found ${existingMedia.size} existing games in database`);
|
||||
|
||||
// Step 1: Fetch games from Playnite
|
||||
logCallback(`Fetching games from ${baseUrl}/api/games...`);
|
||||
progressCallback({ message: 'Fetching games from Playnite...' });
|
||||
|
||||
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=5000`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!gamesResponse.ok) {
|
||||
throw new Error(`Failed to connect to Playnite API: ${gamesResponse.statusText}`);
|
||||
}
|
||||
|
||||
const gamesData: PlayniteGamesResponse = await gamesResponse.json();
|
||||
const games = gamesData.games || [];
|
||||
logCallback(`Found ${games.length} games in Playnite`);
|
||||
|
||||
// Step 2: Fetch detailed information for each game
|
||||
progressCallback({
|
||||
total: games.length,
|
||||
current: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Fetching game details...'
|
||||
});
|
||||
|
||||
const detailedGames: PlayniteGame[] = [];
|
||||
for (let i = 0; i < games.length; i++) {
|
||||
const game = games[i];
|
||||
try {
|
||||
logCallback(`Fetching details for: ${game.name} (${i + 1}/${games.length})`);
|
||||
|
||||
const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
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 {
|
||||
// If detail fetch fails, use basic game info
|
||||
detailedGames.push(game);
|
||||
logCallback(`⊘ Using basic info for: ${game.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// If detail fetch fails, use basic game info
|
||||
detailedGames.push(game);
|
||||
logCallback(`⊘ Using basic info for: ${game.name} (detail fetch failed)`);
|
||||
}
|
||||
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
message: `Fetching game details... ${Math.round(((i + 1) / games.length) * 100)}%`
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Import games
|
||||
progressCallback({
|
||||
total: detailedGames.length,
|
||||
current: 0,
|
||||
stage: 'importing',
|
||||
message: 'Importing games...'
|
||||
});
|
||||
|
||||
let gamesImported = 0;
|
||||
const gameErrors: string[] = [];
|
||||
|
||||
for (let i = 0; i < detailedGames.length; i++) {
|
||||
const game = detailedGames[i];
|
||||
|
||||
const existingGame = existingMedia.get(game.name);
|
||||
const isUpdate = existingGame !== undefined;
|
||||
|
||||
// Skip if updateExisting is false and item already exists
|
||||
if (!config.updateExisting && isUpdate) {
|
||||
logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`);
|
||||
progressCallback({
|
||||
current: i + 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse release date
|
||||
let year = new Date().getFullYear();
|
||||
let releaseDate = null;
|
||||
if (game.releaseDate) {
|
||||
const dateMatch = game.releaseDate.match(/^(\d{4})/);
|
||||
if (dateMatch) {
|
||||
year = parseInt(dateMatch[1]);
|
||||
}
|
||||
releaseDate = game.releaseDate;
|
||||
}
|
||||
|
||||
// Convert playtime from seconds to minutes
|
||||
const runtime = game.playtime ? Math.round(game.playtime / 60) : null;
|
||||
|
||||
// Calculate combined rating from all available scores (0-100 to 0-5)
|
||||
let rating = null;
|
||||
const scores = [];
|
||||
if (game.userScore !== undefined && game.userScore !== null) scores.push(game.userScore);
|
||||
if (game.communityScore !== undefined && game.communityScore !== null) scores.push(game.communityScore);
|
||||
if (game.criticScore !== undefined && game.criticScore !== null) scores.push(game.criticScore);
|
||||
if (scores.length > 0) {
|
||||
const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
||||
rating = avgScore / 20;
|
||||
}
|
||||
|
||||
// Staff is for actors/performers only - leave empty for games
|
||||
const staff: Staff[] = [];
|
||||
// Determine type based on genres/features
|
||||
let type = 'Game';
|
||||
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
|
||||
// type = 'Movie';
|
||||
// }
|
||||
|
||||
const mediaData = {
|
||||
type: 'Game',
|
||||
title: game.name,
|
||||
sortingName: game.sortingName || null,
|
||||
description: game.description || null,
|
||||
notes: game.notes || null,
|
||||
genres: game.genres || [],
|
||||
categories: game.categories || [],
|
||||
tags: game.tags || [],
|
||||
features: game.features || [],
|
||||
platforms: game.platforms || [],
|
||||
developers: game.developers || [],
|
||||
publishers: game.publishers || [],
|
||||
series: game.series ? [game.series] : [],
|
||||
ageRatings: game.ageRatings || [],
|
||||
regions: game.regions || [],
|
||||
source: SOURCE_CATEGORY_MAPPING['playnite']?.includes('Games') ? (game.source || 'playnite') : null,
|
||||
gameId: game.id,
|
||||
pluginId: null,
|
||||
completionStatus: game.completionStatus || 'Not Played',
|
||||
releaseDate: releaseDate,
|
||||
isInstalled: game.isInstalled || false,
|
||||
installDirectory: null,
|
||||
installSize: null,
|
||||
hidden: game.hidden || false,
|
||||
favorite: game.favorite || false,
|
||||
playtime: game.playtime || 0,
|
||||
playCount: game.playCount || 0,
|
||||
lastActivity: game.lastActivity || null,
|
||||
added: game.added || null,
|
||||
modified: null,
|
||||
communityScore: game.communityScore || null,
|
||||
criticScore: game.criticScore || null,
|
||||
userScore: game.userScore || null,
|
||||
hasIcon: false,
|
||||
hasCover: false,
|
||||
hasBackground: false,
|
||||
version: game.version || null,
|
||||
links: game.links || [],
|
||||
achievements: [],
|
||||
year: year.toString(),
|
||||
poster: game.coverBase64 || null,
|
||||
banner: game.backgroundBase64 || null,
|
||||
icon: game.iconBase64 || null,
|
||||
rating: rating,
|
||||
category: 'Game',
|
||||
status: game.completionStatus === 'Completed' ? 'completed' :
|
||||
game.completionStatus === 'Playing' ? 'ongoing' :
|
||||
game.completionStatus === 'Abandoned' ? 'dropped' : 'planned',
|
||||
aspectRatio: '2/3',
|
||||
runtime: runtime,
|
||||
director: null,
|
||||
writer: null
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isUpdate) {
|
||||
response = await fetch(`${BASE_URL}/api/media/${(existingGame as any).id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mediaData)
|
||||
});
|
||||
} else {
|
||||
response = await fetch(`${BASE_URL}/api/media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mediaData)
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
gamesImported++;
|
||||
logCallback(`✓ ${isUpdate ? 'Updated' : 'Imported'} game: ${game.name}`);
|
||||
} else {
|
||||
const error = await response.text();
|
||||
gameErrors.push(`Failed to ${isUpdate ? 'update' : 'import'} game ${game.name}: ${error}`);
|
||||
logCallback(`✗ Failed to ${isUpdate ? 'update' : 'import'} game: ${game.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
gameErrors.push(`Error importing game ${game.name}: ${error}`);
|
||||
logCallback(`✗ Error importing game: ${game.name}`);
|
||||
}
|
||||
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
gamesImported,
|
||||
errors: gameErrors
|
||||
});
|
||||
}
|
||||
|
||||
logCallback(`Imported ${gamesImported}/${games.length} games`);
|
||||
|
||||
// Complete
|
||||
progress.stage = 'complete';
|
||||
progress.message = 'Import complete!';
|
||||
progress.current = games.length;
|
||||
progress.total = games.length;
|
||||
progress.gamesImported = gamesImported;
|
||||
progress.errors = gameErrors;
|
||||
logCallback('Import completed successfully!');
|
||||
|
||||
return progress;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
progress.stage = 'error';
|
||||
progress.message = `Import failed: ${errorMessage}`;
|
||||
progress.errors = [...progress.errors, errorMessage];
|
||||
logCallback(`✗ Import failed: ${errorMessage}`);
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
884
src/lib/stashappImporter.ts
Normal file
884
src/lib/stashappImporter.ts
Normal file
@@ -0,0 +1,884 @@
|
||||
/**
|
||||
* 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 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;
|
||||
/** 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;
|
||||
stream: string;
|
||||
webp: string;
|
||||
vtt: string;
|
||||
sprite: string;
|
||||
funscript: string;
|
||||
caption: string;
|
||||
};
|
||||
/** Array of file information */
|
||||
files: Array<{
|
||||
size: number;
|
||||
duration: number;
|
||||
video_codec: string;
|
||||
audio_codec: string;
|
||||
width: number;
|
||||
height: number;
|
||||
path: string;
|
||||
}>;
|
||||
/** Array of performers in the scene */
|
||||
performers: Array<{
|
||||
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;
|
||||
details: string;
|
||||
death_date: string;
|
||||
hair_color: string;
|
||||
weight: number;
|
||||
image_path: string;
|
||||
scene_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 StashAPPScenesResponse {
|
||||
data: {
|
||||
findScenes: {
|
||||
scenes: StashAPPScene[];
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface StashAPPPerformersResponse {
|
||||
data: {
|
||||
findPerformers: {
|
||||
performers: StashAPPPerformer[];
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
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,
|
||||
progressCallback: ProgressCallback
|
||||
): Promise<ImportProgress> {
|
||||
const progress: ImportProgress = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Connecting to StashAPP...',
|
||||
videosImported: 0,
|
||||
actorsImported: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
logCallback('Starting StashAPP actor update...');
|
||||
|
||||
// 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<string, Staff>(
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
// Fetch all performers from StashAPP
|
||||
logCallback(`Fetching performers from StashAPP...`);
|
||||
progressCallback({ message: 'Fetching performers from StashAPP...' });
|
||||
|
||||
const graphqlQuery = {
|
||||
query: `
|
||||
query FindPerformers($filter: FindFilterType) {
|
||||
findPerformers(filter: $filter) {
|
||||
count
|
||||
performers {
|
||||
id
|
||||
name
|
||||
disambiguation
|
||||
url
|
||||
gender
|
||||
birthdate
|
||||
ethnicity
|
||||
country
|
||||
eye_color
|
||||
height_cm
|
||||
measurements
|
||||
fake_tits
|
||||
career_length
|
||||
tattoos
|
||||
piercings
|
||||
alias_list
|
||||
favorite
|
||||
ignore_auto_tag
|
||||
created_at
|
||||
updated_at
|
||||
details
|
||||
death_date
|
||||
hair_color
|
||||
weight
|
||||
image_path
|
||||
scene_count
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
filter: {
|
||||
per_page: 1000
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (config.apiKey) {
|
||||
headers['ApiKey'] = config.apiKey;
|
||||
}
|
||||
|
||||
const performersResponse = await fetch(`${config.url}/graphql`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(graphqlQuery)
|
||||
});
|
||||
|
||||
if (!performersResponse.ok) {
|
||||
throw new Error(`Failed to connect to StashAPP: ${performersResponse.statusText}`);
|
||||
}
|
||||
|
||||
const performersData: StashAPPPerformersResponse = await performersResponse.json();
|
||||
const performers = performersData.data?.findPerformers?.performers || [];
|
||||
logCallback(`Found ${performers.length} performers in StashAPP`);
|
||||
|
||||
progressCallback({
|
||||
total: performers.length,
|
||||
stage: 'importing',
|
||||
message: 'Updating actors...'
|
||||
});
|
||||
|
||||
let actorsUpdated = 0;
|
||||
let actorsCreated = 0;
|
||||
const actorErrors: string[] = [];
|
||||
|
||||
for (let i = 0; i < performers.length; i++) {
|
||||
const performer = performers[i];
|
||||
const existingActor: Staff | undefined = existingActors.get(performer.name);
|
||||
|
||||
try {
|
||||
if (existingActor) {
|
||||
// Update existing actor
|
||||
const updateData: Partial<Staff> = {
|
||||
name: performer.name,
|
||||
};
|
||||
|
||||
// Update photo if available and different
|
||||
if (performer.image_path && performer.image_path !== existingActor.photo) {
|
||||
updateData.photo = performer.image_path;
|
||||
}
|
||||
|
||||
// Update bio with details if available
|
||||
if (performer.details) {
|
||||
updateData.bio = performer.details;
|
||||
} else if (performer.career_length) {
|
||||
updateData.bio = performer.career_length;
|
||||
}
|
||||
|
||||
// Update birth date if available
|
||||
if (performer.birthdate) {
|
||||
updateData.birthDate = performer.birthdate;
|
||||
}
|
||||
|
||||
// Update birth place if available
|
||||
if (performer.country) {
|
||||
updateData.birthPlace = performer.country;
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${existingActor.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
actorsUpdated++;
|
||||
logCallback(`✓ Updated actor: ${performer.name}`);
|
||||
} else {
|
||||
const error = await response.text();
|
||||
actorErrors.push(`Failed to update actor ${performer.name}: ${error}`);
|
||||
logCallback(`✗ Failed to update actor: ${performer.name}`);
|
||||
}
|
||||
} else {
|
||||
// Create new actor
|
||||
const response = await fetch(`${BASE_URL}/api/cast/adult`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: performer.name,
|
||||
photo: performer.image_path || null,
|
||||
bio: performer.details || performer.career_length || null,
|
||||
birthDate: performer.birthdate || null,
|
||||
birthPlace: performer.country || null,
|
||||
occupations: ['Actor'],
|
||||
adult_specifics: {
|
||||
height: performer.height_cm ? performer.height_cm.toString() : null,
|
||||
weight: performer.weight ? performer.weight.toString() : null,
|
||||
hair_color: performer.hair_color || null,
|
||||
eye_color: performer.eye_color || null,
|
||||
ethnicity: performer.ethnicity || null,
|
||||
tattoos: performer.tattoos || null,
|
||||
piercings: performer.piercings || null,
|
||||
measurements: performer.measurements || null
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
actorsCreated++;
|
||||
logCallback(`✓ Created new Adult actor: ${performer.name}`);
|
||||
} else {
|
||||
const error = await response.text();
|
||||
actorErrors.push(`Failed to create actor ${performer.name}: ${error}`);
|
||||
logCallback(`✗ Failed to create actor: ${performer.name}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
actorErrors.push(`Error processing actor ${performer.name}: ${error}`);
|
||||
logCallback(`✗ Error processing actor: ${performer.name}`);
|
||||
}
|
||||
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
actorsImported: actorsCreated,
|
||||
errors: actorErrors
|
||||
});
|
||||
}
|
||||
|
||||
logCallback(`Updated ${actorsUpdated} existing actors, created ${actorsCreated} new actors`);
|
||||
|
||||
// Complete
|
||||
progress.stage = 'complete';
|
||||
progress.message = 'Actor update complete!';
|
||||
progress.current = performers.length;
|
||||
progress.total = performers.length;
|
||||
progress.actorsImported = actorsCreated;
|
||||
progress.errors = actorErrors;
|
||||
logCallback('Actor update completed successfully!');
|
||||
|
||||
return progress;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
progress.stage = 'error';
|
||||
progress.message = `Actor update failed: ${errorMessage}`;
|
||||
progress.errors = [...progress.errors, errorMessage];
|
||||
logCallback(`✗ Actor update failed: ${errorMessage}`);
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
progressCallback: ProgressCallback
|
||||
): Promise<ImportProgress> {
|
||||
const progress: ImportProgress = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Connecting to StashAPP API...',
|
||||
videosImported: 0,
|
||||
actorsImported: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
logCallback('Starting StashAPP import...');
|
||||
|
||||
// Step 0: Fetch existing media and cast to check for duplicates
|
||||
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: Media) => m.title) || []
|
||||
);
|
||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingActors = new Map<string, Staff>(
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
// Step 1: Fetch scenes from StashAPP
|
||||
logCallback(`Fetching scenes from StashAPP...`);
|
||||
progressCallback({ message: 'Fetching scenes from StashAPP...' });
|
||||
|
||||
const graphqlQuery = {
|
||||
query: `
|
||||
query FindScenes($filter: FindFilterType) {
|
||||
findScenes(filter: $filter) {
|
||||
scenes {
|
||||
id
|
||||
title
|
||||
details
|
||||
url
|
||||
date
|
||||
rating100
|
||||
organized
|
||||
o_counter
|
||||
created_at
|
||||
updated_at
|
||||
paths {
|
||||
screenshot
|
||||
preview
|
||||
stream
|
||||
webp
|
||||
vtt
|
||||
sprite
|
||||
funscript
|
||||
caption
|
||||
}
|
||||
files {
|
||||
size
|
||||
duration
|
||||
video_codec
|
||||
audio_codec
|
||||
width
|
||||
height
|
||||
path
|
||||
}
|
||||
performers {
|
||||
id
|
||||
name
|
||||
disambiguation
|
||||
url
|
||||
gender
|
||||
birthdate
|
||||
ethnicity
|
||||
country
|
||||
eye_color
|
||||
height_cm
|
||||
measurements
|
||||
fake_tits
|
||||
career_length
|
||||
tattoos
|
||||
piercings
|
||||
alias_list
|
||||
favorite
|
||||
ignore_auto_tag
|
||||
created_at
|
||||
updated_at
|
||||
details
|
||||
death_date
|
||||
hair_color
|
||||
weight
|
||||
image_path
|
||||
scene_count
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
filter: {
|
||||
per_page: 20000,
|
||||
sort: "date",
|
||||
direction: "DESC"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (config.apiKey) {
|
||||
headers['ApiKey'] = config.apiKey;
|
||||
}
|
||||
|
||||
const scenesResponse = await fetch(`${config.url}/graphql`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(graphqlQuery)
|
||||
});
|
||||
|
||||
if (!scenesResponse.ok) {
|
||||
throw new Error(`Failed to connect to StashAPP: ${scenesResponse.statusText}`);
|
||||
}
|
||||
|
||||
const scenesData: StashAPPScenesResponse = await scenesResponse.json();
|
||||
const scenes = scenesData.data?.findScenes?.scenes || [];
|
||||
logCallback(`Found ${scenes.length} scenes in StashAPP`);
|
||||
|
||||
// Step 2: Extract unique performers
|
||||
const performerSet = new Map<string, StashAPPScenePerformer>();
|
||||
scenes.forEach(scene => {
|
||||
scene.performers.forEach(performer => {
|
||||
if (!performerSet.has(performer.id)) {
|
||||
performerSet.set(performer.id, performer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const uniquePerformers = Array.from(performerSet.values());
|
||||
logCallback(`Found ${uniquePerformers.length} unique performers across all scenes`);
|
||||
|
||||
// Step 3: Import performers first
|
||||
progressCallback({
|
||||
total: uniquePerformers.length + scenes.length,
|
||||
current: 0,
|
||||
message: 'Importing performers...'
|
||||
});
|
||||
|
||||
let performersImported = 0;
|
||||
const performerErrors: string[] = [];
|
||||
|
||||
for (let i = 0; i < uniquePerformers.length; i++) {
|
||||
const performer = uniquePerformers[i];
|
||||
const existingActor: Staff | undefined = existingActors.get(performer.name);
|
||||
|
||||
try {
|
||||
if (existingActor) {
|
||||
// Update existing actor
|
||||
const updateData: Partial<Staff> = {
|
||||
name: performer.name,
|
||||
};
|
||||
|
||||
// Update photo if available and different
|
||||
if (performer.image_path && performer.image_path !== existingActor.photo) {
|
||||
updateData.photo = performer.image_path;
|
||||
}
|
||||
|
||||
// Update bio with details if available
|
||||
if (performer.details) {
|
||||
updateData.bio = performer.details;
|
||||
} else if (performer.career_length) {
|
||||
updateData.bio = performer.career_length;
|
||||
}
|
||||
|
||||
// Update birth date if available
|
||||
if (performer.birthdate) {
|
||||
updateData.birthDate = performer.birthdate;
|
||||
}
|
||||
|
||||
// Update birth place if available
|
||||
if (performer.country) {
|
||||
updateData.birthPlace = performer.country;
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${existingActor.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
performersImported++;
|
||||
logCallback(`✓ Updated performer: ${performer.name}`);
|
||||
} else {
|
||||
const error = await response.text();
|
||||
performerErrors.push(`Failed to update performer ${performer.name}: ${error}`);
|
||||
logCallback(`✗ Failed to update performer: ${performer.name}`);
|
||||
}
|
||||
} else {
|
||||
// Create new actor
|
||||
const response = await fetch(`${BASE_URL}/api/cast/adult`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: performer.name,
|
||||
photo: performer.image_path || null,
|
||||
bio: performer.details || performer.career_length || null,
|
||||
birthDate: performer.birthdate || null,
|
||||
birthPlace: performer.country || null,
|
||||
occupations: ['Actor'],
|
||||
adult_specifics: {
|
||||
height: performer.height_cm ? performer.height_cm.toString() : null,
|
||||
weight: performer.weight ? performer.weight.toString() : null,
|
||||
hair_color: performer.hair_color || null,
|
||||
eye_color: performer.eye_color || null,
|
||||
ethnicity: performer.ethnicity || null,
|
||||
tattoos: performer.tattoos || null,
|
||||
piercings: performer.piercings || null,
|
||||
measurements: performer.measurements || null
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
performersImported++;
|
||||
logCallback(`✓ Imported performer: ${performer.name}`);
|
||||
} else {
|
||||
const error = await response.text();
|
||||
performerErrors.push(`Failed to import performer ${performer.name}: ${error}`);
|
||||
logCallback(`✗ Failed to import performer: ${performer.name}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
performerErrors.push(`Error processing performer ${performer.name}: ${error}`);
|
||||
logCallback(`✗ Error processing performer: ${performer.name}`);
|
||||
}
|
||||
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
actorsImported: performersImported,
|
||||
errors: performerErrors
|
||||
});
|
||||
}
|
||||
|
||||
logCallback(`Processed ${performersImported}/${uniquePerformers.length} performers (imported or updated)`);
|
||||
|
||||
// Step 4: Import scenes
|
||||
progressCallback({
|
||||
current: uniquePerformers.length,
|
||||
message: 'Importing scenes...'
|
||||
});
|
||||
|
||||
let scenesImported = 0;
|
||||
const sceneErrors: string[] = [];
|
||||
|
||||
for (let i = 0; i < scenes.length; i++) {
|
||||
const scene = scenes[i];
|
||||
|
||||
// Check if scene is blacklisted
|
||||
if (config.blacklist && config.blacklist.length > 0) {
|
||||
const isBlacklisted = scene.files && scene.files.some(file =>
|
||||
isPathBlacklisted(file.path, config.blacklist!)
|
||||
);
|
||||
if (isBlacklisted) {
|
||||
logCallback(`⊘ Skipped blacklisted scene: ${scene.title}`);
|
||||
progressCallback({
|
||||
current: uniquePerformers.length + i + 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if (existingTitles.has(scene.title)) {
|
||||
if (!config.updateExisting) {
|
||||
logCallback(`⊘ Skipped duplicate: ${scene.title} (updateExisting is false)`);
|
||||
progressCallback({
|
||||
current: uniquePerformers.length + i + 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
logCallback(`→ Updating existing: ${scene.title}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract performers as staff
|
||||
const staff = scene.performers && Array.isArray(scene.performers)
|
||||
? scene.performers.map(p => ({
|
||||
name: p.name,
|
||||
role: 'Actor',
|
||||
photo: p.image_path || null,
|
||||
characterName: p.name,
|
||||
characterImage: p.image_path || null
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Parse date
|
||||
const year = scene.date ? new Date(scene.date).getFullYear() : new Date().getFullYear();
|
||||
const releaseDate = scene.date || null;
|
||||
|
||||
// Determine aspect ratio from file dimensions
|
||||
let aspectRatio: '2/3' | '16/9' | '1/1' = '16/9';
|
||||
if (scene.files && scene.files.length > 0) {
|
||||
const file = scene.files[0];
|
||||
if (file.width && file.height) {
|
||||
const ratio = file.width / file.height;
|
||||
if (ratio > 1.6) {
|
||||
aspectRatio = '16/9';
|
||||
} else if (ratio < 1.4 && ratio > 0.8) {
|
||||
aspectRatio = '1/1';
|
||||
} else if (ratio < 0.8) {
|
||||
aspectRatio = '2/3';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get duration from files
|
||||
const runtime = scene.files && scene.files.length > 0 ? scene.files[0].duration : null;
|
||||
|
||||
// Convert rating100 to 5-star scale
|
||||
const rating = scene.rating100 ? scene.rating100 / 20 : null;
|
||||
|
||||
const mediaData = {
|
||||
title: scene.title,
|
||||
year: year.toString(),
|
||||
poster: scene.paths?.screenshot || null,
|
||||
banner: null,
|
||||
description: scene.details || null,
|
||||
rating: rating,
|
||||
category: 'Adult',
|
||||
type: 'Movie',
|
||||
status: 'completed',
|
||||
aspectRatio: aspectRatio,
|
||||
runtime: runtime,
|
||||
director: null,
|
||||
writer: null,
|
||||
releaseDate: releaseDate,
|
||||
source: SOURCE_CATEGORY_MAPPING['stashapp']?.includes('Adult') ? 'stashapp' : null,
|
||||
genres: [],
|
||||
tags: [],
|
||||
studios: [],
|
||||
staff: staff
|
||||
};
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mediaData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
scenesImported++;
|
||||
logCallback(`✓ Imported scene: ${scene.title}`);
|
||||
} else {
|
||||
const error = await response.text();
|
||||
sceneErrors.push(`Failed to import scene ${scene.title}: ${error}`);
|
||||
logCallback(`✗ Failed to import scene: ${scene.title}`);
|
||||
}
|
||||
} catch (error) {
|
||||
sceneErrors.push(`Error importing scene ${scene.title}: ${error}`);
|
||||
logCallback(`✗ Error importing scene: ${scene.title}`);
|
||||
}
|
||||
|
||||
progressCallback({
|
||||
current: uniquePerformers.length + i + 1,
|
||||
videosImported: scenesImported,
|
||||
errors: [...performerErrors, ...sceneErrors]
|
||||
});
|
||||
}
|
||||
|
||||
logCallback(`Imported ${scenesImported}/${scenes.length} scenes`);
|
||||
|
||||
// Complete
|
||||
progress.stage = 'complete';
|
||||
progress.message = 'Import complete!';
|
||||
progress.current = uniquePerformers.length + scenes.length;
|
||||
progress.total = uniquePerformers.length + scenes.length;
|
||||
progress.videosImported = scenesImported;
|
||||
progress.actorsImported = performersImported;
|
||||
progress.errors = [...performerErrors, ...sceneErrors];
|
||||
logCallback('Import completed successfully!');
|
||||
|
||||
return progress;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
progress.stage = 'error';
|
||||
progress.message = `Import failed: ${errorMessage}`;
|
||||
progress.errors = [...progress.errors, errorMessage];
|
||||
logCallback(`✗ Import failed: ${errorMessage}`);
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
464
src/lib/xbvrImporter.ts
Normal file
464
src/lib/xbvrImporter.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* 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 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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
progressCallback: ProgressCallback
|
||||
): Promise<ImportProgress> {
|
||||
const progress: ImportProgress = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Connecting to DeoVR API...',
|
||||
videosImported: 0,
|
||||
actorsImported: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
logCallback('Starting DeoVR import...');
|
||||
|
||||
// Step 0: Fetch existing media and cast to check for duplicates
|
||||
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: Media) => m.title) || []
|
||||
);
|
||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||
|
||||
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: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
// Step 1: Fetch scene list from DeoVR API
|
||||
logCallback(`Fetching scene list from ${config.url}/deovr...`);
|
||||
progressCallback({ message: 'Fetching scene list from DeoVR...' });
|
||||
|
||||
const scenesListResponse = await fetch(`${config.url}/deovr`);
|
||||
if (!scenesListResponse.ok) {
|
||||
throw new Error(`Failed to connect to DeoVR API: ${scenesListResponse.statusText}`);
|
||||
}
|
||||
|
||||
const scenesListData: XBVRSceneList = await scenesListResponse.json();
|
||||
logCallback('Received scene list structure');
|
||||
|
||||
// Extract only videos from the 'Recent' scene group
|
||||
const allVideos: XBVRVideo[] = [];
|
||||
if (scenesListData.scenes && Array.isArray(scenesListData.scenes)) {
|
||||
const recentGroup = scenesListData.scenes.find((group) => group.name === 'Recent');
|
||||
if (recentGroup && recentGroup.list && Array.isArray(recentGroup.list)) {
|
||||
allVideos.push(...recentGroup.list);
|
||||
}
|
||||
}
|
||||
|
||||
logCallback(`Found ${allVideos.length} videos in 'Recent' scene group`);
|
||||
|
||||
// Step 2: Fetch details for each video
|
||||
progressCallback({
|
||||
total: allVideos.length,
|
||||
stage: 'importing',
|
||||
message: 'Fetching video details...'
|
||||
});
|
||||
|
||||
const videoDetails: XBVRVideoDetail[] = [];
|
||||
const actorSet = new Map<number, any>();
|
||||
|
||||
for (let i = 0; i < allVideos.length; i++) {
|
||||
const video = allVideos[i];
|
||||
try {
|
||||
logCallback(`Fetching details for video: ${video.title} (${i + 1}/${allVideos.length})`);
|
||||
|
||||
const detailResponse = await fetch(video.video_url);
|
||||
if (!detailResponse.ok) {
|
||||
throw new Error(`Failed to fetch details: ${detailResponse.statusText}`);
|
||||
}
|
||||
|
||||
const detailData: XBVRVideoDetail = await detailResponse.json();
|
||||
videoDetails.push(detailData);
|
||||
|
||||
// Extract actors from video details
|
||||
if (detailData.actors && Array.isArray(detailData.actors)) {
|
||||
detailData.actors.forEach((actor) => {
|
||||
// Skip actors containing 'aka:' anywhere in the name
|
||||
if (actor.name.toLowerCase().includes('aka:')) {
|
||||
return;
|
||||
}
|
||||
// Deduplicate by actor ID
|
||||
if (!actorSet.has(actor.id)) {
|
||||
actorSet.set(actor.id, actor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logCallback(`✓ Fetched details for: ${video.title}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logCallback(`✗ Failed to fetch details for ${video.title}: ${errorMessage}`);
|
||||
progress.errors.push(`Failed to fetch details for ${video.title}: ${errorMessage}`);
|
||||
progressCallback({ errors: progress.errors });
|
||||
}
|
||||
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
message: `Fetching video details... ${Math.round(((i + 1) / allVideos.length) * 100)}%`
|
||||
});
|
||||
}
|
||||
|
||||
const uniqueActors = Array.from(actorSet.values());
|
||||
logCallback(`Found ${uniqueActors.length} unique actors across all videos`);
|
||||
|
||||
// Step 3: Import actors first
|
||||
progressCallback({
|
||||
total: uniqueActors.length + videoDetails.length,
|
||||
current: 0,
|
||||
message: 'Importing actors...'
|
||||
});
|
||||
|
||||
let actorsImported = 0;
|
||||
const actorErrors: string[] = [];
|
||||
|
||||
for (let i = 0; i < uniqueActors.length; i++) {
|
||||
const actor = uniqueActors[i];
|
||||
|
||||
// Skip actors containing 'aka:' anywhere in the name
|
||||
if (actor.name.toLowerCase().includes('aka:')) {
|
||||
logCallback(`⊘ Skipped 'aka:' actor: ${actor.name}`);
|
||||
progressCallback({ current: i + 1 });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingActor = existingActors.get(actor.name);
|
||||
|
||||
try {
|
||||
if (existingActor) {
|
||||
// Update existing actor - XBVR doesn't have photos, so just ensure it exists
|
||||
logCallback(`⊘ Actor already exists: ${actor.name}`);
|
||||
} else {
|
||||
// Create new actor
|
||||
const response = await fetch(`${BASE_URL}/api/cast`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: actor.name,
|
||||
photo: null,
|
||||
bio: null,
|
||||
birthDate: null,
|
||||
birthPlace: null,
|
||||
occupations: ['Actor']
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
actorsImported++;
|
||||
logCallback(`✓ Imported actor: ${actor.name}`);
|
||||
} else {
|
||||
const error = await response.text();
|
||||
actorErrors.push(`Failed to import actor ${actor.name}: ${error}`);
|
||||
logCallback(`✗ Failed to import actor: ${actor.name}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
actorErrors.push(`Error importing actor ${actor.name}: ${error}`);
|
||||
logCallback(`✗ Error importing actor: ${actor.name}`);
|
||||
}
|
||||
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
actorsImported,
|
||||
errors: actorErrors
|
||||
});
|
||||
}
|
||||
|
||||
logCallback(`Imported ${actorsImported}/${uniqueActors.length} actors`);
|
||||
|
||||
// Step 4: Import videos
|
||||
progressCallback({
|
||||
current: uniqueActors.length,
|
||||
message: 'Importing videos...'
|
||||
});
|
||||
|
||||
let videosImported = 0;
|
||||
const videoErrors: string[] = [];
|
||||
|
||||
for (let i = 0; i < videoDetails.length; i++) {
|
||||
const video = videoDetails[i];
|
||||
|
||||
// Skip videos starting with 'aka:'
|
||||
if (video.title.toLowerCase().startsWith('aka:')) {
|
||||
logCallback(`⊘ Skipped 'aka:' video: ${video.title}`);
|
||||
progressCallback({
|
||||
current: uniqueActors.length + i + 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if (existingTitles.has(video.title)) {
|
||||
if (!config.updateExisting) {
|
||||
logCallback(`⊘ Skipped duplicate: ${video.title} (updateExisting is false)`);
|
||||
progressCallback({
|
||||
current: uniqueActors.length + i + 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
logCallback(`→ Updating existing: ${video.title}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract categories/tags
|
||||
const categories = video.categories && Array.isArray(video.categories)
|
||||
? video.categories.map((c) => c.tag?.name).filter(Boolean)
|
||||
: [];
|
||||
|
||||
// Extract actors
|
||||
const staff = video.actors && Array.isArray(video.actors)
|
||||
? video.actors.map((a) => ({
|
||||
name: a.name,
|
||||
role: 'Actor',
|
||||
photo: null,
|
||||
characterName: a.name,
|
||||
characterImage: null
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Convert Unix timestamp to date
|
||||
const releaseDate = video.date ? new Date(video.date * 1000).toISOString().split('T')[0] : null;
|
||||
const year = video.date ? new Date(video.date * 1000).getFullYear() : new Date().getFullYear();
|
||||
|
||||
// Determine aspect ratio based on DeoVR screenType and stereoMode
|
||||
let aspectRatio: '2/3' | '16/9' | '1/1' = '16/9';
|
||||
if (video.screenType === '360' || video.screenType === '360180') {
|
||||
aspectRatio = '1/1'; // VR360 videos are typically square for SBS
|
||||
} else if (video.screenType === '180' || video.screenType === 'dome') {
|
||||
aspectRatio = '16/9'; // VR180 videos are typically 16:9 for SBS
|
||||
} else if (video.stereoMode === 'tb' && (video.screenType === '360' || video.screenType === '180')) {
|
||||
aspectRatio = '1/1'; // Top-bottom format is taller
|
||||
}
|
||||
|
||||
const mediaData = {
|
||||
title: video.title,
|
||||
year: year,
|
||||
poster: video.thumbnailUrl || null,
|
||||
banner: null,
|
||||
description: video.description || null,
|
||||
rating: video.rating_avg || null,
|
||||
category: 'Adult',
|
||||
type: 'Movie',
|
||||
status: 'completed',
|
||||
aspectRatio: aspectRatio,
|
||||
runtime: video.videoLength || null,
|
||||
director: null,
|
||||
writer: null,
|
||||
releaseDate: releaseDate,
|
||||
source: SOURCE_CATEGORY_MAPPING['xbvr']?.includes('Adult') ? 'xbvr' : null,
|
||||
genres: categories,
|
||||
tags: categories,
|
||||
studios: video.paysite?.name ? [video.paysite.name] : [],
|
||||
staff: staff
|
||||
};
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mediaData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
videosImported++;
|
||||
logCallback(`✓ Imported video: ${video.title}`);
|
||||
} else {
|
||||
const error = await response.text();
|
||||
videoErrors.push(`Failed to import video ${video.title}: ${error}`);
|
||||
logCallback(`✗ Failed to import video: ${video.title}`);
|
||||
}
|
||||
} catch (error) {
|
||||
videoErrors.push(`Error importing video ${video.title}: ${error}`);
|
||||
logCallback(`✗ Error importing video: ${video.title}`);
|
||||
}
|
||||
|
||||
progressCallback({
|
||||
current: uniqueActors.length + i + 1,
|
||||
videosImported,
|
||||
errors: [...actorErrors, ...videoErrors]
|
||||
});
|
||||
}
|
||||
|
||||
logCallback(`Imported ${videosImported}/${videoDetails.length} videos`);
|
||||
|
||||
// Complete
|
||||
progress.stage = 'complete';
|
||||
progress.message = 'Import complete!';
|
||||
progress.current = uniqueActors.length + videoDetails.length;
|
||||
progress.total = uniqueActors.length + videoDetails.length;
|
||||
progress.videosImported = videosImported;
|
||||
progress.actorsImported = actorsImported;
|
||||
progress.errors = [...actorErrors, ...videoErrors];
|
||||
logCallback('Import completed successfully!');
|
||||
|
||||
return progress;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
progress.stage = 'error';
|
||||
progress.message = `Import failed: ${errorMessage}`;
|
||||
progress.errors = [...progress.errors, errorMessage];
|
||||
logCallback(`✗ Import failed: ${errorMessage}`);
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
70
src/store/appStore.ts
Normal file
70
src/store/appStore.ts
Normal file
@@ -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
|
||||
}),
|
||||
}));
|
||||
117
src/types.ts
117
src/types.ts
@@ -1,4 +1,4 @@
|
||||
export type MediaCategory = 'Anime' | 'Movies' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games';
|
||||
export type MediaCategory = 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games';
|
||||
|
||||
export interface Media {
|
||||
id: string;
|
||||
@@ -16,30 +16,133 @@ export interface Media {
|
||||
studios?: string[];
|
||||
status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold';
|
||||
episodes?: Episode[];
|
||||
tracks?: Track[];
|
||||
staff?: Staff[];
|
||||
categories?: string[];
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
source?: string;
|
||||
playCount?: number;
|
||||
lastActivity?: string | null;
|
||||
playtime?: number;
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
id: string;
|
||||
number: number;
|
||||
id: number;
|
||||
media_id: number;
|
||||
season: number;
|
||||
episode_number: number;
|
||||
title: string;
|
||||
date: string;
|
||||
duration: string;
|
||||
description: string;
|
||||
air_date: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: number;
|
||||
media_id: number;
|
||||
track_number: number;
|
||||
title: string;
|
||||
duration: number | null;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
cleanname?: string;
|
||||
role: string;
|
||||
photo: string;
|
||||
characterName: string;
|
||||
characterImage: string;
|
||||
characterName?: string;
|
||||
characterImage?: string;
|
||||
mediaId?: string;
|
||||
mediaTitle?: string;
|
||||
bio?: string;
|
||||
birthDate?: string;
|
||||
birthPlace?: string;
|
||||
occupations?: string[];
|
||||
createdAt?: string;
|
||||
updatedAt?: 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;
|
||||
filmography?: CastMediaItem[];
|
||||
media_types?: string[];
|
||||
adult_specifics?: AdultSpecifics;
|
||||
}
|
||||
|
||||
export interface CastMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
poster: string | null;
|
||||
category: string | null;
|
||||
type: string;
|
||||
role: string;
|
||||
characterName?: string | null;
|
||||
}
|
||||
|
||||
export interface AdultSpecifics {
|
||||
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 UserSettings {
|
||||
id?: number;
|
||||
enabledCategories: MediaCategory[];
|
||||
itemsPerPage: number;
|
||||
gridItemSize: number; // 1-10 scale
|
||||
defaultView: 'grid' | 'list';
|
||||
showAdultContent: boolean;
|
||||
autoPlayTrailers: boolean;
|
||||
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'],
|
||||
'stashapp': ['Adult'],
|
||||
'playnite': ['Games'],
|
||||
'manual': ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Adult', 'Consoles', 'Games'],
|
||||
};
|
||||
|
||||
14
src/vite-env.d.ts
vendored
Normal file
14
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_XBVR_URL?: string;
|
||||
readonly VITE_STASHAPP_URL?: string;
|
||||
readonly VITE_STASHAPP_API_KEY?: string;
|
||||
readonly VITE_PLAYNITE_IP?: string;
|
||||
readonly VITE_PLAYNITE_PORT?: string;
|
||||
readonly VITE_PLAYNITE_API_TOKEN?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
26
typedoc.json
Normal file
26
typedoc.json
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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