Compare commits
7 Commits
6250164656
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34bb4a27be | ||
|
|
e5cdd6b383 | ||
|
|
63c5d0a7c0 | ||
|
|
432416cfc5 | ||
|
|
a407b57006 | ||
|
|
b57b22c30b | ||
|
|
a6d153ac1e |
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)
|
||||
@@ -1,6 +1,10 @@
|
||||
# Kyoo - Media Discovery Platform
|
||||

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

|
||||
|
||||
A modern web application for browsing, managing, and discovering media across multiple categories. Omnyx provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR.
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
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": []
|
||||
}
|
||||
|
||||
1155
package-lock.json
generated
1155
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
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",
|
||||
@@ -28,15 +33,20 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
323
src/App.tsx
323
src/App.tsx
@@ -6,8 +6,9 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { LayoutGroup } from 'motion/react';
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
|
||||
import Header from './components/Header';
|
||||
import 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';
|
||||
@@ -15,30 +16,57 @@ import AddMediaView from './components/AddMediaView';
|
||||
import ImporterView from './components/ImporterView';
|
||||
import SettingsView from './components/SettingsView';
|
||||
import Loading from './components/ui/loading';
|
||||
import MediaDetailRoute from './components/routes/MediaDetailRoute';
|
||||
import CastDetailRoute from './components/routes/CastDetailRoute';
|
||||
import CategoryBrowseRoute from './components/routes/CategoryBrowseRoute';
|
||||
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
||||
import { Media, Staff, MediaCategory, UserSettings } from './types';
|
||||
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
|
||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
|
||||
import { CATEGORY_PATHS, PATH_TO_CATEGORY, DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from './constants';
|
||||
import { useAppStore } from './store/appStore';
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
const [activeCategory, setActiveCategory] = useState<MediaCategory>(
|
||||
(searchParams.get('category') as MediaCategory) || 'Anime'
|
||||
);
|
||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
||||
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
||||
|
||||
// Load media from API on component mount (only when not on cast routes)
|
||||
const [apiMedia, setApiMedia] = useState<Media[]>([]);
|
||||
const [mediaLoading, setMediaLoading] = useState(true);
|
||||
// Zustand store
|
||||
const {
|
||||
apiMedia,
|
||||
customMedia,
|
||||
adultMedia,
|
||||
mediaLoading,
|
||||
selectedMedia,
|
||||
selectedPerson,
|
||||
activeCategory,
|
||||
enabledCategories,
|
||||
searchQuery,
|
||||
settings,
|
||||
setApiMedia,
|
||||
setCustomMedia,
|
||||
setAdultMedia,
|
||||
setMediaLoading,
|
||||
setSelectedMedia,
|
||||
setSelectedPerson,
|
||||
setActiveCategory,
|
||||
setEnabledCategories,
|
||||
setSearchQuery,
|
||||
setSettings,
|
||||
} = useAppStore();
|
||||
|
||||
|
||||
// Set category from URL path on mount or location change
|
||||
useEffect(() => {
|
||||
const pathParts = location.pathname.split('/').filter(Boolean);
|
||||
if (pathParts.length === 1 && PATH_TO_CATEGORY[pathParts[0]]) {
|
||||
const category = PATH_TO_CATEGORY[pathParts[0]];
|
||||
if (enabledCategories.includes(category)) {
|
||||
setActiveCategory(category);
|
||||
}
|
||||
}
|
||||
}, [location.pathname, enabledCategories, setActiveCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettingsFromApi = async () => {
|
||||
@@ -49,6 +77,22 @@ function AppContent() {
|
||||
setEnabledCategories(loadedSettings.enabledCategories);
|
||||
// Sync theme with theme context
|
||||
setTheme(loadedSettings.theme);
|
||||
|
||||
// Set custom page title
|
||||
if (loadedSettings.pageTitle) {
|
||||
document.title = loadedSettings.pageTitle;
|
||||
}
|
||||
|
||||
// Set custom favicon
|
||||
if (loadedSettings.favicon) {
|
||||
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
if (!faviconLink) {
|
||||
faviconLink = document.createElement('link');
|
||||
faviconLink.rel = 'icon';
|
||||
document.head.appendChild(faviconLink);
|
||||
}
|
||||
faviconLink.href = loadedSettings.favicon;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings from API:', error);
|
||||
@@ -58,6 +102,22 @@ function AppContent() {
|
||||
loadSettingsFromApi();
|
||||
}, [setTheme]);
|
||||
|
||||
// Apply custom colors when settings change
|
||||
useEffect(() => {
|
||||
if (settings?.customColors) {
|
||||
const root = document.documentElement;
|
||||
const colors = settings.customColors;
|
||||
|
||||
if (colors.primary) root.style.setProperty('--color-primary', colors.primary);
|
||||
if (colors.secondary) root.style.setProperty('--color-secondary', colors.secondary);
|
||||
if (colors.background) root.style.setProperty('--color-background', colors.background);
|
||||
if (colors.surface) root.style.setProperty('--color-surface', colors.surface);
|
||||
if (colors.text) root.style.setProperty('--color-text', colors.text);
|
||||
if (colors.muted) root.style.setProperty('--color-muted', colors.muted);
|
||||
if (colors.border) root.style.setProperty('--color-border', colors.border);
|
||||
}
|
||||
}, [settings?.customColors]);
|
||||
|
||||
const reloadSettings = async () => {
|
||||
try {
|
||||
const loadedSettings = await fetchSettings();
|
||||
@@ -66,6 +126,22 @@ function AppContent() {
|
||||
setEnabledCategories(loadedSettings.enabledCategories);
|
||||
// Sync theme with theme context
|
||||
setTheme(loadedSettings.theme);
|
||||
|
||||
// Set custom page title
|
||||
if (loadedSettings.pageTitle) {
|
||||
document.title = loadedSettings.pageTitle;
|
||||
}
|
||||
|
||||
// Set custom favicon
|
||||
if (loadedSettings.favicon) {
|
||||
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
if (!faviconLink) {
|
||||
faviconLink = document.createElement('link');
|
||||
faviconLink.rel = 'icon';
|
||||
document.head.appendChild(faviconLink);
|
||||
}
|
||||
faviconLink.href = loadedSettings.favicon;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reload settings from API:', error);
|
||||
@@ -92,47 +168,35 @@ function AppContent() {
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggleCategory = async (category: MediaCategory) => {
|
||||
setEnabledCategories(prev => {
|
||||
const isEnabling = !prev.includes(category);
|
||||
const newList = isEnabling
|
||||
? [...prev, category]
|
||||
: prev.filter(c => c !== category);
|
||||
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);
|
||||
// If we disable the current active category, switch to another enabled one
|
||||
if (!isEnabling && activeCategory === category) {
|
||||
const nextCategory = newList.find(c => c !== category) || 'Anime';
|
||||
setActiveCategory(nextCategory as MediaCategory);
|
||||
}
|
||||
|
||||
setEnabledCategories(newList);
|
||||
|
||||
// Save to API
|
||||
const baseSettings = settings || DEFAULT_SETTINGS;
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
enabledCategories: newList,
|
||||
};
|
||||
updateSettings(updatedSettings).then(saved => {
|
||||
if (saved) {
|
||||
setSettings(saved);
|
||||
}
|
||||
|
||||
// Save to API
|
||||
const baseSettings = settings || {
|
||||
enabledCategories: prev,
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid',
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
};
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
enabledCategories: newList,
|
||||
};
|
||||
updateSettings(updatedSettings).then(saved => {
|
||||
if (saved) {
|
||||
setSettings(saved);
|
||||
}
|
||||
});
|
||||
|
||||
return newList;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: MediaCategory) => {
|
||||
setActiveCategory(category);
|
||||
setSearchParams({ category });
|
||||
navigate('/');
|
||||
navigate(`/${CATEGORY_PATHS[category]}`);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
@@ -179,16 +243,7 @@ function AppContent() {
|
||||
};
|
||||
|
||||
const handleGridItemSizeChange = async (size: number) => {
|
||||
const baseSettings = settings || {
|
||||
enabledCategories: enabledCategories,
|
||||
itemsPerPage: 20,
|
||||
gridItemSize: 5,
|
||||
defaultView: 'grid',
|
||||
showAdultContent: false,
|
||||
autoPlayTrailers: false,
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
};
|
||||
const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories };
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
gridItemSize: size,
|
||||
@@ -300,24 +355,28 @@ function AppContent() {
|
||||
params.delete('search');
|
||||
}
|
||||
setSearchParams(params);
|
||||
navigate('/');
|
||||
navigate('/browse');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
<div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9] flex">
|
||||
<Sidebar
|
||||
enabledCategories={enabledCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
transparent={location.pathname.startsWith('/media/') || location.pathname.startsWith('/cast/')}
|
||||
pageTitle={settings?.pageTitle}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<main className="flex-1 lg:ml-72 flex flex-col">
|
||||
<LayoutGroup>
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<DashboardView
|
||||
mediaList={apiMedia.length > 0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))}
|
||||
onMediaClick={handleMediaClick}
|
||||
loading={mediaLoading}
|
||||
/>
|
||||
} />
|
||||
<Route path="/browse" element={
|
||||
<BrowseView
|
||||
mediaList={filteredMedia}
|
||||
onMediaClick={handleMediaClick}
|
||||
@@ -328,10 +387,18 @@ function AppContent() {
|
||||
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
|
||||
selectedMedia={selectedMedia}
|
||||
setSelectedMedia={setSelectedMedia}
|
||||
allMedia={allMedia}
|
||||
onPersonClick={handlePersonClick}
|
||||
/>
|
||||
@@ -344,10 +411,7 @@ function AppContent() {
|
||||
/>
|
||||
} />
|
||||
<Route path="/cast/:id" element={
|
||||
<CastDetailRoute
|
||||
selectedPerson={selectedPerson}
|
||||
setSelectedPerson={setSelectedPerson}
|
||||
/>
|
||||
<CastDetailRoute />
|
||||
} />
|
||||
<Route path="/add" element={
|
||||
<AddMediaView
|
||||
@@ -364,108 +428,29 @@ function AppContent() {
|
||||
} />
|
||||
</Routes>
|
||||
</LayoutGroup>
|
||||
|
||||
{/* 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>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-6 border-t border-border bg-muted/50">
|
||||
<div className="max-w-[1600px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2 text-xl font-black text-muted-foreground">
|
||||
<div className="w-5 h-5 bg-muted rounded-full" />
|
||||
kyoo
|
||||
</div>
|
||||
<div className="flex items-center gap-8 text-sm font-bold text-muted-foreground">
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors">Terms</a>
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors">Privacy</a>
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors">Contact</a>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
© 2026 Kyoo Media Discovery. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for media detail route
|
||||
function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMedia = async () => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const fetchedMedia = await fetchMediaById(id);
|
||||
if (fetchedMedia) {
|
||||
setSelectedMedia(fetchedMedia);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media:', error);
|
||||
navigate('/');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadMedia();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Loading message="Loading media details..." />;
|
||||
if (!selectedMedia) return null;
|
||||
|
||||
return (
|
||||
<DetailView
|
||||
media={selectedMedia}
|
||||
onPersonClick={onPersonClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for cast detail route
|
||||
function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCast = async () => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const castData = await fetchCastById(id);
|
||||
if (castData) {
|
||||
const person = convertApiCastToStaff(castData);
|
||||
setSelectedPerson(person);
|
||||
} else {
|
||||
navigate('/cast');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cast:', error);
|
||||
navigate('/cast');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCast();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Loading message="Loading cast details..." />;
|
||||
if (!selectedPerson) return null;
|
||||
|
||||
return (
|
||||
<CastDetailView
|
||||
person={selectedPerson}
|
||||
relatedMedia={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
|
||||
774
src/api.ts
774
src/api.ts
@@ -1,603 +1,14 @@
|
||||
import { Media, Staff, UserSettings, MediaCategory } from './types';
|
||||
// Re-export all API functions for backward compatibility
|
||||
export * from './lib/api/mediaApi';
|
||||
export * from './lib/api/castApi';
|
||||
export * from './lib/api/settingsApi';
|
||||
export * from './lib/api/converters';
|
||||
export * from './lib/api/types';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
function normalizeUrl(url: string | null): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
// Remove leading slash if present and add base URL
|
||||
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
|
||||
return `${BASE_URL}/${cleanPath}`;
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
// Media Types
|
||||
export interface ApiEpisode {
|
||||
id: number;
|
||||
media_id: number;
|
||||
season: number;
|
||||
episode_number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
air_date: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ApiTrack {
|
||||
id: number;
|
||||
media_id: number;
|
||||
track_number: number;
|
||||
title: string;
|
||||
duration: number | null;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface ApiMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
poster: string | null;
|
||||
banner: string | null;
|
||||
description: string | null;
|
||||
rating: number | null;
|
||||
category: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
aspectRatio: string | null;
|
||||
runtime: number | null;
|
||||
director: string | null;
|
||||
writer: string | null;
|
||||
releaseDate: string | null;
|
||||
source?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
studios?: string[];
|
||||
staff?: ApiStaff[];
|
||||
categories?: string[];
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
playCount?: number;
|
||||
lastActivity?: string | null;
|
||||
playtime?: number;
|
||||
episodes?: ApiEpisode[];
|
||||
tracks?: ApiTrack[];
|
||||
}
|
||||
|
||||
export interface ApiStaff {
|
||||
id: number;
|
||||
name: string;
|
||||
photo: string | null;
|
||||
bio: string | null;
|
||||
birthDate: string | null;
|
||||
birthPlace: string | null;
|
||||
role: string;
|
||||
characterName: string | null;
|
||||
characterImage: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
export interface CreateMediaInput {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string | null;
|
||||
banner?: string | null;
|
||||
description?: string | null;
|
||||
rating?: number | null;
|
||||
category?: string | null;
|
||||
type?: string;
|
||||
status?: string;
|
||||
aspectRatio?: string | null;
|
||||
runtime?: number | null;
|
||||
director?: string | null;
|
||||
writer?: string | null;
|
||||
releaseDate?: string | null;
|
||||
source?: string | null;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
studios?: string[];
|
||||
staff?: CreateStaffInput[];
|
||||
}
|
||||
|
||||
export interface UpdateMediaInput extends Partial<CreateMediaInput> {}
|
||||
|
||||
export interface CreateStaffInput {
|
||||
name: string;
|
||||
photo?: string | null;
|
||||
bio?: string | null;
|
||||
birthDate?: string | null;
|
||||
birthPlace?: string | null;
|
||||
role: string;
|
||||
characterName?: string | null;
|
||||
characterImage?: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
// Cast Types
|
||||
export interface ApiCastItem {
|
||||
id: number;
|
||||
name: string;
|
||||
cleanname?: string;
|
||||
photo: string | null;
|
||||
bio: string | null;
|
||||
birthDate: string | null;
|
||||
birthPlace: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
occupations?: string[];
|
||||
filmography?: ApiCastMediaItem[];
|
||||
media_types?: string[];
|
||||
bust_size?: number | null;
|
||||
cup_size?: string | null;
|
||||
waist_size?: number | null;
|
||||
hip_size?: number | null;
|
||||
height?: number | null;
|
||||
weight?: number | null;
|
||||
hair_color?: string | null;
|
||||
eye_color?: string | null;
|
||||
ethnicity?: string | null;
|
||||
adult_specifics?: {
|
||||
id: number;
|
||||
cast_id: number;
|
||||
bust_size?: number | null;
|
||||
cup_size?: string | null;
|
||||
waist_size?: number | null;
|
||||
hip_size?: number | null;
|
||||
height?: number | null;
|
||||
weight?: number | null;
|
||||
hair_color?: string | null;
|
||||
eye_color?: string | null;
|
||||
ethnicity?: string | null;
|
||||
tattoos?: string | null;
|
||||
piercings?: string | null;
|
||||
measurements?: string | null;
|
||||
shoe_size?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiCastMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
poster: string | null;
|
||||
category: string | null;
|
||||
type: string;
|
||||
role: string;
|
||||
characterName?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateCastInput {
|
||||
name: string;
|
||||
photo?: string | null;
|
||||
bio?: string | null;
|
||||
birthDate?: string | null;
|
||||
birthPlace?: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateCastInput extends Partial<CreateCastInput> {}
|
||||
|
||||
|
||||
export function convertApiCastToStaff(apiItem: ApiCastItem): Staff {
|
||||
return {
|
||||
id: apiItem.id.toString(),
|
||||
name: apiItem.name,
|
||||
cleanname: apiItem.cleanname,
|
||||
role: apiItem.occupations?.[0] || 'Actor',
|
||||
photo: normalizeUrl(apiItem.photo) || `https://picsum.photos/seed/cast-${apiItem.id}/200/200`,
|
||||
bio: apiItem.bio || undefined,
|
||||
birthDate: apiItem.birthDate || undefined,
|
||||
birthPlace: apiItem.birthPlace || undefined,
|
||||
occupations: apiItem.occupations || ['Actor'],
|
||||
createdAt: apiItem.createdAt,
|
||||
updatedAt: apiItem.updatedAt,
|
||||
bust_size: apiItem.bust_size,
|
||||
cup_size: apiItem.cup_size,
|
||||
waist_size: apiItem.waist_size,
|
||||
hip_size: apiItem.hip_size,
|
||||
height: apiItem.height,
|
||||
weight: apiItem.weight,
|
||||
hair_color: apiItem.hair_color,
|
||||
eye_color: apiItem.eye_color,
|
||||
ethnicity: apiItem.ethnicity,
|
||||
filmography: apiItem.filmography?.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
poster: normalizeUrl(item.poster) || `https://picsum.photos/seed/${item.id}/400/600`,
|
||||
category: item.category,
|
||||
type: item.type,
|
||||
role: item.role,
|
||||
characterName: item.characterName
|
||||
})),
|
||||
media_types: apiItem.media_types,
|
||||
adult_specifics: apiItem.adult_specifics
|
||||
};
|
||||
}
|
||||
|
||||
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
// Convert staff from API to Media staff format
|
||||
const staff: Staff[] = (apiItem.staff || []).map((staffMember) => ({
|
||||
id: staffMember.id.toString(),
|
||||
name: staffMember.name,
|
||||
role: staffMember.role,
|
||||
photo: normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
||||
characterName: staffMember.characterName || staffMember.name,
|
||||
characterImage: normalizeUrl(staffMember.characterImage) || normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
||||
}));
|
||||
|
||||
// Determine aspect ratio from API format
|
||||
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
if (apiItem.aspectRatio) {
|
||||
const ratio = apiItem.aspectRatio.toLowerCase();
|
||||
if (ratio.includes('16:9') || ratio.includes('16/9') || ratio.includes('1.78') || ratio.includes('2.39')) {
|
||||
aspectRatio = '16/9';
|
||||
} else if (ratio.includes('1:1') || ratio.includes('1/1') || ratio.includes('1.00')) {
|
||||
aspectRatio = '1/1';
|
||||
} else if (ratio.includes('2/3')) {
|
||||
aspectRatio = '2/3';
|
||||
}
|
||||
}
|
||||
|
||||
// Map API type to Media type allowed values
|
||||
let mediaType: 'TV' | 'Movie' | 'OVA' | 'ONA' | 'Album' | 'Single' | 'Hardcover' | 'E-book' | 'Console' | 'Game' = 'Movie';
|
||||
const apiType = apiItem.type?.toLowerCase();
|
||||
if (apiType === 'tv' || apiType === 'episode') {
|
||||
mediaType = 'TV';
|
||||
} else if (apiType === 'album' || apiType === 'single') {
|
||||
mediaType = apiType === 'album' ? 'Album' : 'Single';
|
||||
} else if (apiType === 'game' || apiType === 'console') {
|
||||
mediaType = apiType === 'game' ? 'Game' : 'Console';
|
||||
} else if (apiType === 'ova') {
|
||||
mediaType = 'OVA';
|
||||
} else if (apiType === 'ona') {
|
||||
mediaType = 'ONA';
|
||||
} else if (apiType === 'hardcover' || apiType === 'e-book') {
|
||||
mediaType = apiType === 'hardcover' ? 'Hardcover' : 'E-book';
|
||||
}
|
||||
|
||||
// Map API category to MediaCategory
|
||||
let mediaCategory: 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games' = 'Movies';
|
||||
const apiCategory = apiItem.category?.toLowerCase();
|
||||
|
||||
if (apiCategory === 'anime') {
|
||||
mediaCategory = 'Anime';
|
||||
} else if (apiCategory === 'movie' || apiCategory === 'movies') {
|
||||
mediaCategory = 'Movies';
|
||||
} else if (apiCategory === 'tv' || apiCategory === 'series' || apiCategory === 'tv series' || apiType === 'tv' || apiType === 'episode') {
|
||||
mediaCategory = 'TV Series';
|
||||
} else if (apiCategory === 'music' || apiType === 'album' || apiType === 'single') {
|
||||
mediaCategory = 'Music';
|
||||
} else if (apiCategory === 'book' || apiCategory === 'books' || apiType === 'hardcover' || apiType === 'e-book') {
|
||||
mediaCategory = 'Books';
|
||||
} else if (apiCategory === 'adult') {
|
||||
mediaCategory = 'Adult';
|
||||
} else if (apiCategory === 'console' || apiCategory === 'consoles' || apiType === 'console') {
|
||||
mediaCategory = 'Consoles';
|
||||
} else if (apiCategory === 'game' || apiCategory === 'games' || apiType === 'game') {
|
||||
mediaCategory = 'Games';
|
||||
} else {
|
||||
// If category doesn't match any known category, use the original value capitalized
|
||||
// This handles cases where the API returns unexpected category values
|
||||
console.warn('Unknown category:', apiItem.category, 'defaulting to Movies');
|
||||
mediaCategory = 'Movies';
|
||||
}
|
||||
|
||||
// Map API status to Media status allowed values
|
||||
let mediaStatus: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold' = 'completed';
|
||||
const apiStatus = apiItem.status?.toLowerCase();
|
||||
if (apiStatus === 'ongoing' || apiStatus === 'watching') {
|
||||
mediaStatus = 'watching';
|
||||
} else if (apiStatus === 'upcoming' || apiStatus === 'planned') {
|
||||
mediaStatus = 'planned';
|
||||
} else if (apiStatus === 'dropped') {
|
||||
mediaStatus = 'dropped';
|
||||
} else if (apiStatus === 'reading') {
|
||||
mediaStatus = 'reading';
|
||||
} else if (apiStatus === 'listening') {
|
||||
mediaStatus = 'listening';
|
||||
} else if (apiStatus === 'playing') {
|
||||
mediaStatus = 'playing';
|
||||
} else if (apiStatus === 'on-hold') {
|
||||
mediaStatus = 'on-hold';
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiItem.id.toString(),
|
||||
title: apiItem.title,
|
||||
year: apiItem.year?.toString() || 'Unknown',
|
||||
poster: normalizeUrl(apiItem.poster) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
|
||||
category: mediaCategory,
|
||||
banner: normalizeUrl(apiItem.banner) || undefined,
|
||||
description: apiItem.description || undefined,
|
||||
rating: apiItem.rating || undefined,
|
||||
genres: apiItem.genres || [],
|
||||
tags: apiItem.tags || [],
|
||||
studios: apiItem.studios,
|
||||
type: mediaType,
|
||||
source: apiItem.source || undefined,
|
||||
status: mediaStatus,
|
||||
staff: staff.length > 0 ? staff : undefined,
|
||||
aspectRatio: aspectRatio,
|
||||
categories: apiItem.categories,
|
||||
platforms: apiItem.platforms,
|
||||
developers: apiItem.developers,
|
||||
completionStatus: apiItem.completionStatus,
|
||||
playCount: apiItem.playCount,
|
||||
lastActivity: apiItem.lastActivity,
|
||||
playtime: apiItem.playtime,
|
||||
episodes: apiItem.episodes,
|
||||
tracks: apiItem.tracks
|
||||
};
|
||||
}
|
||||
|
||||
// Media API Functions
|
||||
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching media from API:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaById(id: number | string): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMedia(media: CreateMediaInput): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(media),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating media:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMedia(id: number | string, media: UpdateMediaInput): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(media),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating media:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMedia(id: number | string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<{ message: string }> = await response.json();
|
||||
return data.success;
|
||||
} catch (error) {
|
||||
console.error('Error deleting media:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cast API Functions
|
||||
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<Staff[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<ApiCastItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiCastToStaff);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching cast from API:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCastById(id: number | string): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching cast by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCastMedia(castId: number | string): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${castId}/media`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching cast media:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCast(cast: CreateCastInput): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(cast),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating cast:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCast(id: number | string, cast: UpdateCastInput): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(cast),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating cast:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCast(id: number | string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<{ message: string }> = await response.json();
|
||||
return data.success;
|
||||
} catch (error) {
|
||||
console.error('Error deleting cast:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches all unique staff members from media
|
||||
export async function fetchAllActors(): Promise<Array<{id: number, name: string, photo: string | null}>> {
|
||||
try {
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
const actorMap = new Map<number, {id: number, name: string, photo: string | null}>();
|
||||
|
||||
media.forEach(item => {
|
||||
item.staff?.forEach(staffMember => {
|
||||
const id = parseInt(staffMember.id);
|
||||
if (!actorMap.has(id)) {
|
||||
actorMap.set(id, {
|
||||
id: id,
|
||||
name: staffMember.name,
|
||||
photo: staffMember.photo
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(actorMap.values());
|
||||
} catch (error) {
|
||||
console.error('Error fetching all actors:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches all unique tags from media
|
||||
// Legacy functions for compatibility
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
try {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
const tagSet = new Set<string>();
|
||||
|
||||
@@ -613,24 +24,9 @@ export async function fetchAllTags(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches media by actor name
|
||||
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
|
||||
try {
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
return media.filter(item =>
|
||||
item.staff?.some(staffMember =>
|
||||
staffMember.name.toLowerCase().includes(actorName.toLowerCase())
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by actor:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches media by tag
|
||||
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
||||
export async function fetchMediaByTag(tag: string) {
|
||||
try {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
return media.filter(item =>
|
||||
item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) ||
|
||||
@@ -642,154 +38,12 @@ export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function - fetch media from API (legacy compatibility)
|
||||
export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> {
|
||||
export async function fetchMediaFromApi(apiUrl?: string) {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
// Convenience function - fetch media from local JSON (legacy compatibility)
|
||||
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
|
||||
export async function fetchMediaFromLocalJson() {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
// Settings API Types
|
||||
export interface ApiSettingsItem {
|
||||
id?: number;
|
||||
enabled_categories: string[];
|
||||
items_per_page: number;
|
||||
grid_item_size?: number;
|
||||
default_view: string;
|
||||
show_adult_content: boolean;
|
||||
auto_play_trailers: boolean;
|
||||
language: string;
|
||||
theme: string;
|
||||
jellyfin_library_mappings?: string; // JSON string of LibraryMapping[]
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateSettingsInput {
|
||||
enabled_categories: string[];
|
||||
items_per_page?: number;
|
||||
grid_item_size?: number;
|
||||
default_view?: string;
|
||||
show_adult_content?: boolean;
|
||||
auto_play_trailers?: boolean;
|
||||
language?: string;
|
||||
theme?: string;
|
||||
jellyfin_library_mappings?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
|
||||
|
||||
export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
|
||||
return {
|
||||
id: apiItem.id,
|
||||
enabledCategories: apiItem.enabled_categories as MediaCategory[],
|
||||
itemsPerPage: apiItem.items_per_page || 20,
|
||||
gridItemSize: apiItem.grid_item_size || 5,
|
||||
defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid',
|
||||
showAdultContent: apiItem.show_adult_content || false,
|
||||
autoPlayTrailers: apiItem.auto_play_trailers || false,
|
||||
language: apiItem.language || 'en',
|
||||
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
|
||||
jellyfinLibraryMappings: apiItem.jellyfin_library_mappings,
|
||||
createdAt: apiItem.created_at,
|
||||
updatedAt: apiItem.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function convertSettingsToApi(settings: UserSettings): CreateSettingsInput {
|
||||
return {
|
||||
enabled_categories: settings.enabledCategories,
|
||||
items_per_page: settings.itemsPerPage,
|
||||
grid_item_size: settings.gridItemSize,
|
||||
default_view: settings.defaultView,
|
||||
show_adult_content: settings.showAdultContent,
|
||||
auto_play_trailers: settings.autoPlayTrailers,
|
||||
language: settings.language,
|
||||
theme: settings.theme,
|
||||
jellyfin_library_mappings: settings.jellyfinLibraryMappings,
|
||||
};
|
||||
}
|
||||
|
||||
// Settings API Functions
|
||||
export async function fetchSettings(): Promise<UserSettings | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/settings`);
|
||||
if (!response.ok) {
|
||||
// If settings don't exist (404), return null to use defaults
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||
try {
|
||||
const apiSettings = convertSettingsToApi(settings);
|
||||
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(apiSettings),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Create settings error response:', errorText);
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||
try {
|
||||
const apiSettings = convertSettingsToApi(settings);
|
||||
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(apiSettings),
|
||||
});
|
||||
if (!response.ok) {
|
||||
// If settings don't exist (404), try creating them instead
|
||||
if (response.status === 404) {
|
||||
return createSettings(settings);
|
||||
}
|
||||
const errorText = await response.text();
|
||||
console.error('Update settings error response:', errorText);
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToSettings(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createMedia, type CreateMediaInput } from '@/api';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, Film, Calendar, Star, User, BookOpen, Music as MusicIcon, Gamepad2, Monitor, Hash, Tag, Users, FileText, Globe, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AddMediaViewProps {
|
||||
@@ -180,82 +180,112 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: MediaCategory) => {
|
||||
const icons: Record<MediaCategory, any> = {
|
||||
'Anime': <Film size={18} />,
|
||||
'Movies': <Film size={18} />,
|
||||
'TV Series': <Film size={18} />,
|
||||
'Music': <MusicIcon size={18} />,
|
||||
'Books': <BookOpen size={18} />,
|
||||
'Games': <Gamepad2 size={18} />,
|
||||
'Consoles': <Monitor size={18} />,
|
||||
'Adult': <Star size={18} />
|
||||
};
|
||||
return icons[category] || <Film size={18} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
|
||||
<div className="pt-24 pb-12 px-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/')}
|
||||
className="mb-6 gap-2 text-muted-foreground hover:text-foreground"
|
||||
className="mb-6 gap-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-xl transition-all duration-300"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Back to Browse
|
||||
</Button>
|
||||
|
||||
<div className="bg-card rounded-3xl shadow-xl p-8 border border-border">
|
||||
<h1 className="text-3xl font-black text-foreground mb-2">Add New Media</h1>
|
||||
<p className="text-muted-foreground font-medium mb-8">
|
||||
Add a new item to your {activeCategory} library.
|
||||
</p>
|
||||
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#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">
|
||||
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl backdrop-blur-sm">
|
||||
<p className="text-green-500 font-bold">✓ Successfully added to library!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitStatus === 'error' && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl">
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl backdrop-blur-sm">
|
||||
<p className="text-red-500 font-bold">✗ Error: {errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddSubmit} className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-sm font-black text-foreground">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newMedia.title}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="e.g. Mob Psycho 100"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="year" className="text-sm font-black text-foreground">Year</Label>
|
||||
<Input
|
||||
id="year"
|
||||
value={newMedia.year}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||
placeholder="2024"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
<form onSubmit={handleAddSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Info Card */}
|
||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category" className="text-sm font-black text-foreground">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={newMedia.category}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{enabledCategories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type" className="text-sm font-black text-foreground">Type</Label>
|
||||
<select
|
||||
id="type"
|
||||
value={newMedia.type}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{newMedia.category === 'Music' ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newMedia.title}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="e.g. Mob Psycho 100"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#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>
|
||||
@@ -284,287 +314,337 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
<option value="Movie">Movie</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status" className="text-sm font-black text-foreground">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
value={newMedia.status}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="Released">Released</option>
|
||||
<option value="Ongoing">Ongoing</option>
|
||||
<option value="Upcoming">Upcoming</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Watching">Watching</option>
|
||||
<option value="Reading">Reading</option>
|
||||
<option value="Listening">Listening</option>
|
||||
<option value="Playing">Playing</option>
|
||||
<option value="Dropped">Dropped</option>
|
||||
<option value="On Hold">On Hold</option>
|
||||
</select>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
value={newMedia.status}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#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>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-foreground">Aspect Ratio (Format)</Label>
|
||||
<select
|
||||
id="aspectRatio"
|
||||
value={newMedia.aspectRatio}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||
className="bg-background border-border rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="2/3">2:3 (Standard Poster)</option>
|
||||
<option value="16/9">16:9 (Wide Thumbnail)</option>
|
||||
<option value="1/1">1:1 (Square)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="poster" className="text-sm font-black text-foreground">Poster URL</Label>
|
||||
<Input
|
||||
id="poster"
|
||||
value={newMedia.poster}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="banner" className="text-sm font-black text-foreground">Banner URL (Optional)</Label>
|
||||
<Input
|
||||
id="banner"
|
||||
value={newMedia.banner}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description" className="text-sm font-black text-foreground">Description (Optional)</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={newMedia.description}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Brief description..."
|
||||
className="bg-muted border-border rounded-xl p-3 h-20 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rating" className="text-sm font-black text-foreground">Rating (Optional)</Label>
|
||||
<Input
|
||||
id="rating"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="10"
|
||||
value={newMedia.rating}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
|
||||
placeholder="8.5"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||
<>
|
||||
|
||||
{/* Media Info Card */}
|
||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#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="runtime" className="text-sm font-black text-foreground">Runtime (min)</Label>
|
||||
<Label htmlFor="aspectRatio" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Aspect Ratio</Label>
|
||||
<select
|
||||
id="aspectRatio"
|
||||
value={newMedia.aspectRatio}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#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-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="releaseDate" className="text-sm font-black text-foreground">Release Date</Label>
|
||||
<Label htmlFor="releaseDate" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Release Date</Label>
|
||||
<Input
|
||||
id="releaseDate"
|
||||
type="date"
|
||||
value={newMedia.releaseDate}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="director" className="text-sm font-black text-foreground">Director</Label>
|
||||
<Input
|
||||
id="director"
|
||||
value={newMedia.director}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
||||
placeholder="Director name"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="writer" className="text-sm font-black text-foreground">Writer</Label>
|
||||
<Input
|
||||
id="writer"
|
||||
value={newMedia.writer}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
||||
placeholder="Writer name"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="genres" className="text-sm font-black text-foreground">Genres (comma-separated)</Label>
|
||||
<Input
|
||||
id="genres"
|
||||
value={newMedia.genres}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
||||
placeholder="Action, Drama, Sci-Fi"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="tags" className="text-sm font-black text-foreground">Tags (comma-separated)</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={newMedia.tags}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
||||
placeholder="Classic, Best-selling"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="studios" className="text-sm font-black text-foreground">Studios (comma-separated)</Label>
|
||||
<Input
|
||||
id="studios"
|
||||
value={newMedia.studios}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
||||
placeholder="Studio A, Studio B"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="source" className="text-sm font-black text-foreground">Source / Quelle</Label>
|
||||
<Input
|
||||
id="source"
|
||||
value={newMedia.source}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
|
||||
placeholder="e.g. username, xbvr, stashapp"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cast/Staff Section */}
|
||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-black text-foreground">Cast & Crew</Label>
|
||||
</div>
|
||||
|
||||
{/* Staff List */}
|
||||
{staff.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{staff.map((member, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-muted/50 rounded-xl border border-border">
|
||||
{member.photo && (
|
||||
<img
|
||||
src={member.photo}
|
||||
alt={member.name}
|
||||
className="w-12 h-12 rounded-lg object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-foreground truncate">{member.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.role}{member.characterName ? ` as ${member.characterName}` : ''}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setStaff(prev => prev.filter((_, i) => i !== index))}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Staff Form */}
|
||||
<div className="grid gap-3 p-4 bg-muted/30 rounded-xl border border-border">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="staffName" className="text-xs font-black text-foreground">Name</Label>
|
||||
<Label htmlFor="director" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Director</Label>
|
||||
<Input
|
||||
id="staffName"
|
||||
placeholder="Actor name"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const input = e.target as HTMLInputElement;
|
||||
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
|
||||
if (input.value && roleInput?.value) {
|
||||
addStaffMember();
|
||||
}
|
||||
}
|
||||
}}
|
||||
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 grid-cols-2 gap-3">
|
||||
<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="staffRole" className="text-xs font-black text-foreground">Role</Label>
|
||||
<Label htmlFor="staffName" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Name</Label>
|
||||
<Input
|
||||
id="staffRole"
|
||||
placeholder="e.g. Actor, Director"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
id="staffName"
|
||||
placeholder="Actor name"
|
||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#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) {
|
||||
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="staffCharacter" className="text-xs font-black text-foreground">Character (optional)</Label>
|
||||
<Label htmlFor="staffPhoto" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Photo URL (optional)</Label>
|
||||
<Input
|
||||
id="staffCharacter"
|
||||
placeholder="Character name"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
id="staffPhoto"
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#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 className="grid gap-2">
|
||||
<Label htmlFor="staffPhoto" className="text-xs font-black text-foreground">Photo URL (optional)</Label>
|
||||
<Input
|
||||
id="staffPhoto"
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addStaffMember}
|
||||
variant="outline"
|
||||
className="w-full border-border text-sm font-bold"
|
||||
>
|
||||
+ Add Cast Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
||||
</Button>
|
||||
{/* Submit Button - Full Width */}
|
||||
<div className="lg:col-span-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-gradient-to-br from-[#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>
|
||||
|
||||
@@ -124,14 +124,14 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
};
|
||||
|
||||
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">
|
||||
{/* Genre Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<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>
|
||||
@@ -147,7 +147,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
{/* Studio Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -163,7 +163,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<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>
|
||||
@@ -181,7 +181,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<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>
|
||||
@@ -199,7 +199,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<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>
|
||||
@@ -217,7 +217,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
{allSources.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<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>
|
||||
@@ -235,7 +235,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-muted-foreground font-bold"
|
||||
className="text-muted-foreground font-bold hover:text-[#6d28d9] transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
@@ -250,9 +250,9 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Grid item size slider */}
|
||||
<div className="flex items-center gap-3 bg-muted rounded-md px-3 py-2">
|
||||
<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"
|
||||
@@ -271,7 +271,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-muted-foreground font-bold gap-2">
|
||||
<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>
|
||||
@@ -283,13 +283,13 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center bg-muted 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-background shadow-sm text-[#6d28d9]" : "text-muted-foreground"
|
||||
"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')}
|
||||
>
|
||||
@@ -299,8 +299,8 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all",
|
||||
viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground"
|
||||
"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')}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
return (
|
||||
<div className="min-h-screen bg-background pb-20">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-[40vh] md:h-[50vh] overflow-hidden bg-zinc-900">
|
||||
<div className="relative h-[50vh] md:h-[60vh] overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
@@ -44,11 +44,11 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
<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="h-48 md:h-64 rounded-2xl overflow-hidden border-4 border-background 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}
|
||||
@@ -64,17 +64,17 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h1 className="text-4xl md:text-6xl font-black text-foreground mb-4 drop-shadow-sm">
|
||||
<h1 className="text-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">
|
||||
<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>
|
||||
)}
|
||||
@@ -88,22 +88,22 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute top-24 left-6 bg-white/20 hover:bg-white/40 text-white rounded-full backdrop-blur-md"
|
||||
className="absolute top-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-muted/50 rounded-3xl p-8 space-y-6 border border-border">
|
||||
<h3 className="text-xl font-black text-foreground">Personal Info</h3>
|
||||
<div className="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-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<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>
|
||||
@@ -113,7 +113,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<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>
|
||||
@@ -123,7 +123,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border">
|
||||
<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>
|
||||
@@ -134,7 +134,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{(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">
|
||||
<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>
|
||||
@@ -146,13 +146,13 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-3xl p-8 space-y-6 border border-border">
|
||||
<h3 className="text-xl font-black text-foreground">Measurements</h3>
|
||||
<div className="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">
|
||||
<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>
|
||||
@@ -164,7 +164,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{(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">
|
||||
<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>
|
||||
@@ -176,7 +176,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{(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">
|
||||
<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>
|
||||
@@ -199,7 +199,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{(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">
|
||||
<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>
|
||||
@@ -211,7 +211,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{(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">
|
||||
<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>
|
||||
@@ -223,7 +223,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{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">
|
||||
<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>
|
||||
@@ -235,7 +235,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{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">
|
||||
<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>
|
||||
@@ -252,7 +252,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
<div className="lg:col-span-2 space-y-12">
|
||||
{person.bio && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<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">
|
||||
@@ -263,7 +263,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<h2 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<User className="text-[#6d28d9]" />
|
||||
Characters
|
||||
</h2>
|
||||
@@ -271,7 +271,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
{person.filmography.map(item => (
|
||||
<div
|
||||
key={`${item.id}-char`}
|
||||
className="flex items-center gap-4 p-4 rounded-2xl bg-muted/50 border border-border"
|
||||
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-background">
|
||||
<img
|
||||
@@ -286,7 +286,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
<h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4>
|
||||
<button
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left"
|
||||
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left transition-colors"
|
||||
>
|
||||
in {item.title}
|
||||
</button>
|
||||
@@ -305,7 +305,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-black text-foreground flex items-center gap-3">
|
||||
<h2 className="text-3xl font-black text-foreground flex items-center gap-3">
|
||||
<Film className="text-[#6d28d9]" />
|
||||
Filmography
|
||||
</h2>
|
||||
@@ -314,14 +314,14 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
className="rounded-full border-border"
|
||||
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 border border-border rounded-full px-3 py-1.5 text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-[#6d28d9]"
|
||||
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>
|
||||
@@ -334,9 +334,9 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="group flex items-center gap-4 p-4 rounded-2xl bg-card border border-border hover:border-[#6d28d9]/30 hover:shadow-lg transition-all cursor-pointer"
|
||||
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-lg overflow-hidden shrink-0 shadow-sm">
|
||||
<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}
|
||||
@@ -345,14 +345,14 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
<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">
|
||||
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-border/50">
|
||||
{item.role}
|
||||
</Badge>
|
||||
{item.category && (
|
||||
|
||||
@@ -186,27 +186,29 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
};
|
||||
|
||||
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-foreground mb-2">Cast & Staff</h1>
|
||||
<p className="text-muted-foreground 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-muted-foreground" size={18} />
|
||||
<Input
|
||||
placeholder="Search cast..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 w-full md:w-[300px] bg-muted border-none rounded-full h-11"
|
||||
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-full h-11 w-11 ${showFilters ? 'bg-[#6d28d9] text-white border-[#6d28d9]' : 'border-border'}`}
|
||||
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} />
|
||||
@@ -214,7 +216,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full h-11 w-11 border-border"
|
||||
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} />
|
||||
@@ -223,7 +225,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full h-11 w-11 text-muted-foreground hover:text-foreground"
|
||||
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"
|
||||
>
|
||||
@@ -238,7 +240,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="bg-muted/50 rounded-2xl p-6 mb-6 border border-border"
|
||||
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>
|
||||
@@ -246,7 +248,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
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>
|
||||
@@ -260,7 +262,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
<select
|
||||
value={filterOccupation}
|
||||
onChange={(e) => setFilterOccupation(e.target.value)}
|
||||
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
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 => (
|
||||
@@ -273,7 +275,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
<select
|
||||
value={filterMediaType}
|
||||
onChange={(e) => setFilterMediaType(e.target.value)}
|
||||
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
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 => (
|
||||
@@ -284,7 +286,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{searchQuery && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<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} />
|
||||
@@ -292,7 +294,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
</Badge>
|
||||
)}
|
||||
{filterOccupation && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<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} />
|
||||
@@ -300,7 +302,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
</Badge>
|
||||
)}
|
||||
{filterMediaType && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<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} />
|
||||
@@ -308,7 +310,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
</Badge>
|
||||
)}
|
||||
{(sortBy !== 'name' || sortOrder !== 'asc') && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<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} />
|
||||
@@ -322,9 +324,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
{loading ? (
|
||||
<Loading message="Loading cast..." />
|
||||
) : filteredStaff.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<User size={48} className="mb-4 opacity-20" />
|
||||
<p className="text-lg font-bold">No cast members found</p>
|
||||
<div 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">
|
||||
@@ -336,11 +340,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="group bg-card rounded-2xl p-4 shadow-sm border border-border hover:shadow-xl hover:border-[#6d28d9]/20 transition-all duration-300 cursor-pointer"
|
||||
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-border 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}
|
||||
@@ -349,7 +353,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
<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-muted-foreground uppercase tracking-wider">
|
||||
@@ -364,8 +368,8 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
</div>
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<div className="bg-muted/50 rounded-xl p-3 flex items-center gap-3">
|
||||
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background">
|
||||
<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}
|
||||
@@ -388,7 +392,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredStaff.length > 0 && (
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border pt-8">
|
||||
<div className="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-muted-foreground font-medium">Items per page:</span>
|
||||
<select
|
||||
@@ -396,7 +400,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
}}
|
||||
className="bg-muted border-none rounded-md px-2 py-1 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
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"
|
||||
>
|
||||
{[12, 20, 36, 48, 60].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
@@ -410,7 +414,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2 font-bold border-border"
|
||||
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
@@ -427,7 +431,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-border"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
ChevronRight,
|
||||
Search,
|
||||
ListFilter,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
Calendar,
|
||||
Clock,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -28,6 +31,24 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
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(() => {
|
||||
@@ -68,34 +89,35 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
|
||||
const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []);
|
||||
const hasMoreCast = (media.staff?.length || 0) > castLimit;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Banner */}
|
||||
<div className="relative h-[400px] w-full overflow-hidden">
|
||||
<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-background via-background/40 to-transparent" />
|
||||
<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-2 bg-black/20 hover:bg-black/40 text-white rounded-full transition-colors z-10"
|
||||
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-6">
|
||||
{/* Left Column: Poster + Metadata */}
|
||||
<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-card ${
|
||||
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]'
|
||||
@@ -108,188 +130,133 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Compact metadata under poster */}
|
||||
<div className="mt-4 space-y-2">
|
||||
{media.studios && media.studios.length > 0 && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Studios:</span>
|
||||
{media.studios.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{media.developers && media.developers.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Developers:</span>
|
||||
{media.developers.map(dev => (
|
||||
<Badge key={dev} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
|
||||
{dev}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.platforms && media.platforms.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Platforms:</span>
|
||||
{media.platforms.map(platform => (
|
||||
<Badge key={platform} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.categories && media.categories.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Categories:</span>
|
||||
{media.categories.map(category => (
|
||||
<Badge key={category} variant="secondary" className="bg-muted text-foreground hover:bg-muted/80 border-none px-2 py-0.5 font-bold text-[10px]">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.completionStatus && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Completion:</span>
|
||||
{media.completionStatus}
|
||||
</p>
|
||||
)}
|
||||
{media.source && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Source:</span>
|
||||
{media.source}
|
||||
</p>
|
||||
)}
|
||||
{media.playCount !== undefined && media.playCount !== null && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Play Count:</span>
|
||||
{media.playCount}
|
||||
</p>
|
||||
)}
|
||||
{media.playtime !== undefined && media.playtime !== null && media.playtime > 0 && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Playtime:</span>
|
||||
{media.playtime}h
|
||||
</p>
|
||||
)}
|
||||
{media.lastActivity && (
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
<span className="text-muted-foreground/70 uppercase tracking-widest mr-2">Last Activity:</span>
|
||||
{media.lastActivity}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Links:</span>
|
||||
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
|
||||
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">AniDb</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Info */}
|
||||
<div className="flex-1 pt-4 md:pt-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-foreground mb-2">
|
||||
{media.title} <span className="text-muted-foreground font-medium">({media.year})</span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" className="rounded-full bg-[#6d28d9] hover:bg-[#5b21b6]">
|
||||
<Play size={20} fill="currentColor" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="rounded-full border-border">
|
||||
<Bookmark size={20} />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="rounded-full border-border">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-foreground 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-foreground hover:text-[#6d28d9] cursor-pointer transition-colors">
|
||||
• {genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-foreground leading-relaxed mb-6 max-w-3xl prose prose-sm dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: media.description || '' }}
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{media.tags?.map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20 border-none px-3 py-1 font-bold text-[10px] uppercase tracking-wider">
|
||||
{tag}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Staff Section - Only show if staff data exists */}
|
||||
{media.staff && media.staff.length > 0 && (
|
||||
<section className="mt-20">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-black text-foreground">Cast & Crew</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-bold text-muted-foreground">
|
||||
{showAllCast ? media.staff.length : displayedCast.length} / {media.staff.length}
|
||||
</span>
|
||||
{hasMoreCast && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAllCast(!showAllCast)}
|
||||
className="rounded-full border-border font-bold"
|
||||
>
|
||||
{showAllCast ? 'Show Less' : 'Show All'}
|
||||
<ChevronDown size={16} className={`ml-2 transition-transform ${showAllCast ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
{/* 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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{displayedCast.map(person => (
|
||||
|
||||
{/* 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 className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex items-center gap-4 bg-card p-3 rounded-xl shadow-sm border border-border hover:shadow-md transition-shadow cursor-pointer group"
|
||||
onClick={() => onPersonClick(person)}
|
||||
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-foreground truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
|
||||
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
|
||||
</div>
|
||||
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-muted">
|
||||
<img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
</div>
|
||||
{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">
|
||||
@@ -299,12 +266,12 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted border-none rounded-full h-9 text-sm" />
|
||||
<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">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
||||
<ListFilter size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -315,10 +282,10 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(season => (
|
||||
<div key={season} className="border border-border rounded-2xl overflow-hidden">
|
||||
<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 hover:bg-muted/50 transition-colors"
|
||||
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>
|
||||
@@ -338,13 +305,13 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
{episodesBySeason[season].map(episode => (
|
||||
<div key={episode.id} className="group cursor-pointer">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-xl overflow-hidden shadow-sm relative">
|
||||
<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" />
|
||||
<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">
|
||||
<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>
|
||||
@@ -354,7 +321,7 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mt-6 bg-border" />
|
||||
<Separator className="mt-6 bg-border/50" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -365,60 +332,47 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tracks Section - Only show if tracks data exists (Music) */}
|
||||
{media.tracks && media.tracks.length > 0 && (
|
||||
{/* 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-xl">
|
||||
<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 border-none rounded-full h-9 text-sm" />
|
||||
<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">
|
||||
<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">
|
||||
<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="border border-border rounded-2xl overflow-hidden">
|
||||
<div className="divide-y divide-border">
|
||||
{media.tracks
|
||||
.sort((a, b) => a.track_number - b.track_number)
|
||||
.map((track, index) => (
|
||||
<div key={track.id} className="group cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground group-hover:bg-[#6d28d9] group-hover:text-white transition-colors">
|
||||
{track.track_number}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-foreground group-hover:text-[#6d28d9] transition-colors truncate">
|
||||
{track.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{track.artist}</p>
|
||||
</div>
|
||||
{track.duration && (
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{track.duration}s
|
||||
</span>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Play size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</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,7 +1,7 @@
|
||||
import { Search, User, X, Plus, Download, Settings } from 'lucide-react';
|
||||
import { Search, User, X, Plus, Download, Settings, Menu } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { Link, NavLink, useLocation } from 'react-router-dom';
|
||||
import { MediaCategory } from '@/types';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
@@ -25,7 +25,21 @@ export default function Header({
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
// Map category names to URL-friendly paths
|
||||
const categoryPaths: Record<MediaCategory, string> = {
|
||||
'Anime': 'anime',
|
||||
'Movies': 'movies',
|
||||
'TV Series': 'tv-series',
|
||||
'Music': 'music',
|
||||
'Books': 'books',
|
||||
'Games': 'games',
|
||||
'Consoles': 'consoles',
|
||||
'Adult': 'adult'
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -53,70 +67,91 @@ export default function Header({
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-300",
|
||||
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-500",
|
||||
transparent && !scrolled
|
||||
? "bg-transparent"
|
||||
: transparent && scrolled
|
||||
? "backdrop-blur-md bg-background/80 border-b border-border/50"
|
||||
: "bg-[#6d28d9]"
|
||||
? "backdrop-blur-xl bg-background/70 border-b border-border/30"
|
||||
: "backdrop-blur-xl bg-gradient-to-r from-[#6d28d9]/90 via-[#8b5cf6]/90 to-[#6d28d9]/90 border-b border-white/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-8">
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
"text-2xl font-black flex items-center gap-1",
|
||||
"text-2xl font-black flex items-center gap-2 transition-all duration-300 hover:scale-105",
|
||||
(transparent && !scrolled) || !transparent ? "text-white" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white" : "bg-[#6d28d9]"
|
||||
"w-8 h-8 rounded-xl flex items-center justify-center shadow-lg transition-all duration-300",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "bg-white/20 backdrop-blur-sm border border-white/30"
|
||||
: "bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] shadow-[#6d28d9]/30"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"w-3 h-3 rounded-full",
|
||||
(transparent && !scrolled) || !transparent ? "bg-[#6d28d9]" : "bg-white"
|
||||
"w-4 h-4 rounded-full",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white" : "bg-white"
|
||||
)} />
|
||||
</div>
|
||||
kyoo
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
|
||||
omnyx
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className={cn(
|
||||
"md:hidden p-2 rounded-lg transition-all duration-300 hover:bg-white/10",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{enabledCategories.map(cat => (
|
||||
<button
|
||||
<NavLink
|
||||
key={cat}
|
||||
onClick={() => onCategoryChange(cat)}
|
||||
className={cn(
|
||||
"text-sm font-bold transition-colors uppercase tracking-wider",
|
||||
to={`/${categoryPaths[cat]}`}
|
||||
className={({ isActive }) => cn(
|
||||
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg relative",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? activeCategory === cat ? "text-white" : "text-white/60 hover:text-white"
|
||||
: activeCategory === cat ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
? isActive
|
||||
? "text-white bg-white/10"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
: isActive
|
||||
? "text-foreground bg-[#6d28d9]/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
</NavLink>
|
||||
))}
|
||||
<div className={cn(
|
||||
"w-px h-4 mx-2",
|
||||
"w-px h-6 mx-2",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white/20" : "bg-border"
|
||||
)} />
|
||||
<NavLink
|
||||
to="/cast"
|
||||
className={({ isActive }) => cn(
|
||||
"text-sm font-bold transition-colors uppercase tracking-wider",
|
||||
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? isActive ? "text-white" : "text-white/60 hover:text-white"
|
||||
: isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
? isActive ? "text-white bg-white/10" : "text-white/70 hover:text-white hover:bg-white/5"
|
||||
: isActive ? "text-foreground bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
CAST
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex items-center transition-all duration-300 overflow-hidden",
|
||||
isSearchOpen ? "w-48 md:w-64 rounded-full px-3 py-1" : "w-0",
|
||||
(transparent && !scrolled) || !transparent ? "bg-white/10" : "bg-muted"
|
||||
"flex items-center transition-all duration-300 overflow-hidden rounded-2xl",
|
||||
isSearchOpen ? "w-48 md:w-72 px-4 py-2.5" : "w-0",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "bg-white/10 backdrop-blur-md border border-white/20"
|
||||
: "bg-muted/50 backdrop-blur-md border border-border"
|
||||
)}>
|
||||
<input
|
||||
type="text"
|
||||
@@ -124,9 +159,9 @@ export default function Header({
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className={cn(
|
||||
"bg-transparent border-none outline-none text-sm w-full",
|
||||
"bg-transparent border-none outline-none text-sm w-full placeholder:opacity-60",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white placeholder:text-white/50"
|
||||
? "text-white placeholder:text-white"
|
||||
: "text-foreground placeholder:text-muted-foreground"
|
||||
)}
|
||||
autoFocus={isSearchOpen}
|
||||
@@ -135,50 +170,52 @@ export default function Header({
|
||||
<button
|
||||
onClick={toggleSearch}
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{isSearchOpen ? <X size={20} /> : <Search size={20} />}
|
||||
{isSearchOpen ? <X size={18} /> : <Search size={18} />}
|
||||
</button>
|
||||
<Link
|
||||
to="/add"
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Plus size={20} />
|
||||
<Plus size={18} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/import"
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Download size={20} />
|
||||
<Download size={18} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground"
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Settings size={20} />
|
||||
<Settings size={18} />
|
||||
</Link>
|
||||
<button className={cn(
|
||||
"w-8 h-8 rounded-full overflow-hidden border-2",
|
||||
(transparent && !scrolled) || !transparent ? "border-white/20" : "border-border"
|
||||
"w-9 h-9 rounded-xl overflow-hidden border-2 transition-all duration-300 hover:scale-110 hover:shadow-lg",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "border-white/30 hover:border-white/50"
|
||||
: "border-border hover:border-[#6d28d9]/50"
|
||||
)}>
|
||||
<img
|
||||
src="https://picsum.photos/seed/user/100/100"
|
||||
@@ -188,6 +225,38 @@ export default function Header({
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden absolute top-full left-0 right-0 bg-background border-b border-border shadow-lg">
|
||||
<nav className="flex flex-col p-4 gap-2">
|
||||
{enabledCategories.map(cat => (
|
||||
<NavLink
|
||||
key={cat}
|
||||
to={`/${categoryPaths[cat]}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={({ isActive }) => cn(
|
||||
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
|
||||
isActive ? "text-[#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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@ export default function ImporterView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -349,12 +349,12 @@ export default function ImporterView() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/')}
|
||||
className="text-muted-foreground hover:text-[#6d28d9]"
|
||||
className="text-muted-foreground hover:text-[#6d28d9] hover:bg-muted/50 rounded-xl transition-all duration-300"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-foreground">Media Importers</h1>
|
||||
<h1 className="text-4xl font-black text-foreground mb-1">Media Importers</h1>
|
||||
<p className="text-sm text-muted-foreground font-medium">Import media from external platforms</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,7 +364,7 @@ export default function ImporterView() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
{/* XBVR Importer Card */}
|
||||
{xbvrConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
@@ -433,7 +433,7 @@ export default function ImporterView() {
|
||||
|
||||
{/* StashAPP Importer Card */}
|
||||
{stashappConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
@@ -513,7 +513,7 @@ export default function ImporterView() {
|
||||
|
||||
{/* StashAPP Actor Updater Card */}
|
||||
{stashappConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
@@ -571,7 +571,7 @@ export default function ImporterView() {
|
||||
|
||||
{/* Playnite Importer Card */}
|
||||
{playniteConfig.ip && playniteConfig.apiToken && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
@@ -662,7 +662,7 @@ export default function ImporterView() {
|
||||
|
||||
{/* Jellyfin Importer Card */}
|
||||
{jellyfinConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||
@@ -844,7 +844,7 @@ export default function ImporterView() {
|
||||
|
||||
{/* Jellyfin Cleanup Card */}
|
||||
{jellyfinConfig.url && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
@@ -937,20 +937,20 @@ export default function ImporterView() {
|
||||
|
||||
{/* Progress Section */}
|
||||
{progress.stage !== 'idle' && (
|
||||
<div className="bg-card border border-border rounded-xl p-6">
|
||||
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{progress.stage === 'complete' ? (
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="text-green-600" size={20} />
|
||||
<div className="w-10 h-10 bg-green-500/10 rounded-full flex items-center justify-center border border-green-500/30">
|
||||
<CheckCircle className="text-green-500" size={20} />
|
||||
</div>
|
||||
) : progress.stage === 'error' ? (
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<XCircle className="text-red-600" size={20} />
|
||||
<div className="w-10 h-10 bg-red-500/10 rounded-full flex items-center justify-center border border-red-500/30">
|
||||
<XCircle className="text-red-500" size={20} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground animate-spin" size={20} />
|
||||
<div className="w-10 h-10 bg-purple-500/10 rounded-full flex items-center justify-center border border-purple-500/30">
|
||||
<Loader2 className="text-purple-500 animate-spin" size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -968,7 +968,7 @@ export default function ImporterView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetImport}
|
||||
className="gap-2 font-bold border-border"
|
||||
className="gap-2 font-bold border-border/50 hover:border-[#6d28d9]/50 transition-all duration-300"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Reset
|
||||
@@ -983,7 +983,7 @@ export default function ImporterView() {
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-300 ease-out",
|
||||
progress.stage === 'error' ? "bg-red-500" : "bg-[#6d28d9]"
|
||||
progress.stage === 'error' ? "bg-gradient-to-r from-red-500 to-red-600" : "bg-gradient-to-r from-[#6d28d9] to-[#8b5cf6]"
|
||||
)}
|
||||
style={{ width: `${getProgressPercentage()}%` }}
|
||||
/>
|
||||
@@ -997,9 +997,9 @@ export default function ImporterView() {
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Film size={16} className="text-muted-foreground" />
|
||||
<Film size={16} className="text-[#6d28d9]" />
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{(progress as any).gamesImported !== undefined ? 'Games' :
|
||||
(progress as any).moviesImported !== undefined ? 'Movies' :
|
||||
@@ -1014,16 +1014,16 @@ export default function ImporterView() {
|
||||
(progress as any).musicImported !== undefined ? (progress as any).musicImported : progress.videosImported}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users size={16} className="text-muted-foreground" />
|
||||
<Users size={16} className="text-[#6d28d9]" />
|
||||
<span className="text-xs font-bold text-muted-foreground">{(progress as any).castImported !== undefined ? 'Cast' : 'Actors'}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black text-foreground">{(progress as any).castImported !== undefined ? (progress as any).castImported : progress.actorsImported}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle size={16} className="text-muted-foreground" />
|
||||
<AlertCircle size={16} className="text-red-500" />
|
||||
<span className="text-xs font-bold text-muted-foreground">Errors</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black text-foreground">{progress.errors.length}</p>
|
||||
@@ -1034,7 +1034,7 @@ export default function ImporterView() {
|
||||
{importLog.length > 0 && (
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="bg-zinc-900 rounded-lg p-4 max-h-64 overflow-y-auto"
|
||||
className="bg-zinc-900/90 backdrop-blur-sm rounded-xl p-4 max-h-64 overflow-y-auto border border-border/50"
|
||||
>
|
||||
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">
|
||||
{importLog.join('\n')}
|
||||
@@ -1045,10 +1045,10 @@ export default function ImporterView() {
|
||||
{/* Errors */}
|
||||
{progress.errors.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-bold text-red-600 mb-2">Errors</h4>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 max-h-32 overflow-y-auto">
|
||||
<h4 className="text-sm font-bold text-red-500 mb-2">Errors</h4>
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 max-h-32 overflow-y-auto backdrop-blur-sm">
|
||||
{progress.errors.map((error, index) => (
|
||||
<p key={index} className="text-xs text-red-700 font-medium mb-1">
|
||||
<p key={index} className="text-xs text-red-500 font-medium mb-1">
|
||||
• {error}
|
||||
</p>
|
||||
))}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface LibrarySettingsProps {
|
||||
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
|
||||
Anime: <Tv size={18} />,
|
||||
Movies: <Film size={18} />,
|
||||
'TV Series': <Tv size={18} />,
|
||||
Music: <Music size={18} />,
|
||||
Books: <Book size={18} />,
|
||||
Consoles: <Gamepad2 size={18} />,
|
||||
@@ -34,29 +35,29 @@ export default function LibrarySettings({ enabledCategories, onToggleCategory }:
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-colors">
|
||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-all duration-300 hover:scale-110">
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
|
||||
<DialogContent className="sm:max-w-[425px] bg-card/50 backdrop-blur-sm rounded-3xl border border-border/50">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-black text-zinc-900">Library Settings</DialogTitle>
|
||||
<DialogDescription className="text-zinc-500 font-medium">
|
||||
<DialogTitle className="text-2xl font-black text-foreground">Library Settings</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground font-medium">
|
||||
Toggle which media areas you want to see in your library.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-6">
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 border border-zinc-100 transition-all hover:border-[#6d28d9]/20">
|
||||
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-muted/30 border border-border/50 transition-all hover:border-[#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-card 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-foreground 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-muted-foreground">
|
||||
{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-muted/50 transition-colors cursor-pointer border border-transparent hover:border-border"
|
||||
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-card 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,7 +68,7 @@ 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-foreground 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-muted-foreground">({media.year})</span>
|
||||
@@ -89,10 +90,10 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" className="rounded-full text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
|
||||
<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-muted-foreground 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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MediaCategory, UserSettings } from '@/types';
|
||||
import { MediaCategory, UserSettings, CustomColors } from '@/types';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Film, Music, Book, Tv, Gamepad2, ShieldAlert, LayoutGrid, List, Globe, Monitor, Sun, Moon, Save, ArrowLeft } from 'lucide-react';
|
||||
import { 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';
|
||||
@@ -47,6 +47,12 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
|
||||
// Page Settings State
|
||||
const [pageTitle, setPageTitle] = useState<string>('');
|
||||
const [favicon, setFavicon] = useState<string>('');
|
||||
const [customColors, setCustomColors] = useState<CustomColors>({});
|
||||
const [faviconPreview, setFaviconPreview] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
@@ -56,6 +62,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
const loadedSettings = await fetchSettings();
|
||||
if (loadedSettings) {
|
||||
setSettings(loadedSettings);
|
||||
setPageTitle(loadedSettings.pageTitle || '');
|
||||
setFavicon(loadedSettings.favicon || '');
|
||||
setCustomColors(loadedSettings.customColors || {});
|
||||
setFaviconPreview(loadedSettings.favicon || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
@@ -68,7 +78,13 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
setIsSaving(true);
|
||||
setSaveStatus('idle');
|
||||
try {
|
||||
const savedSettings = await updateSettings(settings);
|
||||
const updatedSettings: UserSettings = {
|
||||
...settings,
|
||||
pageTitle: pageTitle || undefined,
|
||||
favicon: favicon || undefined,
|
||||
customColors: Object.keys(customColors).length > 0 ? customColors : undefined,
|
||||
};
|
||||
const savedSettings = await updateSettings(updatedSettings);
|
||||
if (savedSettings) {
|
||||
setSettings(savedSettings);
|
||||
setSaveStatus('success');
|
||||
@@ -96,6 +112,31 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFaviconUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setFavicon(base64);
|
||||
setFaviconPreview(base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFavicon = () => {
|
||||
setFavicon('');
|
||||
setFaviconPreview('');
|
||||
};
|
||||
|
||||
const handleColorChange = (colorKey: keyof CustomColors, value: string) => {
|
||||
setCustomColors(prev => ({
|
||||
...prev,
|
||||
[colorKey]: value || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
@@ -107,22 +148,22 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background pt-20">
|
||||
{/* Content */}
|
||||
<div className="max-w-[1600px] mx-auto px-6 py-12">
|
||||
<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"
|
||||
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-3xl font-black text-foreground">Settings</h1>
|
||||
<h1 className="text-4xl font-black text-foreground">Settings</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-[#6d28d9] text-white hover:bg-[#5b21b6] font-bold px-6 py-3 h-12 rounded-lg flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||
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...'
|
||||
@@ -136,12 +177,12 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
</div>
|
||||
|
||||
{saveStatus === 'success' && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 font-medium">
|
||||
<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-50 border border-red-200 rounded-xl text-red-700 font-medium">
|
||||
<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>
|
||||
)}
|
||||
@@ -149,16 +190,16 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
<div className="grid gap-8">
|
||||
{/* Library Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Library Settings</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border">
|
||||
<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 transition-all hover:border-[#6d28d9]/20">
|
||||
<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-lg bg-muted flex items-center justify-center text-[#6d28d9]">
|
||||
<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>
|
||||
@@ -183,8 +224,8 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
|
||||
{/* Display Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Display Settings</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border space-y-6">
|
||||
<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>
|
||||
@@ -193,10 +234,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: option }))}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${
|
||||
className={`px-4 py-2 rounded-xl text-sm font-bold transition-all ${
|
||||
settings.itemsPerPage === option
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
? '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}
|
||||
@@ -211,10 +252,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||
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-[#6d28d9] text-white'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
? '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} />
|
||||
@@ -222,10 +263,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'list' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||
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-[#6d28d9] text-white'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
? '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} />
|
||||
@@ -260,10 +301,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => setSettings(prev => ({ ...prev, theme }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||
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-[#6d28d9] text-white'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
? '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} />}
|
||||
@@ -279,10 +320,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
|
||||
{/* Content Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Content Settings</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border space-y-4">
|
||||
<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">
|
||||
<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
|
||||
@@ -299,7 +340,7 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
</div>
|
||||
|
||||
{/* Auto-play trailers */}
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-background border border-border">
|
||||
<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
|
||||
@@ -319,8 +360,8 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
|
||||
{/* Language Settings */}
|
||||
<section>
|
||||
<h2 className="text-xl font-black text-foreground mb-6">Language</h2>
|
||||
<div className="bg-muted/50 rounded-2xl p-6 border border-border">
|
||||
<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>
|
||||
@@ -330,10 +371,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setSettings(prev => ({ ...prev, language: option.value }))}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${
|
||||
className={`px-4 py-2 rounded-xl text-sm font-bold transition-all ${
|
||||
settings.language === option.value
|
||||
? 'bg-[#6d28d9] text-white'
|
||||
: 'bg-background text-foreground hover:bg-muted border border-border'
|
||||
? '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}
|
||||
@@ -342,6 +383,115 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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,
|
||||
};
|
||||
@@ -127,7 +127,13 @@ export const MOCK_MEDIA: Media[] = [
|
||||
studios: ['Example Studio'],
|
||||
}
|
||||
];
|
||||
export const DETAIL_MEDIA: Media = {}
|
||||
export const DETAIL_MEDIA: Media = {
|
||||
id: '',
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: 'Movies'
|
||||
}
|
||||
/*
|
||||
export const DETAIL_MEDIA: Media = {
|
||||
id: 'mob-psycho',
|
||||
|
||||
@@ -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> {}
|
||||
@@ -1,38 +1,82 @@
|
||||
/**
|
||||
* Jellyfin Importer Module
|
||||
*
|
||||
* This module provides functionality to import media from a Jellyfin media server into the Omnyx media database.
|
||||
* It supports importing movies, TV series (including episodes), music albums, and cast members.
|
||||
* The module handles library mapping to categorize content appropriately and supports both new imports
|
||||
* and updates to existing entries.
|
||||
*
|
||||
* @module jellyfinImporter
|
||||
*/
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff, Episode, Track } from '@/types';
|
||||
|
||||
/**
|
||||
* Configuration for connecting to a Jellyfin instance
|
||||
*/
|
||||
export interface JellyfinConfig {
|
||||
/** URL of the Jellyfin server */
|
||||
url: string;
|
||||
/** API key for authentication with Jellyfin */
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping configuration for Jellyfin libraries to Omnyx categories
|
||||
*/
|
||||
export interface LibraryMapping {
|
||||
/** Name of the Jellyfin library */
|
||||
libraryName: string;
|
||||
/** Category to map this library to (use 'skip' to exclude the library) */
|
||||
category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip';
|
||||
pathSegments?: string[]; // Additional path segments that map to this library
|
||||
/** Additional path segments that map to this library */
|
||||
pathSegments?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for controlling the Jellyfin import process
|
||||
*/
|
||||
export interface JellyfinImportOptions {
|
||||
/** Whether to import movies */
|
||||
importMovies?: boolean;
|
||||
/** Whether to import TV series */
|
||||
importSeries?: boolean;
|
||||
/** Whether to import music */
|
||||
importMusic?: boolean;
|
||||
/** Whether to import cast members */
|
||||
importCast?: boolean;
|
||||
/** Maximum number of items to import (optional) */
|
||||
limit?: number;
|
||||
/** Library to category mappings */
|
||||
libraryMappings?: LibraryMapping[];
|
||||
updateExisting?: boolean; // If true, update existing items; if false, only import new items
|
||||
/** If true, update existing items; if false, only import new items */
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking for the import operation
|
||||
*/
|
||||
export interface ImportProgress {
|
||||
/** Current number of items processed */
|
||||
current: number;
|
||||
/** Total number of items to process */
|
||||
total: number;
|
||||
/** Current stage of the import process */
|
||||
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
|
||||
/** Human-readable status message */
|
||||
message: string;
|
||||
/** Number of movies successfully imported */
|
||||
moviesImported: number;
|
||||
/** Number of series successfully imported */
|
||||
seriesImported: number;
|
||||
/** Number of music items successfully imported */
|
||||
musicImported: number;
|
||||
/** Number of cast members successfully imported */
|
||||
castImported: number;
|
||||
/** Array of error messages encountered during import */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
@@ -56,7 +100,7 @@ export interface JellyfinItem {
|
||||
Type: string;
|
||||
Role?: string;
|
||||
PrimaryImageTag?: string;
|
||||
ImageBlurHashes?: any;
|
||||
ImageBlurHashes?: Record<string, Record<string, string>>;
|
||||
}>;
|
||||
ImageTags?: {
|
||||
Primary?: string;
|
||||
@@ -96,7 +140,7 @@ export interface JellyfinPerson {
|
||||
Name: string;
|
||||
Type: string;
|
||||
PrimaryImageTag?: string;
|
||||
ImageBlurHashes?: any;
|
||||
ImageBlurHashes?: Record<string, Record<string, string>>;
|
||||
PremiereDate?: string;
|
||||
ProductionYear?: number;
|
||||
Overview?: string;
|
||||
@@ -105,10 +149,45 @@ export interface JellyfinPerson {
|
||||
PlaceOfBirth?: string;
|
||||
}
|
||||
|
||||
export interface JellyfinEpisode {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
PremiereDate?: string;
|
||||
RunTimeTicks?: number;
|
||||
ParentIndexNumber?: number;
|
||||
IndexNumber?: number;
|
||||
ImageTags?: {
|
||||
Primary?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface JellyfinTrack {
|
||||
Id: string;
|
||||
Name: string;
|
||||
IndexNumber?: number;
|
||||
RunTimeTicks?: number;
|
||||
AlbumArtist?: string;
|
||||
Artists?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for logging import progress messages
|
||||
* @param message - The log message to display
|
||||
*/
|
||||
export type LogCallback = (message: string) => void;
|
||||
|
||||
/**
|
||||
* Callback function for updating import progress
|
||||
* @param progress - Partial progress object with updated fields
|
||||
*/
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
|
||||
// Helper function to normalize URL (avoid double slashes)
|
||||
/**
|
||||
* Normalizes a URL by removing trailing slashes
|
||||
* @param url - The URL to normalize
|
||||
* @returns The normalized URL
|
||||
*/
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
@@ -135,12 +214,20 @@ function getJellyfinImageUrl(config: JellyfinConfig, itemId: string, imageTag: s
|
||||
return `${normalizeUrl(config.url)}/Items/${itemId}/Images/${imageType}?tag=${imageTag}`;
|
||||
}
|
||||
|
||||
// Helper function to convert ticks to minutes
|
||||
/**
|
||||
* Converts Jellyfin ticks (100ns units) to minutes
|
||||
* @param ticks - Time in ticks (100 nanosecond units)
|
||||
* @returns Time in minutes
|
||||
*/
|
||||
function ticksToMinutes(ticks: number): number {
|
||||
return Math.floor(ticks / 600000000);
|
||||
}
|
||||
|
||||
// Helper function to format date
|
||||
/**
|
||||
* Formats a date string to ISO format (YYYY-MM-DD)
|
||||
* @param dateString - The date string to format
|
||||
* @returns Formatted date string or null if invalid
|
||||
*/
|
||||
function formatDate(dateString?: string): string | null {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
@@ -151,7 +238,11 @@ function formatDate(dateString?: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get year from date
|
||||
/**
|
||||
* Extracts the year from a date string
|
||||
* @param dateString - The date string to extract year from
|
||||
* @returns The year as a number
|
||||
*/
|
||||
function getYear(dateString?: string): number {
|
||||
if (!dateString) return new Date().getFullYear();
|
||||
try {
|
||||
@@ -219,7 +310,11 @@ async function fetchWithAuth(url: string, apiKey: string, options: RequestInit =
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
|
||||
// Fetch libraries from Jellyfin
|
||||
/**
|
||||
* Fetches all libraries from a Jellyfin instance
|
||||
* @param config - Configuration for connecting to Jellyfin
|
||||
* @returns Promise resolving to an array of library information
|
||||
*/
|
||||
export async function fetchJellyfinLibraries(config: JellyfinConfig): Promise<Array<{ Id: string; Name: string; CollectionType: string }>> {
|
||||
const userId = await getJellyfinUserId(config);
|
||||
|
||||
@@ -575,10 +670,12 @@ async function convertJellyfinSeriesToMedia(
|
||||
const writers = item.People?.filter(p => p.Type === 'Writer').map(p => p.Name) || [];
|
||||
|
||||
// Fetch episodes for this series
|
||||
let episodes: any[] = [];
|
||||
let episodes: Episode[] = [];
|
||||
try {
|
||||
const jellyfinEpisodes = await fetchJellyfinSeriesEpisodes(config, item.Id);
|
||||
episodes = jellyfinEpisodes.map(ep => ({
|
||||
id: parseInt(ep.Id),
|
||||
media_id: parseInt(item.Id),
|
||||
season: ep.ParentIndexNumber || 1,
|
||||
episode_number: ep.IndexNumber || 1,
|
||||
title: ep.Name,
|
||||
@@ -682,14 +779,16 @@ async function convertJellyfinAlbumToMedia(
|
||||
}));
|
||||
|
||||
// Fetch tracks for this album
|
||||
let tracks: any[] = [];
|
||||
let tracks: Track[] = [];
|
||||
try {
|
||||
const jellyfinTracks = await fetchJellyfinAlbumTracks(config, item.Id);
|
||||
tracks = jellyfinTracks.map((track, index) => ({
|
||||
id: parseInt(track.Id),
|
||||
media_id: parseInt(item.Id),
|
||||
track_number: track.IndexNumber || (index + 1),
|
||||
title: track.Name,
|
||||
duration: track.RunTimeTicks ? `${Math.floor(track.RunTimeTicks / 600000000 / 60)}:${String(Math.floor((track.RunTimeTicks / 600000000) % 60)).padStart(2, '0')}` : null,
|
||||
artist: track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown'
|
||||
duration: track.RunTimeTicks ? Math.floor(track.RunTimeTicks / 600000000) : null,
|
||||
artist: (track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown') as string
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch tracks for album ${item.Name}:`, error);
|
||||
@@ -721,22 +820,51 @@ async function convertJellyfinAlbumToMedia(
|
||||
}
|
||||
|
||||
// Convert Jellyfin person to API cast format
|
||||
function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): any {
|
||||
function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): Staff {
|
||||
const photo = person.PrimaryImageTag
|
||||
? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary')
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: person.Id,
|
||||
name: person.Name,
|
||||
role: person.Type || 'Actor',
|
||||
photo: photo,
|
||||
bio: person.Overview || null,
|
||||
birthDate: person.BirthDate ? formatDate(person.BirthDate) : null,
|
||||
birthPlace: person.PlaceOfBirth || null,
|
||||
occupations: [person.Type === 'Actor' ? 'Actor' : person.Type || 'Person']
|
||||
occupations: ['Actor']
|
||||
};
|
||||
}
|
||||
|
||||
// Main import function
|
||||
/**
|
||||
* Imports media from a Jellyfin instance into the Omnyx media database
|
||||
*
|
||||
* This function performs the following steps:
|
||||
* 1. Fetches existing media and cast from Omnyx to check for duplicates
|
||||
* 2. Fetches Jellyfin libraries for category mapping (if library mappings are provided)
|
||||
* 3. Imports movies (if enabled)
|
||||
* 4. Imports TV series with episodes (if enabled)
|
||||
* 5. Imports music albums with tracks (if enabled)
|
||||
* 6. Imports cast members (if enabled)
|
||||
*
|
||||
* @param config - Configuration for connecting to Jellyfin
|
||||
* @param options - Import options to control behavior
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const progress = await importFromJellyfin(
|
||||
* { url: 'http://localhost:8096', apiKey: 'your-api-key' },
|
||||
* { importMovies: true, importSeries: true, libraryMappings: [...] },
|
||||
* (msg) => console.log(msg),
|
||||
* (prog) => updateUI(prog)
|
||||
* );
|
||||
* console.log(`Imported ${progress.moviesImported} movies and ${progress.seriesImported} series`);
|
||||
* ```
|
||||
*/
|
||||
export async function importFromJellyfin(
|
||||
config: JellyfinConfig,
|
||||
options: JellyfinImportOptions,
|
||||
@@ -767,19 +895,19 @@ export async function importFromJellyfin(
|
||||
logCallback('Starting Jellyfin import...');
|
||||
|
||||
// Step 0: Fetch existing media and cast to check for duplicates
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const existingMedia = new Map(
|
||||
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
|
||||
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
|
||||
);
|
||||
logCallback(`Found ${existingMedia.size} existing media items in database`);
|
||||
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingCast = new Map(
|
||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingCast.size} existing cast members in database`);
|
||||
|
||||
@@ -1169,18 +1297,18 @@ export async function cleanupJellyfinMedia(
|
||||
try {
|
||||
logCallback('Starting Jellyfin cleanup...');
|
||||
|
||||
// Fetch all existing media from Kyoo API
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
// Fetch all existing media from Omnyx API
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: any) => m.source === 'jellyfin');
|
||||
const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: Media) => m.source === 'jellyfin');
|
||||
logCallback(`Found ${jellyfinMedia.length} Jellyfin media items in database`);
|
||||
|
||||
// Fetch all existing cast from Kyoo API
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
// Fetch all existing cast from Omnyx API
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const jellyfinCast = (existingCastData.data?.items || []).filter((c: any) => c.photo && c.photo.includes(normalizeUrl(config.url)));
|
||||
const jellyfinCast = (existingCastData.data?.items || []).filter((c: Staff) => c.photo && c.photo.includes(normalizeUrl(config.url)));
|
||||
logCallback(`Found ${jellyfinCast.length} Jellyfin cast members in database`);
|
||||
|
||||
// Fetch current items from Jellyfin
|
||||
|
||||
@@ -1,71 +1,153 @@
|
||||
/**
|
||||
* Playnite Importer Module
|
||||
*
|
||||
* This module provides functionality to import games from a Playnite library into the Omnyx media database.
|
||||
* It fetches game data from the Playnite API, converts it to the Omnyx media format, and handles both
|
||||
* new imports and updates to existing entries.
|
||||
*
|
||||
* @module playniteImporter
|
||||
*/
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||
|
||||
/**
|
||||
* Configuration for connecting to a Playnite instance
|
||||
*/
|
||||
export interface PlayniteConfig {
|
||||
/** IP address of the Playnite server */
|
||||
ip: string;
|
||||
/** API token for authentication with Playnite */
|
||||
apiToken: string;
|
||||
/** Port number of the Playnite API (default: 19821) */
|
||||
port?: number;
|
||||
/** If true, update existing media entries; if false, only import new entries */
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking for the import operation
|
||||
*/
|
||||
export interface ImportProgress {
|
||||
/** Current number of items processed */
|
||||
current: number;
|
||||
/** Total number of items to process */
|
||||
total: number;
|
||||
/** Current stage of the import process */
|
||||
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
|
||||
/** Human-readable status message */
|
||||
message: string;
|
||||
/** Number of games successfully imported */
|
||||
gamesImported: number;
|
||||
/** Array of error messages encountered during import */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Game data structure as returned by the Playnite API
|
||||
*/
|
||||
export interface PlayniteGame {
|
||||
/** Unique identifier for the game */
|
||||
id: string;
|
||||
/** Game name */
|
||||
name: string;
|
||||
/** Alternate name for sorting purposes */
|
||||
sortingName?: string;
|
||||
/** Game description */
|
||||
description?: string;
|
||||
/** User notes */
|
||||
notes?: string;
|
||||
/** Game version */
|
||||
version?: string;
|
||||
/** Whether the game is hidden */
|
||||
hidden?: boolean;
|
||||
/** Whether the game is marked as favorite */
|
||||
favorite?: boolean;
|
||||
/** User rating (0-100) */
|
||||
userScore?: number;
|
||||
/** Community rating (0-100) */
|
||||
communityScore?: number;
|
||||
/** Critic rating (0-100) */
|
||||
criticScore?: number;
|
||||
/** Release date in ISO format */
|
||||
releaseDate?: string;
|
||||
/** Completion status (e.g., 'Completed', 'Playing', 'Abandoned') */
|
||||
completionStatus?: string;
|
||||
/** Game categories */
|
||||
categories?: string[];
|
||||
/** Game tags */
|
||||
tags?: string[];
|
||||
/** Game features */
|
||||
features?: string[];
|
||||
/** Game genres */
|
||||
genres?: string[];
|
||||
/** Developer names */
|
||||
developers?: string[];
|
||||
/** Publisher names */
|
||||
publishers?: string[];
|
||||
/** Series name */
|
||||
series?: string[];
|
||||
/** Platform names */
|
||||
platforms?: string[];
|
||||
/** Age rating names */
|
||||
ageRatings?: string[];
|
||||
/** Region names */
|
||||
regions?: string[];
|
||||
/** External links */
|
||||
links?: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
/** Total playtime in seconds */
|
||||
playtime?: number;
|
||||
/** Number of times played */
|
||||
playCount?: number;
|
||||
/** Last activity timestamp */
|
||||
lastActivity?: string;
|
||||
/** Date added to library */
|
||||
added?: string;
|
||||
/** Last played date */
|
||||
lastPlayed?: string;
|
||||
/** Source platform/library */
|
||||
source?: string;
|
||||
/** Whether the game is currently installed */
|
||||
isInstalled?: boolean;
|
||||
/** Cover image as base64 data URI */
|
||||
coverBase64?: string;
|
||||
/** Background image as base64 data URI */
|
||||
backgroundBase64?: string;
|
||||
/** Icon image as base64 data URI */
|
||||
iconBase64?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response structure for the Playnite games API endpoint
|
||||
*/
|
||||
export interface PlayniteGamesResponse {
|
||||
/** Total number of games available */
|
||||
total: number;
|
||||
/** Offset for pagination */
|
||||
offset: number;
|
||||
/** Limit for pagination */
|
||||
limit: number;
|
||||
/** Array of game objects */
|
||||
games: PlayniteGame[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for logging import progress messages
|
||||
* @param message - The log message to display
|
||||
*/
|
||||
export type LogCallback = (message: string) => void;
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
|
||||
/**
|
||||
* Callback function for updating import progress
|
||||
* @param progress - Partial progress object with updated fields
|
||||
*/
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
/*
|
||||
async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const coverResponse = await fetch(`${baseUrl}/api/games/${gameId}/cover`, {
|
||||
@@ -89,6 +171,75 @@ async function fetchGameCover(baseUrl: string, headers: Record<string, string>,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGameBackground(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const backgroundResponse = await fetch(`${baseUrl}/api/games/${gameId}/background`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!backgroundResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = await backgroundResponse.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
|
||||
const mimeType = blob.type || 'image/jpeg';
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const iconResponse = await fetch(`${baseUrl}/api/games/${gameId}/icon`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!iconResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = await iconResponse.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
|
||||
const mimeType = blob.type || 'image/png';
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
*/
|
||||
/**
|
||||
* Imports games from a Playnite library into the Omnyx media database
|
||||
*
|
||||
* This function performs the following steps:
|
||||
* 1. Fetches existing media from Omnyx to check for duplicates
|
||||
* 2. Fetches all games from the Playnite API
|
||||
* 3. Fetches detailed information for each game
|
||||
* 4. Converts Playnite game data to Omnyx media format
|
||||
* 5. Imports or updates each game in the Omnyx database
|
||||
*
|
||||
* @param config - Configuration for connecting to Playnite
|
||||
* @param 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,
|
||||
@@ -113,11 +264,11 @@ export async function importFromPlaynite(
|
||||
logCallback('Starting Playnite import...');
|
||||
|
||||
// Step 0: Fetch existing media to check for duplicates and enable updates
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
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: any) => [m.title, m])
|
||||
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
|
||||
);
|
||||
logCallback(`Found ${existingMedia.size} existing games in database`);
|
||||
|
||||
@@ -159,6 +310,18 @@ export async function importFromPlaynite(
|
||||
|
||||
if (detailResponse.ok) {
|
||||
const detailData: PlayniteGame = await detailResponse.json();
|
||||
/*
|
||||
// Fetch images
|
||||
const [cover, background, icon] = await Promise.all([
|
||||
fetchGameCover(baseUrl, headers, game.id),
|
||||
fetchGameBackground(baseUrl, headers, game.id),
|
||||
fetchGameIcon(baseUrl, headers, game.id)
|
||||
]);
|
||||
|
||||
detailData.coverBase64 = cover;
|
||||
detailData.backgroundBase64 = background;
|
||||
detailData.iconBase64 = icon;
|
||||
*/
|
||||
detailedGames.push(detailData);
|
||||
logCallback(`✓ Fetched details for: ${game.name}`);
|
||||
} else {
|
||||
@@ -231,7 +394,7 @@ export async function importFromPlaynite(
|
||||
}
|
||||
|
||||
// Staff is for actors/performers only - leave empty for games
|
||||
const staff: any[] = [];
|
||||
const staff: Staff[] = [];
|
||||
// Determine type based on genres/features
|
||||
let type = 'Game';
|
||||
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
|
||||
|
||||
@@ -1,36 +1,77 @@
|
||||
/**
|
||||
* StashAPP Importer Module
|
||||
*
|
||||
* This module provides functionality to import adult video content and performers from a StashAPP instance
|
||||
* into the Omnyx media database. It fetches scene and performer data via GraphQL, converts it to the Omnyx
|
||||
* media format, and handles both new imports and updates to existing entries.
|
||||
*
|
||||
* @module stashappImporter
|
||||
*/
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||
|
||||
/**
|
||||
* Configuration for connecting to a StashAPP instance
|
||||
*/
|
||||
export interface StashAPPConfig {
|
||||
/** URL of the StashAPP server */
|
||||
url: string;
|
||||
/** API key for authentication (optional) */
|
||||
apiKey?: string;
|
||||
blacklist?: ['/AI/', 'temp', 'backup'];
|
||||
/** List of path patterns to blacklist during import */
|
||||
blacklist?: string[];
|
||||
/** If true, update existing media entries; if false, only import new entries */
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking for the import operation
|
||||
*/
|
||||
export interface ImportProgress {
|
||||
/** Current number of items processed */
|
||||
current: number;
|
||||
/** Total number of items to process */
|
||||
total: number;
|
||||
/** Current stage of the import process */
|
||||
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
|
||||
/** Human-readable status message */
|
||||
message: string;
|
||||
/** Number of videos successfully imported */
|
||||
videosImported: number;
|
||||
/** Number of actors successfully imported */
|
||||
actorsImported: number;
|
||||
/** Array of error messages encountered during import */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene data structure as returned by the StashAPP GraphQL API
|
||||
*/
|
||||
export interface StashAPPScene {
|
||||
/** Unique identifier for the scene */
|
||||
id: string;
|
||||
/** Scene title */
|
||||
title: string;
|
||||
/** Scene description/details */
|
||||
details: string;
|
||||
/** Scene URL */
|
||||
url: string;
|
||||
/** Release date in ISO format */
|
||||
date: string;
|
||||
/** Rating on a 0-100 scale */
|
||||
rating100: number;
|
||||
/** Whether the scene is organized */
|
||||
organized: boolean;
|
||||
/** O-counter value */
|
||||
o_counter: number;
|
||||
/** Creation timestamp */
|
||||
created_at: string;
|
||||
/** Last update timestamp */
|
||||
updated_at: string;
|
||||
/** File paths for various media assets */
|
||||
paths: {
|
||||
screenshot: string;
|
||||
preview: string;
|
||||
@@ -41,6 +82,7 @@ export interface StashAPPScene {
|
||||
funscript: string;
|
||||
caption: string;
|
||||
};
|
||||
/** Array of file information */
|
||||
files: Array<{
|
||||
size: number;
|
||||
duration: number;
|
||||
@@ -50,6 +92,7 @@ export interface StashAPPScene {
|
||||
height: number;
|
||||
path: string;
|
||||
}>;
|
||||
/** Array of performers in the scene */
|
||||
performers: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -81,7 +124,30 @@ export interface StashAPPScene {
|
||||
export interface StashAPPScenePerformer {
|
||||
id: string;
|
||||
name: string;
|
||||
disambiguation: string;
|
||||
url: string;
|
||||
gender: string;
|
||||
birthdate: string;
|
||||
ethnicity: string;
|
||||
country: string;
|
||||
eye_color: string;
|
||||
height_cm: number;
|
||||
measurements: string;
|
||||
fake_tits: boolean;
|
||||
career_length: string;
|
||||
tattoos: string;
|
||||
piercings: string;
|
||||
alias_list: string[];
|
||||
favorite: boolean;
|
||||
ignore_auto_tag: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
details: string;
|
||||
death_date: string;
|
||||
hair_color: string;
|
||||
weight: number;
|
||||
image_path: string;
|
||||
scene_count: number;
|
||||
}
|
||||
|
||||
export interface StashAPPPerformer {
|
||||
@@ -131,9 +197,24 @@ export interface StashAPPPerformersResponse {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for logging import progress messages
|
||||
* @param message - The log message to display
|
||||
*/
|
||||
export type LogCallback = (message: string) => void;
|
||||
|
||||
/**
|
||||
* Callback function for updating import progress
|
||||
* @param progress - Partial progress object with updated fields
|
||||
*/
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
|
||||
/**
|
||||
* Checks if a file path matches any blacklist pattern
|
||||
* @param filePath - The file path to check
|
||||
* @param blacklist - Array of blacklist patterns
|
||||
* @returns True if the path is blacklisted, false otherwise
|
||||
*/
|
||||
function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
|
||||
if (!blacklist || blacklist.length === 0) {
|
||||
return false;
|
||||
@@ -141,6 +222,17 @@ function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
|
||||
return blacklist.some(pattern => filePath.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or creates actor entries from StashAPP performers
|
||||
*
|
||||
* This function fetches all performers from StashAPP and updates or creates
|
||||
* corresponding actor entries in the Omnyx database.
|
||||
*
|
||||
* @param config - Configuration for connecting to StashAPP
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
*/
|
||||
export async function updateActorsFromStashAPP(
|
||||
config: StashAPPConfig,
|
||||
logCallback: LogCallback,
|
||||
@@ -159,12 +251,12 @@ export async function updateActorsFromStashAPP(
|
||||
try {
|
||||
logCallback('Starting StashAPP actor update...');
|
||||
|
||||
// Fetch existing cast from Kyoo API
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
// Fetch existing cast from Omnyx API
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingActors = new Map(
|
||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||
const existingActors = new Map<string, Staff>(
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
@@ -249,12 +341,12 @@ export async function updateActorsFromStashAPP(
|
||||
|
||||
for (let i = 0; i < performers.length; i++) {
|
||||
const performer = performers[i];
|
||||
const existingActor: any = existingActors.get(performer.name);
|
||||
const existingActor: Staff | undefined = existingActors.get(performer.name);
|
||||
|
||||
try {
|
||||
if (existingActor) {
|
||||
// Update existing actor
|
||||
const updateData: any = {
|
||||
const updateData: Partial<Staff> = {
|
||||
name: performer.name,
|
||||
};
|
||||
|
||||
@@ -363,6 +455,31 @@ export async function updateActorsFromStashAPP(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports scenes and performers from a StashAPP instance into the Omnyx media database
|
||||
*
|
||||
* This function performs the following steps:
|
||||
* 1. Fetches existing media and cast from Omnyx to check for duplicates
|
||||
* 2. Fetches all scenes from StashAPP via GraphQL
|
||||
* 3. Extracts unique performers from all scenes
|
||||
* 4. Imports or updates performers first
|
||||
* 5. Imports or updates scenes with their associated performers
|
||||
*
|
||||
* @param config - Configuration for connecting to StashAPP
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const progress = await importFromStashAPP(
|
||||
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
|
||||
* (msg) => console.log(msg),
|
||||
* (prog) => updateUI(prog)
|
||||
* );
|
||||
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
|
||||
* ```
|
||||
*/
|
||||
export async function importFromStashAPP(
|
||||
config: StashAPPConfig,
|
||||
logCallback: LogCallback,
|
||||
@@ -382,19 +499,19 @@ export async function importFromStashAPP(
|
||||
logCallback('Starting StashAPP import...');
|
||||
|
||||
// Step 0: Fetch existing media and cast to check for duplicates
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const existingTitles = new Set(
|
||||
existingMediaData.data?.items?.map((m: any) => m.title) || []
|
||||
existingMediaData.data?.items?.map((m: Media) => m.title) || []
|
||||
);
|
||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingActors = new Map(
|
||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||
const existingActors = new Map<string, Staff>(
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
@@ -525,12 +642,12 @@ export async function importFromStashAPP(
|
||||
|
||||
for (let i = 0; i < uniquePerformers.length; i++) {
|
||||
const performer = uniquePerformers[i];
|
||||
const existingActor: any = existingActors.get(performer.name);
|
||||
const existingActor: Staff | undefined = existingActors.get(performer.name);
|
||||
|
||||
try {
|
||||
if (existingActor) {
|
||||
// Update existing actor
|
||||
const updateData: any = {
|
||||
const updateData: Partial<Staff> = {
|
||||
name: performer.name,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,48 +1,96 @@
|
||||
/**
|
||||
* XBVR Importer Module
|
||||
*
|
||||
* This module provides functionality to import VR adult video content from an XBVR instance into the Omnyx media database.
|
||||
* It fetches scene data from the DeoVR API endpoint, extracts actors and video details, and handles both new imports
|
||||
* and updates to existing entries. The module specifically filters for content in the 'Recent' scene group.
|
||||
*
|
||||
* @module xbvrImporter
|
||||
*/
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
// Import the source mapping and types
|
||||
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
|
||||
|
||||
/**
|
||||
* Configuration for connecting to an XBVR instance
|
||||
*/
|
||||
export interface XBVRConfig {
|
||||
/** URL of the XBVR server */
|
||||
url: string;
|
||||
/** API key for authentication (optional) */
|
||||
apiKey?: string;
|
||||
/** If true, update existing media entries; if false, only import new entries */
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking for the import operation
|
||||
*/
|
||||
export interface ImportProgress {
|
||||
/** Current number of items processed */
|
||||
current: number;
|
||||
/** Total number of items to process */
|
||||
total: number;
|
||||
/** Current stage of the import process */
|
||||
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
|
||||
/** Human-readable status message */
|
||||
message: string;
|
||||
/** Number of videos successfully imported */
|
||||
videosImported: number;
|
||||
/** Number of actors successfully imported */
|
||||
actorsImported: number;
|
||||
/** Array of error messages encountered during import */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic video information from the DeoVR scene list
|
||||
*/
|
||||
export interface XBVRVideo {
|
||||
/** Video title */
|
||||
title: string;
|
||||
/** Video length in seconds */
|
||||
videoLength: number;
|
||||
/** URL to the video thumbnail */
|
||||
thumbnailUrl: string;
|
||||
/** URL to fetch detailed video information */
|
||||
video_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed video information as returned by the XBVR API
|
||||
*/
|
||||
export interface XBVRVideoDetail {
|
||||
/** Unique video identifier */
|
||||
id: number;
|
||||
/** Video title */
|
||||
title: string;
|
||||
/** Video description */
|
||||
description: string;
|
||||
/** Release date as Unix timestamp */
|
||||
date: number;
|
||||
/** URL to the video thumbnail */
|
||||
thumbnailUrl: string;
|
||||
/** Average rating */
|
||||
rating_avg: number;
|
||||
/** Screen type (e.g., '180', '360', 'dome') */
|
||||
screenType: string;
|
||||
/** Stereo mode (e.g., 'sbs', 'tb') */
|
||||
stereoMode: string;
|
||||
/** Video length in seconds */
|
||||
videoLength: number;
|
||||
/** Pay site information */
|
||||
paysite?: {
|
||||
name: string;
|
||||
};
|
||||
/** Array of actors in the video */
|
||||
actors: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
}>;
|
||||
/** Array of category tags */
|
||||
categories: Array<{
|
||||
tag: {
|
||||
name: string;
|
||||
@@ -50,16 +98,59 @@ export interface XBVRVideoDetail {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene list structure as returned by the DeoVR API
|
||||
*/
|
||||
export interface XBVRSceneList {
|
||||
/** Array of scene groups */
|
||||
scenes: Array<{
|
||||
/** Name of the scene group (e.g., 'Recent', 'Favorites') */
|
||||
name: string;
|
||||
/** List of videos in this group */
|
||||
list: XBVRVideo[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for logging import progress messages
|
||||
* @param message - The log message to display
|
||||
*/
|
||||
export type LogCallback = (message: string) => void;
|
||||
|
||||
/**
|
||||
* Callback function for updating import progress
|
||||
* @param progress - Partial progress object with updated fields
|
||||
*/
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
|
||||
/**
|
||||
* Imports VR adult videos and actors from an XBVR instance into the Omnyx media database
|
||||
*
|
||||
* This function performs the following steps:
|
||||
* 1. Fetches existing media and cast from Omnyx to check for duplicates
|
||||
* 2. Fetches the scene list from the DeoVR API endpoint
|
||||
* 3. Extracts videos from the 'Recent' scene group
|
||||
* 4. Fetches detailed information for each video
|
||||
* 5. Imports or updates actors first
|
||||
* 6. Imports or updates videos with their associated actors
|
||||
*
|
||||
* Videos and actors containing 'aka:' in their name are automatically skipped.
|
||||
*
|
||||
* @param config - Configuration for connecting to XBVR
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const progress = await importFromXBVR(
|
||||
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
|
||||
* (msg) => console.log(msg),
|
||||
* (prog) => updateUI(prog)
|
||||
* );
|
||||
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
|
||||
* ```
|
||||
*/
|
||||
export async function importFromXBVR(
|
||||
config: XBVRConfig,
|
||||
logCallback: LogCallback,
|
||||
@@ -79,19 +170,19 @@ export async function importFromXBVR(
|
||||
logCallback('Starting DeoVR import...');
|
||||
|
||||
// Step 0: Fetch existing media and cast to check for duplicates
|
||||
logCallback('Fetching existing media from Kyoo API...');
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const existingTitles = new Set(
|
||||
existingMediaData.data?.items?.map((m: any) => m.title) || []
|
||||
existingMediaData.data?.items?.map((m: Media) => m.title) || []
|
||||
);
|
||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||
|
||||
logCallback('Fetching existing cast from Kyoo API...');
|
||||
logCallback('Fetching existing cast from Omnyx API...');
|
||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
|
||||
const existingCastData = await existingCastResponse.json();
|
||||
const existingActors = new Map(
|
||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
|
||||
);
|
||||
logCallback(`Found ${existingActors.size} existing actors in database`);
|
||||
|
||||
|
||||
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
|
||||
}),
|
||||
}));
|
||||
16
src/types.ts
16
src/types.ts
@@ -119,10 +119,26 @@ export interface UserSettings {
|
||||
language: string;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[]
|
||||
|
||||
// Page Settings
|
||||
pageTitle?: string; // Custom page title
|
||||
favicon?: string; // Base64 encoded favicon/image
|
||||
customColors?: CustomColors; // Custom color scheme
|
||||
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CustomColors {
|
||||
primary?: string; // Primary accent color (hex)
|
||||
secondary?: string; // Secondary accent color (hex)
|
||||
background?: string; // Background color (hex)
|
||||
surface?: string; // Surface/card color (hex)
|
||||
text?: string; // Text color (hex)
|
||||
muted?: string; // Muted text color (hex)
|
||||
border?: string; // Border color (hex)
|
||||
}
|
||||
|
||||
// Source to Category mapping - ensures sources are only used with appropriate categories
|
||||
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
|
||||
'xbvr': ['Adult'],
|
||||
|
||||
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