Compare commits

..

7 Commits

Author SHA1 Message Date
Lars Behrends
34bb4a27be Add logo and banner to README
Embed project logo and banner in the README and add the corresponding image assets. Adds img/logo.png (displayed above the title) and img/banner.png (displayed below the title) to improve repository branding and visual presentation.
2026-04-20 22:55:48 +02:00
Lars Behrends
e5cdd6b383 Rename Kyoo to Omnyx & add page settings
Rename project branding from "Kyoo" to "Omnyx" across README, index.html, metadata.json, typedoc and various UI components. Add support for page-level settings: pageTitle, favicon (Base64 upload/preview), and customColors (color scheme) — introduced CustomColors type, persisted via API types and converters, and wired into updateSettings/fetchSettings flows. UI: SettingsView adds page settings UI (upload, preview, color pickers) and handlers; App applies pageTitle, favicon and sets CSS variables for customColors; Sidebar and Header now display the configured page title. Also update importer modules and docs to use the new project name in logs/comments.
2026-04-20 22:51:33 +02:00
Lars Behrends
63c5d0a7c0 Add Vitest, jsdom and importer tests
Set up testing with Vitest and jsdom and add unit tests for importers (jellyfin, playnite, stashapp, xbvr). Add typedoc configuration and update vite.config.ts and importer source files to support the tests. Ignore generated docs by adding /docs to .gitignore and add test-related devDependencies (vitest, @vitest/ui, jsdom, typedoc) in package.json.
2026-04-16 15:09:06 +02:00
Lars Behrends
432416cfc5 Use Zustand store; modularize API & routes
Introduce a centralized Zustand store and refactor app state out of App.tsx into src/store/appStore.ts. Modularize API surface by moving media/cast/settings/converters/types into src/lib/api/* and re-exporting from src/api.ts for backward compatibility. Replace inline route helpers with dedicated route components (MediaDetailRoute, CastDetailRoute, CategoryBrowseRoute) and wire CATEGORY_PATHS/PATH_TO_CATEGORY constants. Update AddMediaView UI (icons, layout) and adjust settings/category handling to use DEFAULT_SETTINGS and the store. Add zustand to package.json/package-lock.json and include a new React SKILL.md. Overall changes improve state management, API organization, and route/component separation for better maintainability and code-splitting.
2026-04-16 14:53:46 +02:00
Lars Behrends
a407b57006 Add Sidebar, restructure App and DetailView
Add a new Sidebar component and integrate it into App.tsx (replacing Header), updating overall layout to a two-column flex layout and moving/footer adjustments. Substantially refactor DetailView: new responsive layout, progress bar, tabbed navigation (Overview, Cast, Tracks, Seasons, etc.), improved cast and tracks UI, various icon and metadata display tweaks, and several UX/responsiveness fixes. Also add AGENTS.md (project development guide) and minor related imports/cleanup across changed files.
2026-04-16 13:51:08 +02:00
Lars Behrends
b57b22c30b Revamp UI styles and component theming
Visual refresh across multiple views: increased max layout widths (1200/1600 → 1920), adjusted typographic scale, and updated component styling for a more modern, cohesive look. Changes include backdrop-blur, softer borders (reduced border opacity), gradients for accents, rounded-xl corners, hover/transition improvements, and refined spacing for Footer, AddMediaView, BrowseView, CastDetailView, CastView, and various shared components. No functional logic changes — purely presentational updates to improve spacing, responsiveness, and visual polish.
2026-04-16 12:29:57 +02:00
Lars Behrends
a6d153ac1e Add Dashboard view and routing; mobile header menu
Introduce a new DashboardView component (src/components/DashboardView.tsx) that shows collection stats, recent/top/most-played lists and uses motion + Loading. Wire the dashboard into App (src/App.tsx): import DashboardView, add a root route for /, add per-category routes (/anime, /movies, /tv-series, etc.), map URL paths to MediaCategory, and update navigation/search behavior to use category paths (navigate to /<category>). Update Header (src/components/Header.tsx) to use NavLink for category links, add a mobile menu toggle with a Menu icon, and add URL-friendly category path mapping for consistent navigation.
2026-04-12 23:30:43 +02:00
48 changed files with 6867 additions and 1731 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ coverage/
*.log *.log
.env* .env*
!.env.example !.env.example
/docs

View 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
View 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)

View File

@@ -1,6 +1,10 @@
# Kyoo - Media Discovery Platform ![Omnyx Logo](img/logo.png)
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
![Omnyx Banner](img/banner.png)
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 ## Features

BIN
img/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

BIN
img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title> <title>Omnyx - Media Discovery</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -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.", "description": "A polished media discovery and tracking application inspired by modern anime platforms.",
"requestFramePermissions": [] "requestFramePermissions": []
} }

1155
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,12 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist", "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": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
@@ -28,15 +33,20 @@
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vite": "^6.2.0" "vite": "^6.2.0",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitest/ui": "^4.1.4",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"jsdom": "^29.0.2",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typedoc": "^0.28.19",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0",
"vitest": "^4.1.4"
} }
} }

View File

@@ -6,8 +6,9 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { LayoutGroup } from 'motion/react'; import { LayoutGroup } from 'motion/react';
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom'; 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 BrowseView from './components/BrowseView';
import DashboardView from './components/DashboardView';
import DetailView from './components/DetailView'; import DetailView from './components/DetailView';
import CastView from './components/CastView'; import CastView from './components/CastView';
import CastDetailView from './components/CastDetailView'; import CastDetailView from './components/CastDetailView';
@@ -15,30 +16,57 @@ import AddMediaView from './components/AddMediaView';
import ImporterView from './components/ImporterView'; import ImporterView from './components/ImporterView';
import SettingsView from './components/SettingsView'; import SettingsView from './components/SettingsView';
import Loading from './components/ui/loading'; 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 { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory, UserSettings } from './types'; import { Media, Staff, MediaCategory, UserSettings } from './types';
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api'; import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
import { ThemeProvider, useTheme } from './contexts/ThemeContext'; 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() { function AppContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { setTheme } = useTheme(); 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) // Zustand store
const [apiMedia, setApiMedia] = useState<Media[]>([]); const {
const [mediaLoading, setMediaLoading] = useState(true); 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(() => { useEffect(() => {
const loadSettingsFromApi = async () => { const loadSettingsFromApi = async () => {
@@ -49,6 +77,22 @@ function AppContent() {
setEnabledCategories(loadedSettings.enabledCategories); setEnabledCategories(loadedSettings.enabledCategories);
// Sync theme with theme context // Sync theme with theme context
setTheme(loadedSettings.theme); 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) { } catch (error) {
console.error('Failed to load settings from API:', error); console.error('Failed to load settings from API:', error);
@@ -58,6 +102,22 @@ function AppContent() {
loadSettingsFromApi(); loadSettingsFromApi();
}, [setTheme]); }, [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 () => { const reloadSettings = async () => {
try { try {
const loadedSettings = await fetchSettings(); const loadedSettings = await fetchSettings();
@@ -66,6 +126,22 @@ function AppContent() {
setEnabledCategories(loadedSettings.enabledCategories); setEnabledCategories(loadedSettings.enabledCategories);
// Sync theme with theme context // Sync theme with theme context
setTheme(loadedSettings.theme); 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) { } catch (error) {
console.error('Failed to reload settings from API:', error); console.error('Failed to reload settings from API:', error);
@@ -92,47 +168,35 @@ function AppContent() {
}, [location.pathname]); }, [location.pathname]);
const toggleCategory = async (category: MediaCategory) => { const toggleCategory = async (category: MediaCategory) => {
setEnabledCategories(prev => { const isEnabling = !enabledCategories.includes(category);
const isEnabling = !prev.includes(category); const newList = isEnabling
const newList = isEnabling ? [...enabledCategories, category]
? [...prev, category] : enabledCategories.filter(c => c !== category);
: prev.filter(c => c !== category);
// If we disable the current active category, switch to another enabled one // If we disable the current active category, switch to another enabled one
if (!isEnabling && activeCategory === category) { if (!isEnabling && activeCategory === category) {
const nextCategory = newList.find(c => c !== category) || 'Anime'; const nextCategory = newList.find(c => c !== category) || 'Anime';
setActiveCategory(nextCategory as MediaCategory); 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) => { const handleCategoryChange = (category: MediaCategory) => {
setActiveCategory(category); setActiveCategory(category);
setSearchParams({ category }); navigate(`/${CATEGORY_PATHS[category]}`);
navigate('/');
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
@@ -179,16 +243,7 @@ function AppContent() {
}; };
const handleGridItemSizeChange = async (size: number) => { const handleGridItemSizeChange = async (size: number) => {
const baseSettings = settings || { const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories };
enabledCategories: enabledCategories,
itemsPerPage: 20,
gridItemSize: 5,
defaultView: 'grid',
showAdultContent: false,
autoPlayTrailers: false,
language: 'en',
theme: 'system',
};
const updatedSettings: UserSettings = { const updatedSettings: UserSettings = {
...baseSettings, ...baseSettings,
gridItemSize: size, gridItemSize: size,
@@ -300,24 +355,28 @@ function AppContent() {
params.delete('search'); params.delete('search');
} }
setSearchParams(params); setSearchParams(params);
navigate('/'); navigate('/browse');
}; };
return ( return (
<div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]"> <div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9] flex">
<Header <Sidebar
onSearch={handleSearch}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
enabledCategories={enabledCategories} enabledCategories={enabledCategories}
onToggleCategory={toggleCategory} 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> <LayoutGroup>
<Routes> <Routes>
<Route path="/" element={ <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 <BrowseView
mediaList={filteredMedia} mediaList={filteredMedia}
onMediaClick={handleMediaClick} onMediaClick={handleMediaClick}
@@ -328,10 +387,18 @@ function AppContent() {
loading={mediaLoading} 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={ <Route path="/media/:id" element={
<MediaDetailRoute <MediaDetailRoute
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
allMedia={allMedia} allMedia={allMedia}
onPersonClick={handlePersonClick} onPersonClick={handlePersonClick}
/> />
@@ -344,10 +411,7 @@ function AppContent() {
/> />
} /> } />
<Route path="/cast/:id" element={ <Route path="/cast/:id" element={
<CastDetailRoute <CastDetailRoute />
selectedPerson={selectedPerson}
setSelectedPerson={setSelectedPerson}
/>
} /> } />
<Route path="/add" element={ <Route path="/add" element={
<AddMediaView <AddMediaView
@@ -364,108 +428,29 @@ function AppContent() {
} /> } />
</Routes> </Routes>
</LayoutGroup> </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> </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> </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() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>

View File

@@ -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; // Legacy functions for compatibility
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
export async function fetchAllTags(): Promise<string[]> { export async function fetchAllTags(): Promise<string[]> {
try { try {
const { fetchAllMedia } = await import('./lib/api/mediaApi');
const media = await fetchAllMedia(1, 1000); const media = await fetchAllMedia(1, 1000);
const tagSet = new Set<string>(); 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 fetchMediaByTag(tag: string) {
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[]> {
try { try {
const { fetchAllMedia } = await import('./lib/api/mediaApi');
const media = await fetchAllMedia(1, 1000); const media = await fetchAllMedia(1, 1000);
return media.filter(item => return media.filter(item =>
item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) || 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) {
export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> { const { fetchAllMedia } = await import('./lib/api/mediaApi');
return fetchAllMedia(); return fetchAllMedia();
} }
// Convenience function - fetch media from local JSON (legacy compatibility) export async function fetchMediaFromLocalJson() {
export async function fetchMediaFromLocalJson(): Promise<Media[]> { const { fetchAllMedia } = await import('./lib/api/mediaApi');
return fetchAllMedia(); 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;
}
}

View File

@@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { createMedia, type CreateMediaInput } from '@/api'; 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'; import { cn } from '@/lib/utils';
interface AddMediaViewProps { 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 ( return (
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto"> <div className="pt-24 pb-12 px-6">
<Button <Button
variant="ghost" variant="ghost"
onClick={() => navigate('/')} 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} /> <ArrowLeft size={20} />
Back to Browse Back to Browse
</Button> </Button>
<div className="bg-card rounded-3xl shadow-xl p-8 border border-border"> <div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
<h1 className="text-3xl font-black text-foreground mb-2">Add New Media</h1> <div className="flex items-center gap-4 mb-8">
<p className="text-muted-foreground font-medium 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">
Add a new item to your {activeCategory} library. {getCategoryIcon(activeCategory)}
</p> </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' && ( {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> <p className="text-green-500 font-bold"> Successfully added to library!</p>
</div> </div>
)} )}
{submitStatus === 'error' && ( {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> <p className="text-red-500 font-bold"> Error: {errorMessage}</p>
</div> </div>
)} )}
<form onSubmit={handleAddSubmit} className="space-y-6"> <form onSubmit={handleAddSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid gap-2"> {/* Basic Info Card */}
<Label htmlFor="title" className="text-sm font-black text-foreground">Title</Label> <div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<Input <div className="flex items-center gap-3 mb-4">
id="title" <div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
value={newMedia.title} <FileText size={16} />
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))} </div>
placeholder="e.g. Mob Psycho 100" <h3 className="text-lg font-black text-foreground">Basic Information</h3>
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]"
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-4">
<Label htmlFor="category" className="text-sm font-black text-foreground">Category</Label> <div className="grid gap-2">
<select <Label htmlFor="title" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Title</Label>
id="category" <Input
value={newMedia.category} id="title"
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))} value={newMedia.title}
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" onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
> placeholder="e.g. Mob Psycho 100"
{enabledCategories.map(cat => ( className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
<option key={cat} value={cat}>{cat}</option> required
))} />
</select> </div>
</div> <div className="grid grid-cols-2 gap-4">
</div> <div className="grid gap-2">
<div className="grid grid-cols-2 gap-4"> <Label htmlFor="year" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Year</Label>
<div className="grid gap-2"> <Input
<Label htmlFor="type" className="text-sm font-black text-foreground">Type</Label> id="year"
<select value={newMedia.year}
id="type" onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
value={newMedia.type} placeholder="2024"
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))} className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
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" />
> </div>
{newMedia.category === 'Music' ? ( <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="Album">Album</option>
<option value="Single">Single</option> <option value="Single">Single</option>
@@ -284,287 +314,337 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
<option value="Movie">Movie</option> <option value="Movie">Movie</option>
</> </>
)} )}
</select> </select>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="status" className="text-sm font-black text-foreground">Status</Label> <Label htmlFor="status" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Status</Label>
<select <select
id="status" id="status"
value={newMedia.status} value={newMedia.status}
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))} 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" 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="Released">Released</option>
<option value="Ongoing">Ongoing</option> <option value="Ongoing">Ongoing</option>
<option value="Upcoming">Upcoming</option> <option value="Upcoming">Upcoming</option>
<option value="Completed">Completed</option> <option value="Completed">Completed</option>
<option value="Watching">Watching</option> <option value="Watching">Watching</option>
<option value="Reading">Reading</option> <option value="Reading">Reading</option>
<option value="Listening">Listening</option> <option value="Listening">Listening</option>
<option value="Playing">Playing</option> <option value="Playing">Playing</option>
<option value="Dropped">Dropped</option> <option value="Dropped">Dropped</option>
<option value="On Hold">On Hold</option> <option value="On Hold">On Hold</option>
</select> </select>
</div>
</div>
</div> </div>
</div> </div>
<div className="grid gap-2">
<Label htmlFor="aspectRatio" className="text-sm font-black text-foreground">Aspect Ratio (Format)</Label> {/* Media Info Card */}
<select <div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
id="aspectRatio" <div className="flex items-center gap-3 mb-4">
value={newMedia.aspectRatio} <div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))} <Globe size={16} />
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" </div>
> <h3 className="text-lg font-black text-foreground">Media Information</h3>
<option value="2/3">2:3 (Standard Poster)</option> </div>
<option value="16/9">16:9 (Wide Thumbnail)</option> <div className="grid gap-4">
<option value="1/1">1:1 (Square)</option> <div className="grid gap-2">
</select> <Label htmlFor="poster" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Poster URL</Label>
</div> <Input
<div className="grid gap-2"> id="poster"
<Label htmlFor="poster" className="text-sm font-black text-foreground">Poster URL</Label> value={newMedia.poster}
<Input onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
id="poster" placeholder="https://example.com/poster.jpg"
value={newMedia.poster} className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))} required
placeholder="https://example.com/poster.jpg" />
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]" </div>
required <div className="grid gap-2">
/> <Label htmlFor="banner" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Banner URL (optional)</Label>
</div> <Input
<div className="grid gap-2"> id="banner"
<Label htmlFor="banner" className="text-sm font-black text-foreground">Banner URL (Optional)</Label> value={newMedia.banner}
<Input onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
id="banner" placeholder="https://example.com/banner.jpg"
value={newMedia.banner} className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))} />
placeholder="https://example.com/banner.jpg" </div>
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') && (
<>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <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 <Input
id="runtime" id="runtime"
type="number" type="number"
value={newMedia.runtime} value={newMedia.runtime}
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))} onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
placeholder="120" 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>
<div className="grid gap-2"> <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 <Input
id="releaseDate" id="releaseDate"
type="date" type="date"
value={newMedia.releaseDate} value={newMedia.releaseDate}
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))} 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>
<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"> <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 <Input
id="staffName" id="director"
placeholder="Actor name" value={newMedia.director}
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]" onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
onKeyDown={(e) => { placeholder="Director name"
if (e.key === 'Enter') { className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
e.preventDefault();
const input = e.target as HTMLInputElement;
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
if (input.value && roleInput?.value) {
addStaffMember();
}
}
}}
/> />
</div> </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"> <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 <Input
id="staffRole" id="staffName"
placeholder="e.g. Actor, Director" placeholder="Actor name"
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]" className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
const nameInput = document.getElementById('staffName') as HTMLInputElement; const roleInput = document.getElementById('staffRole') as HTMLInputElement;
if (input.value && nameInput?.value) { if (input.value && roleInput?.value) {
addStaffMember(); addStaffMember();
} }
} }
}} }}
/> />
</div> </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"> <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 <Input
id="staffCharacter" id="staffPhoto"
placeholder="Character name" placeholder="https://example.com/photo.jpg"
className="bg-background border-border rounded-lg h-9 text-sm focus:ring-[#6d28d9]" className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
/> />
</div> </div>
<Button
type="button"
onClick={addStaffMember}
variant="outline"
className="w-full border-border/50 text-sm font-bold hover:border-[#6d28d9]/50 hover:bg-[#6d28d9]/10 rounded-xl transition-all duration-300"
>
+ Add Cast Member
</Button>
</div> </div>
<div 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>
</div> </div>
)} )}
<Button {/* Submit Button - Full Width */}
type="submit" <div className="lg:col-span-2">
disabled={isSubmitting} <Button
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" type="submit"
> disabled={isSubmitting}
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'} 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"
</Button> >
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
</Button>
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -124,14 +124,14 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
}; };
return ( 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 */} {/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-8"> <div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* Genre Filter */} {/* Genre Filter */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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} /> <Star size={16} />
{selectedGenre || 'Genres'} {selectedGenre || 'Genres'}
</button> </button>
@@ -147,7 +147,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{/* Studio Filter */} {/* Studio Filter */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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 Studios
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -163,7 +163,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{activeCategory === 'Games' && ( {activeCategory === 'Games' && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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} /> <Monitor size={16} />
{selectedPlatform || 'Platforms'} {selectedPlatform || 'Platforms'}
</button> </button>
@@ -181,7 +181,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{activeCategory === 'Games' && ( {activeCategory === 'Games' && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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} /> <Users size={16} />
{selectedDeveloper || 'Developers'} {selectedDeveloper || 'Developers'}
</button> </button>
@@ -199,7 +199,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{activeCategory === 'Games' && ( {activeCategory === 'Games' && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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} /> <FolderTree size={16} />
{selectedCategory || 'Categories'} {selectedCategory || 'Categories'}
</button> </button>
@@ -217,7 +217,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
{allSources.length > 0 && ( {allSources.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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} /> <Tag size={16} />
{selectedSource || 'Source'} {selectedSource || 'Source'}
</button> </button>
@@ -235,7 +235,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
className="text-muted-foreground font-bold" className="text-muted-foreground font-bold hover:text-[#6d28d9] transition-colors"
onClick={() => { onClick={() => {
setSelectedGenre(null); setSelectedGenre(null);
setSelectedStudio(null); setSelectedStudio(null);
@@ -250,9 +250,9 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
)} )}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
{/* Grid item size slider */} {/* 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> <span className="text-xs font-bold text-muted-foreground">Size</span>
<input <input
type="range" type="range"
@@ -271,7 +271,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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} /> <ArrowUpDown size={16} />
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'} {sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
</button> </button>
@@ -283,13 +283,13 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 transition-all", "h-8 w-8 transition-all rounded-lg",
viewMode === 'grid' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground" viewMode === 'grid' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
)} )}
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
> >
@@ -299,8 +299,8 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 transition-all", "h-8 w-8 transition-all rounded-lg",
viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground" viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
)} )}
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
> >

View File

@@ -34,7 +34,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
return ( return (
<div className="min-h-screen bg-background pb-20"> <div className="min-h-screen bg-background pb-20">
{/* Hero Section */} {/* 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 <img
src={person.photo} src={person.photo}
alt={person.name} 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 bg-gradient-to-t from-background via-transparent to-transparent" />
<div className="absolute inset-0 flex items-end px-6 pb-12"> <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 <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} 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 <img
src={person.photo} src={person.photo}
@@ -64,17 +64,17 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }} 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} {person.name}
</h1> </h1>
<div className="flex flex-wrap justify-center md:justify-start gap-3"> <div className="flex flex-wrap justify-center md:justify-start gap-3">
{person.occupations?.map(occ => ( {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} {occ}
</Badge> </Badge>
))} ))}
{person.filmography && person.filmography.length > 0 && ( {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' : ''} {person.filmography.length} Role{person.filmography.length !== 1 ? 's' : ''}
</Badge> </Badge>
)} )}
@@ -88,22 +88,22 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate(-1)} 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} /> <ArrowLeft size={24} />
</Button> </Button>
</div> </div>
{/* Content Section */} {/* 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 */} {/* Sidebar Info */}
<div className="space-y-8"> <div className="space-y-8">
<div className="bg-muted/50 rounded-3xl p-8 space-y-6 border border-border"> <div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
<h3 className="text-xl font-black text-foreground">Personal Info</h3> <h3 className="text-2xl font-black text-foreground">Personal Info</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start gap-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} /> <Calendar size={20} />
</div> </div>
<div> <div>
@@ -113,7 +113,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
</div> </div>
<div className="flex items-start gap-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">
<MapPin size={20} /> <MapPin size={20} />
</div> </div>
<div> <div>
@@ -123,7 +123,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
</div> </div>
<div className="flex items-start gap-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">
<Briefcase size={20} /> <Briefcase size={20} />
</div> </div>
<div> <div>
@@ -134,7 +134,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{(person.ethnicity || person.adult_specifics?.ethnicity) && ( {(person.ethnicity || person.adult_specifics?.ethnicity) && (
<div className="flex items-start gap-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">
<User size={20} /> <User size={20} />
</div> </div>
<div> <div>
@@ -146,13 +146,13 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
</div> </div>
</div> </div>
<div className="bg-muted/50 rounded-3xl p-8 space-y-6 border border-border"> <div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
<h3 className="text-xl font-black text-foreground">Measurements</h3> <h3 className="text-2xl font-black text-foreground">Measurements</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start gap-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} /> <Ruler size={20} />
</div> </div>
<div> <div>
@@ -164,7 +164,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{(person.weight || person.adult_specifics?.weight) && ( {(person.weight || person.adult_specifics?.weight) && (
<div className="flex items-start gap-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} /> <Ruler size={20} />
</div> </div>
<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) && ( {(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="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} /> <Ruler size={20} />
</div> </div>
<div> <div>
@@ -199,7 +199,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{(person.hair_color || person.adult_specifics?.hair_color) && ( {(person.hair_color || person.adult_specifics?.hair_color) && (
<div className="flex items-start gap-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">
<Palette size={20} /> <Palette size={20} />
</div> </div>
<div> <div>
@@ -211,7 +211,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{(person.eye_color || person.adult_specifics?.eye_color) && ( {(person.eye_color || person.adult_specifics?.eye_color) && (
<div className="flex items-start gap-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">
<Eye size={20} /> <Eye size={20} />
</div> </div>
<div> <div>
@@ -223,7 +223,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.adult_specifics?.tattoos && ( {person.adult_specifics?.tattoos && (
<div className="flex items-start gap-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">
<Palette size={20} /> <Palette size={20} />
</div> </div>
<div> <div>
@@ -235,7 +235,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.adult_specifics?.piercings && ( {person.adult_specifics?.piercings && (
<div className="flex items-start gap-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">
<Palette size={20} /> <Palette size={20} />
</div> </div>
<div> <div>
@@ -252,7 +252,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
<div className="lg:col-span-2 space-y-12"> <div className="lg:col-span-2 space-y-12">
{person.bio && ( {person.bio && (
<section> <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 Biography
</h2> </h2>
<p className="text-foreground leading-relaxed text-lg"> <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 && ( {person.filmography && person.filmography.length > 0 && (
<section> <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]" /> <User className="text-[#6d28d9]" />
Characters Characters
</h2> </h2>
@@ -271,7 +271,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.filmography.map(item => ( {person.filmography.map(item => (
<div <div
key={`${item.id}-char`} 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"> <div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-background">
<img <img
@@ -286,7 +286,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
<h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4> <h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4>
<button <button
onClick={() => handleMediaClick(item.id.toString())} 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} in {item.title}
</button> </button>
@@ -305,7 +305,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.filmography && person.filmography.length > 0 && ( {person.filmography && person.filmography.length > 0 && (
<section> <section>
<div className="flex items-center justify-between mb-6"> <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]" /> <Film className="text-[#6d28d9]" />
Filmography Filmography
</h2> </h2>
@@ -314,14 +314,14 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')} 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} /> <ListFilter size={16} />
</Button> </Button>
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'year' | 'title' | 'role')} 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="year">Year</option>
<option value="title">Title</option> <option value="title">Title</option>
@@ -334,9 +334,9 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
<div <div
key={item.id} key={item.id}
onClick={() => handleMediaClick(item.id.toString())} 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 <img
src={item.poster || person.photo} src={item.poster || person.photo}
alt={item.title} alt={item.title}
@@ -345,14 +345,14 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
/> />
</div> </div>
<div className="min-w-0"> <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} {item.title}
</h4> </h4>
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1"> <p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1">
{item.year || 'Unknown'} {item.year || 'Unknown'}
</p> </p>
<div className="flex items-center gap-2"> <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} {item.role}
</Badge> </Badge>
{item.category && ( {item.category && (

View File

@@ -186,27 +186,29 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
}; };
return ( 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 className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
<div> <div>
<h1 className="text-4xl font-black text-foreground mb-2">Cast & Staff</h1> <h1 className="text-5xl font-black text-foreground mb-3 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
<p className="text-muted-foreground font-medium">Discover the people behind your favorite media</p> Cast & Staff
</h1>
<p className="text-muted-foreground font-medium text-lg">Discover the people behind your favorite media</p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
<Input <Input
placeholder="Search cast..." placeholder="Search cast..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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> </div>
<Button <Button
variant={showFilters ? 'default' : 'outline'} variant={showFilters ? 'default' : 'outline'}
size="icon" 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)} onClick={() => setShowFilters(!showFilters)}
> >
<Filter size={20} /> <Filter size={20} />
@@ -214,7 +216,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
<Button <Button
variant="outline" variant="outline"
size="icon" 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')} onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
> >
<ArrowUpDown size={20} /> <ArrowUpDown size={20} />
@@ -223,7 +225,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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} onClick={handleResetFilters}
title="Reset filters" title="Reset filters"
> >
@@ -238,7 +240,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
initial={{ opacity: 0, height: 0 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }} animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }} 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 className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div> <div>
@@ -246,7 +248,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)} 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="name">Name</option>
<option value="role">Role</option> <option value="role">Role</option>
@@ -260,7 +262,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
<select <select
value={filterOccupation} value={filterOccupation}
onChange={(e) => setFilterOccupation(e.target.value)} 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> <option value="">All Occupations</option>
{uniqueOccupations.map(occ => ( {uniqueOccupations.map(occ => (
@@ -273,7 +275,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
<select <select
value={filterMediaType} value={filterMediaType}
onChange={(e) => setFilterMediaType(e.target.value)} 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> <option value="">All Media Types</option>
{uniqueMediaTypes.map(type => ( {uniqueMediaTypes.map(type => (
@@ -284,7 +286,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
</div> </div>
<div className="mt-4 flex items-center gap-2"> <div className="mt-4 flex items-center gap-2">
{searchQuery && ( {searchQuery && (
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Search: {searchQuery} Search: {searchQuery}
<button onClick={() => setSearchQuery('')} className="hover:text-foreground"> <button onClick={() => setSearchQuery('')} className="hover:text-foreground">
<X size={12} /> <X size={12} />
@@ -292,7 +294,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
</Badge> </Badge>
)} )}
{filterOccupation && ( {filterOccupation && (
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Occupation: {filterOccupation} Occupation: {filterOccupation}
<button onClick={() => setFilterOccupation('')} className="hover:text-foreground"> <button onClick={() => setFilterOccupation('')} className="hover:text-foreground">
<X size={12} /> <X size={12} />
@@ -300,7 +302,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
</Badge> </Badge>
)} )}
{filterMediaType && ( {filterMediaType && (
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Media Type: {filterMediaType} Media Type: {filterMediaType}
<button onClick={() => setFilterMediaType('')} className="hover:text-foreground"> <button onClick={() => setFilterMediaType('')} className="hover:text-foreground">
<X size={12} /> <X size={12} />
@@ -308,7 +310,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
</Badge> </Badge>
)} )}
{(sortBy !== 'name' || sortOrder !== 'asc') && ( {(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}) Sort: {sortBy} ({sortOrder})
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-foreground"> <button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-foreground">
<X size={12} /> <X size={12} />
@@ -322,9 +324,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
{loading ? ( {loading ? (
<Loading message="Loading cast..." /> <Loading message="Loading cast..." />
) : filteredStaff.length === 0 ? ( ) : filteredStaff.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
<User size={48} className="mb-4 opacity-20" /> <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">
<p className="text-lg font-bold">No cast members found</p> <User size={40} />
</div>
<p className="text-xl font-bold">No cast members found</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <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 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} 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)} onClick={() => onPersonClick(person)}
> >
<div className="flex items-center gap-4 mb-4"> <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 <img
src={person.photo} src={person.photo}
alt={person.name} alt={person.name}
@@ -349,7 +353,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
/> />
</div> </div>
<div className="min-w-0 flex-1"> <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} {person.name}
</h3> </h3>
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider"> <p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
@@ -364,8 +368,8 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
</div> </div>
{person.filmography && person.filmography.length > 0 && ( {person.filmography && person.filmography.length > 0 && (
<div className="bg-muted/50 rounded-xl p-3 flex items-center gap-3"> <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"> <div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background border border-border/30">
<img <img
src={person.filmography[0].poster || person.photo} src={person.filmography[0].poster || person.photo}
alt={person.filmography[0].title} alt={person.filmography[0].title}
@@ -388,7 +392,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
{/* Pagination Controls */} {/* Pagination Controls */}
{filteredStaff.length > 0 && ( {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"> <div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground font-medium">Items per page:</span> <span className="text-sm text-muted-foreground font-medium">Items per page:</span>
<select <select
@@ -396,7 +400,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
onChange={(e) => { onChange={(e) => {
setItemsPerPage(Number(e.target.value)); 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 => ( {[12, 20, 36, 48, 60].map(size => (
<option key={size} value={size}>{size}</option> <option key={size} value={size}>{size}</option>
@@ -410,7 +414,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
size="sm" size="sm"
onClick={handlePrevPage} onClick={handlePrevPage}
disabled={currentPage === 1} 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} /> <ChevronLeft size={16} />
Previous Previous
@@ -427,7 +431,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
size="sm" size="sm"
onClick={handleNextPage} onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0} 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 Next
<ChevronRight size={16} /> <ChevronRight size={16} />

View 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>
);
}

View File

@@ -10,7 +10,10 @@ import {
ChevronRight, ChevronRight,
Search, Search,
ListFilter, ListFilter,
ChevronDown ChevronDown,
Calendar,
Clock,
Eye
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -28,6 +31,24 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
const [castLimit, setCastLimit] = useState(6); const [castLimit, setCastLimit] = useState(6);
const [showAllCast, setShowAllCast] = useState(false); const [showAllCast, setShowAllCast] = useState(false);
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set()); 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 // Group episodes by season
const episodesBySeason = useMemo(() => { 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 displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []);
const hasMoreCast = (media.staff?.length || 0) > castLimit; const hasMoreCast = (media.staff?.length || 0) > castLimit;
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Banner */} {/* Banner */}
<div className="relative h-[400px] w-full overflow-hidden"> <div className="relative h-[450px] w-full overflow-hidden">
<img <img
src={media.banner || media.poster} src={media.banner || media.poster}
alt={media.title} alt={media.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
referrerPolicy="no-referrer" 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 <button
onClick={() => navigate(-1)} 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} /> <ChevronLeft size={24} />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="max-w-[1400px] mx-auto px-6 -mt-32 relative z-10 pb-24"> <div className="max-w-[1920px] mx-auto px-6 py-8 pb-24 -mt-32 relative z-10">
<div className="flex flex-col md:flex-row gap-6"> <div className="flex flex-col lg:flex-row gap-8">
{/* Left Column: Poster + Metadata */} {/* Left Column: Cover Image */}
<div className="w-full md:w-[300px] shrink-0"> <div className="w-full lg:w-[400px] shrink-0">
<motion.div <motion.div
layoutId={`media-${media.id}`} 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 === '16/9' ? 'aspect-video' :
media.aspectRatio === '1/1' ? 'aspect-square' : media.aspectRatio === '1/1' ? 'aspect-square' :
'aspect-[2/3]' 'aspect-[2/3]'
@@ -108,188 +130,133 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</motion.div> </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> </div>
{/* Right Column: Info */} {/* Right Column: Info */}
<div className="flex-1 pt-4 md:pt-8"> <div className="flex-1">
<div className="flex flex-wrap items-end justify-between gap-4 mb-6"> {/* Header with tags */}
<div> <div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-4xl font-black text-foreground mb-2"> <h1 className="text-4xl lg:text-5xl font-black text-foreground">
{media.title} <span className="text-muted-foreground font-medium">({media.year})</span> {media.title}
</h1> </h1>
<div className="flex items-center gap-4"> {media.status && (
<div className="flex items-center gap-2"> <Badge className={
<Button size="icon" className="rounded-full bg-[#6d28d9] hover:bg-[#5b21b6]"> media.status === 'watching' || media.status === 'reading' || media.status === 'listening' || media.status === 'playing'
<Play size={20} fill="currentColor" /> ? 'bg-green-500/20 text-green-400 border-green-500/30 font-bold'
</Button> : media.status === 'completed'
<Button size="icon" variant="outline" className="rounded-full border-border"> ? 'bg-blue-500/20 text-blue-400 border-blue-500/30 font-bold'
<Bookmark size={20} /> : 'bg-gray-500/20 text-gray-400 border-gray-500/30 font-bold'
</Button> }>
<Button size="icon" variant="outline" className="rounded-full border-border"> {media.status.toUpperCase()}
<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}
</Badge> </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>
</div>
{/* Staff Section - Only show if staff data exists */} {/* Show Details */}
{media.staff && media.staff.length > 0 && ( <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
<section className="mt-20"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between mb-8"> <Calendar size={16} />
<h2 className="text-2xl font-black text-foreground">Cast & Crew</h2> <span>{media.year}</span>
<div className="flex items-center gap-4"> </div>
<span className="text-sm font-bold text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
{showAllCast ? media.staff.length : displayedCast.length} / {media.staff.length} <span>{media.status ? media.status.charAt(0).toUpperCase() + media.status.slice(1) : 'Unknown'}</span>
</span> </div>
{hasMoreCast && ( <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Button <Clock size={16} />
variant="outline" <span>{media.playtime ? `${media.playtime}h` : '12h 30m'}</span>
size="sm"
onClick={() => setShowAllCast(!showAllCast)}
className="rounded-full border-border font-bold"
>
{showAllCast ? 'Show Less' : 'Show All'}
<ChevronDown size={16} className={`ml-2 transition-transform ${showAllCast ? 'rotate-180' : ''}`} />
</Button>
)}
</div> </div>
</div> </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 <div
key={person.id} className="h-full bg-gradient-to-r from-[#6d28d9] to-[#8b5cf6] transition-all duration-500"
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" style={{ width: `${progress}%` }}
onClick={() => onPersonClick(person)} />
</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"> {tab}
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" referrerPolicy="no-referrer" /> </button>
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
</div>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-muted">
<img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
</div>
</div>
))} ))}
</div> </div>
</section>
)}
{/* Episodes Section - Only show if episodes data exists */} {/* Genre Tags */}
{media.episodes && media.episodes.length > 0 && ( {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"> <section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8"> <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-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' : ''} <span className="opacity-40">{media.episodes.length}</span> Episode{media.episodes.length !== 1 ? 's' : ''}
</div> </div>
<div className="text-sm font-bold text-muted-foreground"> <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="flex items-center gap-2">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} /> <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> </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} /> <MoreHorizontal size={20} />
</Button> </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} /> <ListFilter size={20} />
</Button> </Button>
</div> </div>
@@ -315,10 +282,10 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
.map(Number) .map(Number)
.sort((a, b) => a - b) .sort((a, b) => a - b)
.map(season => ( .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 <button
onClick={() => toggleSeason(season)} 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"> <div className="flex items-center gap-4">
<h3 className="text-2xl font-black text-foreground">Season {season}</h3> <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 => ( {episodesBySeason[season].map(episode => (
<div key={episode.id} className="group cursor-pointer"> <div key={episode.id} className="group cursor-pointer">
<div className="flex flex-col md:flex-row gap-6"> <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" /> <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>
<div className="flex-1 py-1"> <div className="flex-1 py-1">
<div className="flex items-center justify-between mb-2"> <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} E{episode.episode_number} {episode.title}
</h3> </h3>
<span className="text-xs font-bold text-muted-foreground">{episode.air_date} {episode.duration}m</span> <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> </p>
</div> </div>
</div> </div>
<Separator className="mt-6 bg-border" /> <Separator className="mt-6 bg-border/50" />
</div> </div>
))} ))}
</div> </div>
@@ -365,60 +332,47 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
</section> </section>
)} )}
{/* Tracks Section - Only show if tracks data exists (Music) */} {/* Tracks Section - Only show if tracks data exists and Tracks tab is active */}
{media.tracks && media.tracks.length > 0 && ( {media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
<section className="mt-20"> <section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8"> <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-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' : ''} <span className="opacity-40">{media.tracks.length}</span> Track{media.tracks.length !== 1 ? 's' : ''}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} /> <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> </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} /> <MoreHorizontal size={20} />
</Button> </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} /> <ListFilter size={20} />
</Button> </Button>
</div> </div>
</div> </div>
<div className="border border-border rounded-2xl overflow-hidden"> <div className="space-y-2">
<div className="divide-y divide-border"> {media.tracks.map(track => (
{media.tracks <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">
.sort((a, b) => a.track_number - b.track_number) <span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
.map((track, index) => ( <div className="flex-1">
<div key={track.id} className="group cursor-pointer hover:bg-muted/50 transition-colors"> <h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
<div className="flex items-center gap-4 p-4"> {track.title}
<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"> </h3>
{track.track_number} <p className="text-sm text-muted-foreground">{track.artist}</p>
</div> </div>
<div className="flex-1 min-w-0"> <span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span>
<h3 className="font-bold text-foreground group-hover:text-[#6d28d9] transition-colors truncate"> </div>
{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> </div>
</section> </section>
)} )}
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -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 { cn } from '@/lib/utils';
import React, { useState, useEffect } from 'react'; 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 { MediaCategory } from '@/types';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
@@ -25,7 +25,21 @@ export default function Header({
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { theme } = useTheme(); 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(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
@@ -53,70 +67,91 @@ export default function Header({
return ( return (
<header <header
className={cn( 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 transparent && !scrolled
? "bg-transparent" ? "bg-transparent"
: transparent && scrolled : transparent && scrolled
? "backdrop-blur-md bg-background/80 border-b border-border/50" ? "backdrop-blur-xl bg-background/70 border-b border-border/30"
: "bg-[#6d28d9]" : "backdrop-blur-xl bg-gradient-to-r from-[#6d28d9]/90 via-[#8b5cf6]/90 to-[#6d28d9]/90 border-b border-white/10"
)} )}
> >
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<Link <Link
to="/" to="/"
className={cn( 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" (transparent && !scrolled) || !transparent ? "text-white" : "text-foreground"
)} )}
> >
<div className={cn( <div className={cn(
"w-6 h-6 rounded-full flex items-center justify-center", "w-8 h-8 rounded-xl flex items-center justify-center shadow-lg transition-all duration-300",
(transparent && !scrolled) || !transparent ? "bg-white" : "bg-[#6d28d9]" (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( <div className={cn(
"w-3 h-3 rounded-full", "w-4 h-4 rounded-full",
(transparent && !scrolled) || !transparent ? "bg-[#6d28d9]" : "bg-white" (transparent && !scrolled) || !transparent ? "bg-white" : "bg-white"
)} /> )} />
</div> </div>
kyoo <span className="bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
omnyx
</span>
</Link> </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 => ( {enabledCategories.map(cat => (
<button <NavLink
key={cat} key={cat}
onClick={() => onCategoryChange(cat)} to={`/${categoryPaths[cat]}`}
className={cn( 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 relative",
(transparent && !scrolled) || !transparent (transparent && !scrolled) || !transparent
? activeCategory === cat ? "text-white" : "text-white/60 hover:text-white" ? isActive
: activeCategory === cat ? "text-foreground" : "text-muted-foreground hover:text-foreground" ? "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} {cat}
</button> </NavLink>
))} ))}
<div className={cn( <div className={cn(
"w-px h-4 mx-2", "w-px h-6 mx-2",
(transparent && !scrolled) || !transparent ? "bg-white/20" : "bg-border" (transparent && !scrolled) || !transparent ? "bg-white/20" : "bg-border"
)} /> )} />
<NavLink <NavLink
to="/cast" to="/cast"
className={({ isActive }) => cn( 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 (transparent && !scrolled) || !transparent
? isActive ? "text-white" : "text-white/60 hover:text-white" ? isActive ? "text-white bg-white/10" : "text-white/70 hover:text-white hover:bg-white/5"
: isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground" : isActive ? "text-foreground bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)} )}
> >
CAST CAST
</NavLink> </NavLink>
</nav> </nav>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-2">
<div className={cn( <div className={cn(
"flex items-center transition-all duration-300 overflow-hidden", "flex items-center transition-all duration-300 overflow-hidden rounded-2xl",
isSearchOpen ? "w-48 md:w-64 rounded-full px-3 py-1" : "w-0", isSearchOpen ? "w-48 md:w-72 px-4 py-2.5" : "w-0",
(transparent && !scrolled) || !transparent ? "bg-white/10" : "bg-muted" (transparent && !scrolled) || !transparent
? "bg-white/10 backdrop-blur-md border border-white/20"
: "bg-muted/50 backdrop-blur-md border border-border"
)}> )}>
<input <input
type="text" type="text"
@@ -124,9 +159,9 @@ export default function Header({
value={searchQuery} value={searchQuery}
onChange={handleSearchChange} onChange={handleSearchChange}
className={cn( 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 (transparent && !scrolled) || !transparent
? "text-white placeholder:text-white/50" ? "text-white placeholder:text-white"
: "text-foreground placeholder:text-muted-foreground" : "text-foreground placeholder:text-muted-foreground"
)} )}
autoFocus={isSearchOpen} autoFocus={isSearchOpen}
@@ -135,50 +170,52 @@ export default function Header({
<button <button
onClick={toggleSearch} onClick={toggleSearch}
className={cn( className={cn(
"p-2 transition-colors", "p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent (transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white" ? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground" : "text-foreground hover:text-foreground hover:bg-muted"
)} )}
> >
{isSearchOpen ? <X size={20} /> : <Search size={20} />} {isSearchOpen ? <X size={18} /> : <Search size={18} />}
</button> </button>
<Link <Link
to="/add" to="/add"
className={cn( className={cn(
"p-2 transition-colors", "p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent (transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white" ? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground" : "text-foreground hover:text-foreground hover:bg-muted"
)} )}
> >
<Plus size={20} /> <Plus size={18} />
</Link> </Link>
<Link <Link
to="/import" to="/import"
className={cn( className={cn(
"p-2 transition-colors", "p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent (transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white" ? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground" : "text-foreground hover:text-foreground hover:bg-muted"
)} )}
> >
<Download size={20} /> <Download size={18} />
</Link> </Link>
<Link <Link
to="/settings" to="/settings"
className={cn( className={cn(
"p-2 transition-colors", "p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent (transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white" ? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground" : "text-foreground hover:text-foreground hover:bg-muted"
)} )}
> >
<Settings size={20} /> <Settings size={18} />
</Link> </Link>
<button className={cn( <button className={cn(
"w-8 h-8 rounded-full overflow-hidden border-2", "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/20" : "border-border" (transparent && !scrolled) || !transparent
? "border-white/30 hover:border-white/50"
: "border-border hover:border-[#6d28d9]/50"
)}> )}>
<img <img
src="https://picsum.photos/seed/user/100/100" src="https://picsum.photos/seed/user/100/100"
@@ -188,6 +225,38 @@ export default function Header({
/> />
</button> </button>
</div> </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> </header>
); );
} }

View File

@@ -341,7 +341,7 @@ export default function ImporterView() {
}; };
return ( 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 */} {/* Header */}
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -349,12 +349,12 @@ export default function ImporterView() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate('/')} 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} /> <ArrowLeft size={20} />
</Button> </Button>
<div> <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> <p className="text-sm text-muted-foreground font-medium">Import media from external platforms</p>
</div> </div>
</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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{/* XBVR Importer Card */} {/* XBVR Importer Card */}
{xbvrConfig.url && ( {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-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center"> <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 */} {/* StashAPP Importer Card */}
{stashappConfig.url && ( {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-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center"> <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 */} {/* StashAPP Actor Updater Card */}
{stashappConfig.url && ( {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-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center"> <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 */} {/* Playnite Importer Card */}
{playniteConfig.ip && playniteConfig.apiToken && ( {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-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center"> <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 */} {/* Jellyfin Importer Card */}
{jellyfinConfig.url && ( {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-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center"> <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 */} {/* Jellyfin Cleanup Card */}
{jellyfinConfig.url && ( {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-start justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center"> <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 Section */}
{progress.stage !== 'idle' && ( {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 justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{progress.stage === 'complete' ? ( {progress.stage === 'complete' ? (
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center"> <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-600" size={20} /> <CheckCircle className="text-green-500" size={20} />
</div> </div>
) : progress.stage === 'error' ? ( ) : progress.stage === 'error' ? (
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center"> <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-600" size={20} /> <XCircle className="text-red-500" size={20} />
</div> </div>
) : ( ) : (
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center"> <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-muted-foreground animate-spin" size={20} /> <Loader2 className="text-purple-500 animate-spin" size={20} />
</div> </div>
)} )}
<div> <div>
@@ -968,7 +968,7 @@ export default function ImporterView() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={resetImport} 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} /> <RefreshCw size={16} />
Reset Reset
@@ -983,7 +983,7 @@ export default function ImporterView() {
<div <div
className={cn( className={cn(
"h-full transition-all duration-300 ease-out", "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()}%` }} style={{ width: `${getProgressPercentage()}%` }}
/> />
@@ -997,9 +997,9 @@ export default function ImporterView() {
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6"> <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"> <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"> <span className="text-xs font-bold text-muted-foreground">
{(progress as any).gamesImported !== undefined ? 'Games' : {(progress as any).gamesImported !== undefined ? 'Games' :
(progress as any).moviesImported !== undefined ? 'Movies' : (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} (progress as any).musicImported !== undefined ? (progress as any).musicImported : progress.videosImported}
</p> </p>
</div> </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"> <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> <span className="text-xs font-bold text-muted-foreground">{(progress as any).castImported !== undefined ? 'Cast' : 'Actors'}</span>
</div> </div>
<p className="text-2xl font-black text-foreground">{(progress as any).castImported !== undefined ? (progress as any).castImported : progress.actorsImported}</p> <p className="text-2xl font-black text-foreground">{(progress as any).castImported !== undefined ? (progress as any).castImported : progress.actorsImported}</p>
</div> </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"> <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> <span className="text-xs font-bold text-muted-foreground">Errors</span>
</div> </div>
<p className="text-2xl font-black text-foreground">{progress.errors.length}</p> <p className="text-2xl font-black text-foreground">{progress.errors.length}</p>
@@ -1034,7 +1034,7 @@ export default function ImporterView() {
{importLog.length > 0 && ( {importLog.length > 0 && (
<div <div
ref={logContainerRef} 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"> <pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">
{importLog.join('\n')} {importLog.join('\n')}
@@ -1045,10 +1045,10 @@ export default function ImporterView() {
{/* Errors */} {/* Errors */}
{progress.errors.length > 0 && ( {progress.errors.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<h4 className="text-sm font-bold text-red-600 mb-2">Errors</h4> <h4 className="text-sm font-bold text-red-500 mb-2">Errors</h4>
<div className="bg-red-50 border border-red-200 rounded-lg p-3 max-h-32 overflow-y-auto"> <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) => ( {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} {error}
</p> </p>
))} ))}

View File

@@ -21,6 +21,7 @@ interface LibrarySettingsProps {
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = { const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
Anime: <Tv size={18} />, Anime: <Tv size={18} />,
Movies: <Film size={18} />, Movies: <Film size={18} />,
'TV Series': <Tv size={18} />,
Music: <Music size={18} />, Music: <Music size={18} />,
Books: <Book size={18} />, Books: <Book size={18} />,
Consoles: <Gamepad2 size={18} />, Consoles: <Gamepad2 size={18} />,
@@ -34,29 +35,29 @@ export default function LibrarySettings({ enabledCategories, onToggleCategory }:
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <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} /> <Settings size={20} />
</button> </button>
</DialogTrigger> </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> <DialogHeader>
<DialogTitle className="text-2xl font-black text-zinc-900">Library Settings</DialogTitle> <DialogTitle className="text-2xl font-black text-foreground">Library Settings</DialogTitle>
<DialogDescription className="text-zinc-500 font-medium"> <DialogDescription className="text-muted-foreground font-medium">
Toggle which media areas you want to see in your library. Toggle which media areas you want to see in your library.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-6 py-6"> <div className="grid gap-6 py-6">
{categories.map((category) => ( {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="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]} {CATEGORY_ICONS[category]}
</div> </div>
<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} {category}
</Label> </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'} {enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
</p> </p>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { Media } from '@/types'; import { Media } from '@/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Star } from 'lucide-react';
interface MediaCardProps { interface MediaCardProps {
key?: string; key?: string;
@@ -48,34 +49,58 @@ export default function MediaCard({ media, onClick }: MediaCardProps) {
layoutId={`media-${media.id}`} layoutId={`media-${media.id}`}
className="group cursor-pointer" className="group cursor-pointer"
onClick={() => onClick(media)} onClick={() => onClick(media)}
whileHover={{ y: -4 }} whileHover={{ y: -8, scale: 1.02 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.3, ease: "easeOut" }}
> >
<div className={cn( <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 aspectRatioClass
)}> )}>
<img <img
src={media.poster} src={media.poster}
alt={media.title} 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" 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 && ( {media.status && (
<div className={cn( <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] 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>
<div className="mt-3 space-y-1"> <div className="mt-4 space-y-1.5">
<h3 className="text-sm font-bold text-foreground line-clamp-1 group-hover:text-[#6d28d9] transition-colors"> <h3 className="text-sm font-bold text-foreground line-clamp-2 group-hover:text-[#6d28d9] transition-colors duration-300">
{media.title} {media.title}
</h3> </h3>
<p className="text-xs font-medium text-muted-foreground"> <div className="flex items-center gap-2">
{media.year} <p className="text-xs font-medium text-muted-foreground">
</p> {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> </div>
</motion.div> </motion.div>
); );

View File

@@ -44,11 +44,11 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} 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)} onClick={() => onClick(media)}
> >
<div className={cn( <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 aspectRatioClass
)}> )}>
<img <img
@@ -57,6 +57,7 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
className="w-full h-full object-cover" className="w-full h-full object-cover"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
{media.status && ( {media.status && (
<div className={cn( <div className={cn(
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm", "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-1 min-w-0">
<div className="flex items-center gap-3 mb-1"> <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} {media.title}
</h3> </h3>
<span className="text-sm font-bold text-muted-foreground">({media.year})</span> <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>
<div className="hidden md:flex items-center gap-2"> <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" /> <Play size={18} fill="currentColor" />
</Button> </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} /> <Bookmark size={18} />
</Button> </Button>
</div> </div>

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { MediaCategory, UserSettings } from '@/types'; import { MediaCategory, UserSettings, CustomColors } from '@/types';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; 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 { Link } from 'react-router-dom';
import { fetchSettings, updateSettings } from '@/api'; import { fetchSettings, updateSettings } from '@/api';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
@@ -47,6 +47,12 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle'); 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(() => { useEffect(() => {
loadSettings(); loadSettings();
}, []); }, []);
@@ -56,6 +62,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
const loadedSettings = await fetchSettings(); const loadedSettings = await fetchSettings();
if (loadedSettings) { if (loadedSettings) {
setSettings(loadedSettings); setSettings(loadedSettings);
setPageTitle(loadedSettings.pageTitle || '');
setFavicon(loadedSettings.favicon || '');
setCustomColors(loadedSettings.customColors || {});
setFaviconPreview(loadedSettings.favicon || '');
} }
} catch (error) { } catch (error) {
console.error('Failed to load settings:', error); console.error('Failed to load settings:', error);
@@ -68,7 +78,13 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
setIsSaving(true); setIsSaving(true);
setSaveStatus('idle'); setSaveStatus('idle');
try { 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) { if (savedSettings) {
setSettings(savedSettings); setSettings(savedSettings);
setSaveStatus('success'); 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) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-background flex items-center justify-center"> <div className="min-h-screen bg-background flex items-center justify-center">
@@ -107,22 +148,22 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
return ( return (
<div className="min-h-screen bg-background pt-20"> <div className="min-h-screen bg-background pt-20">
{/* Content */} {/* 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 className="flex items-center justify-between mb-8">
<div> <div>
<Link <Link
to="/" 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} /> <ArrowLeft size={16} />
Back to home Back to home
</Link> </Link>
<h1 className="text-3xl font-black text-foreground">Settings</h1> <h1 className="text-4xl font-black text-foreground">Settings</h1>
</div> </div>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isSaving} 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 ? ( {isSaving ? (
'Saving...' 'Saving...'
@@ -136,12 +177,12 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
</div> </div>
{saveStatus === 'success' && ( {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! Settings saved successfully!
</div> </div>
)} )}
{saveStatus === 'error' && ( {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. Failed to save settings. Please try again.
</div> </div>
)} )}
@@ -149,16 +190,16 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
<div className="grid gap-8"> <div className="grid gap-8">
{/* Library Settings */} {/* Library Settings */}
<section> <section>
<h2 className="text-xl font-black text-foreground mb-6">Library Settings</h2> <h2 className="text-2xl font-black text-foreground mb-6">Library Settings</h2>
<div className="bg-muted/50 rounded-2xl p-6 border border-border"> <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"> <p className="text-sm font-medium text-muted-foreground mb-4">
Toggle which media areas you want to see in your library. Toggle which media areas you want to see in your library.
</p> </p>
<div className="grid gap-4"> <div className="grid gap-4">
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => ( {(['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="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]} {CATEGORY_ICONS[category]}
</div> </div>
<div> <div>
@@ -183,8 +224,8 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
{/* Display Settings */} {/* Display Settings */}
<section> <section>
<h2 className="text-xl font-black text-foreground mb-6">Display Settings</h2> <h2 className="text-2xl font-black text-foreground mb-6">Display Settings</h2>
<div className="bg-muted/50 rounded-2xl p-6 border border-border space-y-6"> <div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50 space-y-6">
{/* Items per page */} {/* Items per page */}
<div> <div>
<Label className="text-sm font-black text-foreground mb-2 block">Items per page</Label> <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 <button
key={option} key={option}
onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: 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 settings.itemsPerPage === option
? 'bg-[#6d28d9] text-white' ? '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' : 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`} }`}
> >
{option} {option}
@@ -211,10 +252,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))} 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' settings.defaultView === 'grid'
? 'bg-[#6d28d9] text-white' ? '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' : 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`} }`}
> >
<LayoutGrid size={18} /> <LayoutGrid size={18} />
@@ -222,10 +263,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
</button> </button>
<button <button
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'list' }))} 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' settings.defaultView === 'list'
? 'bg-[#6d28d9] text-white' ? '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' : 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`} }`}
> >
<List size={18} /> <List size={18} />
@@ -260,10 +301,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
<button <button
key={theme} key={theme}
onClick={() => setSettings(prev => ({ ...prev, 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 settings.theme === theme
? 'bg-[#6d28d9] text-white' ? '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' : 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`} }`}
> >
{theme === 'light' && <Sun size={18} />} {theme === 'light' && <Sun size={18} />}
@@ -279,10 +320,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
{/* Content Settings */} {/* Content Settings */}
<section> <section>
<h2 className="text-xl font-black text-foreground mb-6">Content Settings</h2> <h2 className="text-2xl font-black text-foreground mb-6">Content Settings</h2>
<div className="bg-muted/50 rounded-2xl p-6 border border-border space-y-4"> <div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50 space-y-4">
{/* Show adult content */} {/* 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> <div>
<Label htmlFor="showAdult" className="text-sm font-black text-foreground cursor-pointer"> <Label htmlFor="showAdult" className="text-sm font-black text-foreground cursor-pointer">
Show adult content Show adult content
@@ -299,7 +340,7 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
</div> </div>
{/* Auto-play trailers */} {/* 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> <div>
<Label htmlFor="autoPlay" className="text-sm font-black text-foreground cursor-pointer"> <Label htmlFor="autoPlay" className="text-sm font-black text-foreground cursor-pointer">
Auto-play trailers Auto-play trailers
@@ -319,8 +360,8 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
{/* Language Settings */} {/* Language Settings */}
<section> <section>
<h2 className="text-xl font-black text-foreground mb-6">Language</h2> <h2 className="text-2xl font-black text-foreground mb-6">Language</h2>
<div className="bg-muted/50 rounded-2xl p-6 border border-border"> <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"> <div className="flex items-center gap-2 mb-4">
<Globe size={18} className="text-[#6d28d9]" /> <Globe size={18} className="text-[#6d28d9]" />
<Label className="text-sm font-black text-foreground">Interface language</Label> <Label className="text-sm font-black text-foreground">Interface language</Label>
@@ -330,10 +371,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
<button <button
key={option.value} key={option.value}
onClick={() => setSettings(prev => ({ ...prev, language: 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 settings.language === option.value
? 'bg-[#6d28d9] text-white' ? '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' : 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`} }`}
> >
{option.label} {option.label}
@@ -342,6 +383,115 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
</div> </div>
</div> </div>
</section> </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> </div>
</div> </div>

212
src/components/Sidebar.tsx Normal file
View 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>
</>
);
}

View 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={[]}
/>
);
}

View 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}
/>
);
}

View 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
View 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,
};

View File

@@ -127,7 +127,13 @@ export const MOCK_MEDIA: Media[] = [
studios: ['Example Studio'], studios: ['Example Studio'],
} }
]; ];
export const DETAIL_MEDIA: Media = {} export const DETAIL_MEDIA: Media = {
id: '',
title: '',
year: '',
poster: '',
category: 'Movies'
}
/* /*
export const DETAIL_MEDIA: Media = { export const DETAIL_MEDIA: Media = {
id: 'mob-psycho', id: 'mob-psycho',

View File

@@ -83,7 +83,7 @@
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--radius: 0.625rem; --radius: 0.75rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.205 0 0);
@@ -92,40 +92,60 @@
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 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 { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.12 0.01 264);
--foreground: oklch(0.985 0 0); --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); --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); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 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); --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); --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); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(0.985 0 0 / 15%);
--input: oklch(1 0 0 / 15%); --input: oklch(0.985 0 0 / 20%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 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-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 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); --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 { @layer base {
@@ -138,4 +158,41 @@
html { html {
@apply font-sans; @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);
}
} }

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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
View 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
View 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
View 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;
}
}

View 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
View 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> {}

View File

@@ -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; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping // Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING } from '@/types'; import { SOURCE_CATEGORY_MAPPING, Media, Staff, Episode, Track } from '@/types';
/**
* Configuration for connecting to a Jellyfin instance
*/
export interface JellyfinConfig { export interface JellyfinConfig {
/** URL of the Jellyfin server */
url: string; url: string;
/** API key for authentication with Jellyfin */
apiKey: string; apiKey: string;
} }
/**
* Mapping configuration for Jellyfin libraries to Omnyx categories
*/
export interface LibraryMapping { export interface LibraryMapping {
/** Name of the Jellyfin library */
libraryName: string; libraryName: string;
/** Category to map this library to (use 'skip' to exclude the library) */
category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip'; 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 { export interface JellyfinImportOptions {
/** Whether to import movies */
importMovies?: boolean; importMovies?: boolean;
/** Whether to import TV series */
importSeries?: boolean; importSeries?: boolean;
/** Whether to import music */
importMusic?: boolean; importMusic?: boolean;
/** Whether to import cast members */
importCast?: boolean; importCast?: boolean;
/** Maximum number of items to import (optional) */
limit?: number; limit?: number;
/** Library to category mappings */
libraryMappings?: LibraryMapping[]; 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 { export interface ImportProgress {
/** Current number of items processed */
current: number; current: number;
/** Total number of items to process */
total: number; total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string; message: string;
/** Number of movies successfully imported */
moviesImported: number; moviesImported: number;
/** Number of series successfully imported */
seriesImported: number; seriesImported: number;
/** Number of music items successfully imported */
musicImported: number; musicImported: number;
/** Number of cast members successfully imported */
castImported: number; castImported: number;
/** Array of error messages encountered during import */
errors: string[]; errors: string[];
} }
@@ -56,7 +100,7 @@ export interface JellyfinItem {
Type: string; Type: string;
Role?: string; Role?: string;
PrimaryImageTag?: string; PrimaryImageTag?: string;
ImageBlurHashes?: any; ImageBlurHashes?: Record<string, Record<string, string>>;
}>; }>;
ImageTags?: { ImageTags?: {
Primary?: string; Primary?: string;
@@ -96,7 +140,7 @@ export interface JellyfinPerson {
Name: string; Name: string;
Type: string; Type: string;
PrimaryImageTag?: string; PrimaryImageTag?: string;
ImageBlurHashes?: any; ImageBlurHashes?: Record<string, Record<string, string>>;
PremiereDate?: string; PremiereDate?: string;
ProductionYear?: number; ProductionYear?: number;
Overview?: string; Overview?: string;
@@ -105,10 +149,45 @@ export interface JellyfinPerson {
PlaceOfBirth?: string; 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; 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; 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 { function normalizeUrl(url: string): string {
return url.replace(/\/+$/, ''); 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}`; 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 { function ticksToMinutes(ticks: number): number {
return Math.floor(ticks / 600000000); 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 { function formatDate(dateString?: string): string | null {
if (!dateString) return null; if (!dateString) return null;
try { 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 { function getYear(dateString?: string): number {
if (!dateString) return new Date().getFullYear(); if (!dateString) return new Date().getFullYear();
try { try {
@@ -219,7 +310,11 @@ async function fetchWithAuth(url: string, apiKey: string, options: RequestInit =
return fetch(url, { ...options, headers }); 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 }>> { export async function fetchJellyfinLibraries(config: JellyfinConfig): Promise<Array<{ Id: string; Name: string; CollectionType: string }>> {
const userId = await getJellyfinUserId(config); 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) || []; const writers = item.People?.filter(p => p.Type === 'Writer').map(p => p.Name) || [];
// Fetch episodes for this series // Fetch episodes for this series
let episodes: any[] = []; let episodes: Episode[] = [];
try { try {
const jellyfinEpisodes = await fetchJellyfinSeriesEpisodes(config, item.Id); const jellyfinEpisodes = await fetchJellyfinSeriesEpisodes(config, item.Id);
episodes = jellyfinEpisodes.map(ep => ({ episodes = jellyfinEpisodes.map(ep => ({
id: parseInt(ep.Id),
media_id: parseInt(item.Id),
season: ep.ParentIndexNumber || 1, season: ep.ParentIndexNumber || 1,
episode_number: ep.IndexNumber || 1, episode_number: ep.IndexNumber || 1,
title: ep.Name, title: ep.Name,
@@ -682,14 +779,16 @@ async function convertJellyfinAlbumToMedia(
})); }));
// Fetch tracks for this album // Fetch tracks for this album
let tracks: any[] = []; let tracks: Track[] = [];
try { try {
const jellyfinTracks = await fetchJellyfinAlbumTracks(config, item.Id); const jellyfinTracks = await fetchJellyfinAlbumTracks(config, item.Id);
tracks = jellyfinTracks.map((track, index) => ({ tracks = jellyfinTracks.map((track, index) => ({
id: parseInt(track.Id),
media_id: parseInt(item.Id),
track_number: track.IndexNumber || (index + 1), track_number: track.IndexNumber || (index + 1),
title: track.Name, title: track.Name,
duration: track.RunTimeTicks ? `${Math.floor(track.RunTimeTicks / 600000000 / 60)}:${String(Math.floor((track.RunTimeTicks / 600000000) % 60)).padStart(2, '0')}` : null, duration: track.RunTimeTicks ? Math.floor(track.RunTimeTicks / 600000000) : null,
artist: track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown' artist: (track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown') as string
})); }));
} catch (error) { } catch (error) {
console.warn(`Failed to fetch tracks for album ${item.Name}:`, 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 // 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 const photo = person.PrimaryImageTag
? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary') ? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary')
: null; : null;
return { return {
id: person.Id,
name: person.Name, name: person.Name,
role: person.Type || 'Actor',
photo: photo, photo: photo,
bio: person.Overview || null, bio: person.Overview || null,
birthDate: person.BirthDate ? formatDate(person.BirthDate) : null, birthDate: person.BirthDate ? formatDate(person.BirthDate) : null,
birthPlace: person.PlaceOfBirth || 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( export async function importFromJellyfin(
config: JellyfinConfig, config: JellyfinConfig,
options: JellyfinImportOptions, options: JellyfinImportOptions,
@@ -767,19 +895,19 @@ export async function importFromJellyfin(
logCallback('Starting Jellyfin import...'); logCallback('Starting Jellyfin import...');
// Step 0: Fetch existing media and cast to check for duplicates // 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 existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
const existingMediaData = await existingMediaResponse.json(); const existingMediaData = await existingMediaResponse.json();
const existingMedia = new Map( 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(`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 existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
const existingCastData = await existingCastResponse.json(); const existingCastData = await existingCastResponse.json();
const existingCast = new Map( 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`); logCallback(`Found ${existingCast.size} existing cast members in database`);
@@ -1169,18 +1297,18 @@ export async function cleanupJellyfinMedia(
try { try {
logCallback('Starting Jellyfin cleanup...'); logCallback('Starting Jellyfin cleanup...');
// Fetch all existing media from Kyoo API // Fetch all existing media from Omnyx API
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 existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
const existingMediaData = await existingMediaResponse.json(); 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`); logCallback(`Found ${jellyfinMedia.length} Jellyfin media items in database`);
// Fetch all existing cast from Kyoo API // Fetch all existing cast from Omnyx API
logCallback('Fetching existing cast from Kyoo API...'); logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`); const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
const existingCastData = await existingCastResponse.json(); 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`); logCallback(`Found ${jellyfinCast.length} Jellyfin cast members in database`);
// Fetch current items from Jellyfin // Fetch current items from Jellyfin

View File

@@ -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; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping // Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING } from '@/types'; import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to a Playnite instance
*/
export interface PlayniteConfig { export interface PlayniteConfig {
/** IP address of the Playnite server */
ip: string; ip: string;
/** API token for authentication with Playnite */
apiToken: string; apiToken: string;
/** Port number of the Playnite API (default: 19821) */
port?: number; port?: number;
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean; updateExisting?: boolean;
} }
/**
* Progress tracking for the import operation
*/
export interface ImportProgress { export interface ImportProgress {
/** Current number of items processed */
current: number; current: number;
/** Total number of items to process */
total: number; total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string; message: string;
/** Number of games successfully imported */
gamesImported: number; gamesImported: number;
/** Array of error messages encountered during import */
errors: string[]; errors: string[];
} }
/**
* Game data structure as returned by the Playnite API
*/
export interface PlayniteGame { export interface PlayniteGame {
/** Unique identifier for the game */
id: string; id: string;
/** Game name */
name: string; name: string;
/** Alternate name for sorting purposes */
sortingName?: string; sortingName?: string;
/** Game description */
description?: string; description?: string;
/** User notes */
notes?: string; notes?: string;
/** Game version */
version?: string; version?: string;
/** Whether the game is hidden */
hidden?: boolean; hidden?: boolean;
/** Whether the game is marked as favorite */
favorite?: boolean; favorite?: boolean;
/** User rating (0-100) */
userScore?: number; userScore?: number;
/** Community rating (0-100) */
communityScore?: number; communityScore?: number;
/** Critic rating (0-100) */
criticScore?: number; criticScore?: number;
/** Release date in ISO format */
releaseDate?: string; releaseDate?: string;
/** Completion status (e.g., 'Completed', 'Playing', 'Abandoned') */
completionStatus?: string; completionStatus?: string;
/** Game categories */
categories?: string[]; categories?: string[];
/** Game tags */
tags?: string[]; tags?: string[];
/** Game features */
features?: string[]; features?: string[];
/** Game genres */
genres?: string[]; genres?: string[];
/** Developer names */
developers?: string[]; developers?: string[];
/** Publisher names */
publishers?: string[]; publishers?: string[];
/** Series name */
series?: string[]; series?: string[];
/** Platform names */
platforms?: string[]; platforms?: string[];
/** Age rating names */
ageRatings?: string[]; ageRatings?: string[];
/** Region names */
regions?: string[]; regions?: string[];
/** External links */
links?: Array<{ links?: Array<{
name: string; name: string;
url: string; url: string;
}>; }>;
/** Total playtime in seconds */
playtime?: number; playtime?: number;
/** Number of times played */
playCount?: number; playCount?: number;
/** Last activity timestamp */
lastActivity?: string; lastActivity?: string;
/** Date added to library */
added?: string; added?: string;
/** Last played date */
lastPlayed?: string; lastPlayed?: string;
/** Source platform/library */
source?: string; source?: string;
/** Whether the game is currently installed */
isInstalled?: boolean; 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 { export interface PlayniteGamesResponse {
/** Total number of games available */
total: number; total: number;
/** Offset for pagination */
offset: number; offset: number;
/** Limit for pagination */
limit: number; limit: number;
/** Array of game objects */
games: PlayniteGame[]; games: PlayniteGame[];
} }
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void; 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> { async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
try { try {
const coverResponse = await fetch(`${baseUrl}/api/games/${gameId}/cover`, { 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( export async function importFromPlaynite(
config: PlayniteConfig, config: PlayniteConfig,
logCallback: LogCallback, logCallback: LogCallback,
@@ -113,11 +264,11 @@ export async function importFromPlaynite(
logCallback('Starting Playnite import...'); logCallback('Starting Playnite import...');
// Step 0: Fetch existing media to check for duplicates and enable updates // 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 existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
const existingMediaData = await existingMediaResponse.json(); const existingMediaData = await existingMediaResponse.json();
const existingMedia = new Map( 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`); logCallback(`Found ${existingMedia.size} existing games in database`);
@@ -159,6 +310,18 @@ export async function importFromPlaynite(
if (detailResponse.ok) { if (detailResponse.ok) {
const detailData: PlayniteGame = await detailResponse.json(); 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); detailedGames.push(detailData);
logCallback(`✓ Fetched details for: ${game.name}`); logCallback(`✓ Fetched details for: ${game.name}`);
} else { } else {
@@ -231,7 +394,7 @@ export async function importFromPlaynite(
} }
// Staff is for actors/performers only - leave empty for games // Staff is for actors/performers only - leave empty for games
const staff: any[] = []; const staff: Staff[] = [];
// Determine type based on genres/features // Determine type based on genres/features
let type = 'Game'; let type = 'Game';
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) { //if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {

View File

@@ -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; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping // Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING } from '@/types'; import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to a StashAPP instance
*/
export interface StashAPPConfig { export interface StashAPPConfig {
/** URL of the StashAPP server */
url: string; url: string;
/** API key for authentication (optional) */
apiKey?: string; 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; updateExisting?: boolean;
} }
/**
* Progress tracking for the import operation
*/
export interface ImportProgress { export interface ImportProgress {
/** Current number of items processed */
current: number; current: number;
/** Total number of items to process */
total: number; total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string; message: string;
/** Number of videos successfully imported */
videosImported: number; videosImported: number;
/** Number of actors successfully imported */
actorsImported: number; actorsImported: number;
/** Array of error messages encountered during import */
errors: string[]; errors: string[];
} }
/**
* Scene data structure as returned by the StashAPP GraphQL API
*/
export interface StashAPPScene { export interface StashAPPScene {
/** Unique identifier for the scene */
id: string; id: string;
/** Scene title */
title: string; title: string;
/** Scene description/details */
details: string; details: string;
/** Scene URL */
url: string; url: string;
/** Release date in ISO format */
date: string; date: string;
/** Rating on a 0-100 scale */
rating100: number; rating100: number;
/** Whether the scene is organized */
organized: boolean; organized: boolean;
/** O-counter value */
o_counter: number; o_counter: number;
/** Creation timestamp */
created_at: string; created_at: string;
/** Last update timestamp */
updated_at: string; updated_at: string;
/** File paths for various media assets */
paths: { paths: {
screenshot: string; screenshot: string;
preview: string; preview: string;
@@ -41,6 +82,7 @@ export interface StashAPPScene {
funscript: string; funscript: string;
caption: string; caption: string;
}; };
/** Array of file information */
files: Array<{ files: Array<{
size: number; size: number;
duration: number; duration: number;
@@ -50,6 +92,7 @@ export interface StashAPPScene {
height: number; height: number;
path: string; path: string;
}>; }>;
/** Array of performers in the scene */
performers: Array<{ performers: Array<{
id: string; id: string;
name: string; name: string;
@@ -81,7 +124,30 @@ export interface StashAPPScene {
export interface StashAPPScenePerformer { export interface StashAPPScenePerformer {
id: string; id: string;
name: 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; image_path: string;
scene_count: number;
} }
export interface StashAPPPerformer { 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; 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; 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 { function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
if (!blacklist || blacklist.length === 0) { if (!blacklist || blacklist.length === 0) {
return false; return false;
@@ -141,6 +222,17 @@ function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
return blacklist.some(pattern => filePath.includes(pattern)); 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( export async function updateActorsFromStashAPP(
config: StashAPPConfig, config: StashAPPConfig,
logCallback: LogCallback, logCallback: LogCallback,
@@ -159,12 +251,12 @@ export async function updateActorsFromStashAPP(
try { try {
logCallback('Starting StashAPP actor update...'); logCallback('Starting StashAPP actor update...');
// Fetch existing cast from Kyoo API // Fetch existing cast from Omnyx API
logCallback('Fetching existing cast from Kyoo API...'); logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`); const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
const existingCastData = await existingCastResponse.json(); const existingCastData = await existingCastResponse.json();
const existingActors = new Map( const existingActors = new Map<string, Staff>(
(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`); logCallback(`Found ${existingActors.size} existing actors in database`);
@@ -249,12 +341,12 @@ export async function updateActorsFromStashAPP(
for (let i = 0; i < performers.length; i++) { for (let i = 0; i < performers.length; i++) {
const performer = performers[i]; const performer = performers[i];
const existingActor: any = existingActors.get(performer.name); const existingActor: Staff | undefined = existingActors.get(performer.name);
try { try {
if (existingActor) { if (existingActor) {
// Update existing actor // Update existing actor
const updateData: any = { const updateData: Partial<Staff> = {
name: performer.name, 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( export async function importFromStashAPP(
config: StashAPPConfig, config: StashAPPConfig,
logCallback: LogCallback, logCallback: LogCallback,
@@ -382,19 +499,19 @@ export async function importFromStashAPP(
logCallback('Starting StashAPP import...'); logCallback('Starting StashAPP import...');
// Step 0: Fetch existing media and cast to check for duplicates // 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 existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
const existingMediaData = await existingMediaResponse.json(); const existingMediaData = await existingMediaResponse.json();
const existingTitles = new Set( 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(`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 existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
const existingCastData = await existingCastResponse.json(); const existingCastData = await existingCastResponse.json();
const existingActors = new Map( const existingActors = new Map<string, Staff>(
(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`); logCallback(`Found ${existingActors.size} existing actors in database`);
@@ -525,12 +642,12 @@ export async function importFromStashAPP(
for (let i = 0; i < uniquePerformers.length; i++) { for (let i = 0; i < uniquePerformers.length; i++) {
const performer = uniquePerformers[i]; const performer = uniquePerformers[i];
const existingActor: any = existingActors.get(performer.name); const existingActor: Staff | undefined = existingActors.get(performer.name);
try { try {
if (existingActor) { if (existingActor) {
// Update existing actor // Update existing actor
const updateData: any = { const updateData: Partial<Staff> = {
name: performer.name, name: performer.name,
}; };

View File

@@ -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; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping // Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING } from '@/types'; import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to an XBVR instance
*/
export interface XBVRConfig { export interface XBVRConfig {
/** URL of the XBVR server */
url: string; url: string;
/** API key for authentication (optional) */
apiKey?: string; apiKey?: string;
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean; updateExisting?: boolean;
} }
/**
* Progress tracking for the import operation
*/
export interface ImportProgress { export interface ImportProgress {
/** Current number of items processed */
current: number; current: number;
/** Total number of items to process */
total: number; total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string; message: string;
/** Number of videos successfully imported */
videosImported: number; videosImported: number;
/** Number of actors successfully imported */
actorsImported: number; actorsImported: number;
/** Array of error messages encountered during import */
errors: string[]; errors: string[];
} }
/**
* Basic video information from the DeoVR scene list
*/
export interface XBVRVideo { export interface XBVRVideo {
/** Video title */
title: string; title: string;
/** Video length in seconds */
videoLength: number; videoLength: number;
/** URL to the video thumbnail */
thumbnailUrl: string; thumbnailUrl: string;
/** URL to fetch detailed video information */
video_url: string; video_url: string;
} }
/**
* Detailed video information as returned by the XBVR API
*/
export interface XBVRVideoDetail { export interface XBVRVideoDetail {
/** Unique video identifier */
id: number; id: number;
/** Video title */
title: string; title: string;
/** Video description */
description: string; description: string;
/** Release date as Unix timestamp */
date: number; date: number;
/** URL to the video thumbnail */
thumbnailUrl: string; thumbnailUrl: string;
/** Average rating */
rating_avg: number; rating_avg: number;
/** Screen type (e.g., '180', '360', 'dome') */
screenType: string; screenType: string;
/** Stereo mode (e.g., 'sbs', 'tb') */
stereoMode: string; stereoMode: string;
/** Video length in seconds */
videoLength: number; videoLength: number;
/** Pay site information */
paysite?: { paysite?: {
name: string; name: string;
}; };
/** Array of actors in the video */
actors: Array<{ actors: Array<{
id: number; id: number;
name: string; name: string;
}>; }>;
/** Array of category tags */
categories: Array<{ categories: Array<{
tag: { tag: {
name: string; name: string;
@@ -50,16 +98,59 @@ export interface XBVRVideoDetail {
}>; }>;
} }
/**
* Scene list structure as returned by the DeoVR API
*/
export interface XBVRSceneList { export interface XBVRSceneList {
/** Array of scene groups */
scenes: Array<{ scenes: Array<{
/** Name of the scene group (e.g., 'Recent', 'Favorites') */
name: string; name: string;
/** List of videos in this group */
list: XBVRVideo[]; list: XBVRVideo[];
}>; }>;
} }
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void; 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; 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( export async function importFromXBVR(
config: XBVRConfig, config: XBVRConfig,
logCallback: LogCallback, logCallback: LogCallback,
@@ -79,19 +170,19 @@ export async function importFromXBVR(
logCallback('Starting DeoVR import...'); logCallback('Starting DeoVR import...');
// Step 0: Fetch existing media and cast to check for duplicates // 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 existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
const existingMediaData = await existingMediaResponse.json(); const existingMediaData = await existingMediaResponse.json();
const existingTitles = new Set( 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(`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 existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
const existingCastData = await existingCastResponse.json(); const existingCastData = await existingCastResponse.json();
const existingActors = new Map( 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`); logCallback(`Found ${existingActors.size} existing actors in database`);

70
src/store/appStore.ts Normal file
View 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
}),
}));

View File

@@ -119,10 +119,26 @@ export interface UserSettings {
language: string; language: string;
theme: 'light' | 'dark' | 'system'; theme: 'light' | 'dark' | 'system';
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[] 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; createdAt?: string;
updatedAt?: 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 // Source to Category mapping - ensures sources are only used with appropriate categories
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = { export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
'xbvr': ['Adult'], 'xbvr': ['Adult'],

26
typedoc.json Normal file
View 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"
}

View File

@@ -17,8 +17,13 @@ export default defineConfig(({mode}) => {
}, },
server: { server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var. // 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 modifyfile watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true', hmr: process.env.DISABLE_HMR !== 'true',
}, },
test: {
globals: true,
environment: 'jsdom',
setupFiles: [],
},
}; };
}); });