Compare commits

..

22 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
Lars Behrends
6250164656 Add updateExisting option to importers & UI
Introduce an updateExisting flag across importers and the Importer UI to control whether existing items should be updated or only new items imported. Changes: added updateExisting to XBVR, StashAPP, Playnite, and Jellyfin config types; added checkboxes in ImporterView (enabled by default) to toggle the behavior; import logic now skips existing items when updateExisting is false and logs/skips appropriately (XBVR, StashAPP, Playnite, Jellyfin). Also: minor Playnite env parsing tweak for port (undefined when not provided) and small logging/cleanup in the Jellyfin album handling.
2026-04-12 02:57:34 +02:00
Lars Behrends
9c7e5a2b19 Add Jellyfin library mapping support
Add support for Jellyfin library-to-category mappings used during import. Key changes:

- UI: ImporterView now lets users fetch Jellyfin libraries, configure per-library category (TV/Anime/Movies/Music/skip) and optional path segments, and persists mappings to API settings and localStorage.
- API/types: Add jellyfin_library_mappings to ApiSettingsItem/CreateSettingsInput and UserSettings (JSON string of LibraryMapping[]), and wire conversion helpers in src/api.ts and src/types.ts.
- Jellyfin importer: Introduce LibraryMapping type, fetchJellyfinLibraries, helper functions to resolve library from ParentId or Path (extractLibraryFromPath, findLibraryForItem), and update item conversion (movies/series/albums) to apply mappings and skip items marked 'skip'. Import flow now fetches libraries to build id->name map and passes mappings through to converters.

This enables category-aware imports and allows skipping libraries during Jellyfin imports.
2026-04-12 02:08:31 +02:00
Lars Behrends
dff599e5af Add track support and UI list in DetailView
Introduce Track and ApiTrack types and add tracks to ApiMediaItem/Media. Map ApiMediaItem.tracks into Media in convertApiToMedia. Implement a conditional Tracks section in DetailView that lists sorted tracks with search input, track number, title, artist, duration and a play button (only shown when tracks exist). Files changed: src/types.ts, src/api.ts, src/components/DetailView.tsx.
2026-04-11 01:42:45 +02:00
Lars Behrends
6c316fbf84 Update DetailView.tsx 2026-04-11 01:27:58 +02:00
Lars Behrends
0d530ea99c Add Loading component and use across views
Introduce a reusable Loading component (src/components/ui/loading.tsx) that shows a spinning Loader2 icon and an optional message. Replace ad-hoc loading UIs by importing and using Loading in BrowseView and CastView. In App.tsx, add mediaLoading state (set around fetchAllMedia) and pass it to BrowseView; also add local loading states to MediaDetailRoute and CastDetailRoute to show Loading while fetching details. These changes centralize loading UX and remove duplicated spinner markup.
2026-04-11 01:26:41 +02:00
Lars Behrends
555209ed4b Add Jellyfin importer and UI improvements
Introduce a full Jellyfin importer and related UI enhancements.

- Add new lib/jellyfinImporter.ts: implements Jellyfin API clients, conversion helpers, and import/cleanup flows (movies, series, music, cast) with progress/log callbacks.
- Wire Jellyfin integration into ImporterView: add config/options state, import and cleanup handlers, and two new UI cards for importing and cleaning up Jellyfin media; adjust progress display to support different media types and cast naming.
- Update API types (src/api.ts) to include ApiEpisode and episodes on ApiMediaItem and propagate episodes through convertApiToMedia.
- Improve DetailView: add cast show/hide controls, display counts, use characterName when available, and format episode season/episode, air date and duration.
- Enhance Header: theme/scroll-aware styling, scroll listener, themed search/input/avatar styling, and improved nav color handling.
- Simplify MediaDetailRoute in App.tsx: always fetch media by id and remove allMedia dependency to avoid stale resolution.
- Update src/types.ts to support source/category mapping required by the Jellyfin importer.

These changes add Jellyfin as an import source and polish the app UI and detail handling for better UX and more complete media metadata.
2026-04-11 01:24:50 +02:00
Lars Behrends
52d272c701 Add filmography sorting and role-count UI
Introduce filmography sorting controls and role-count indicators across cast list and detail views. In CastDetailView: add sort state (sortBy/sortOrder), compute sortedFilmography, add UI for choosing sort key and toggling order, and show a filmography count badge; import new icons and useState. In CastView: add a new 'roleCount' sort option (default sort is now roleCount desc), persist/restore it from localStorage, adjust reset/default filter logic and hasActiveFilters check, render role-count badges for each person, and tweak layout (flex-1) for better truncation. These changes make it easier to surface prolific cast members and sort filmography entries by year, title, or role.
2026-04-11 00:47:04 +02:00
Lars Behrends
b36b72b8e0 Update DetailView.tsx 2026-04-11 00:39:36 +02:00
Lars Behrends
53c6f5c555 Add source field, UI filter, and import mapping
Introduce a 'source' property across types and API models, include it in convertApiToMedia, and add a Source input to AddMediaView. Add source-based filtering in BrowseView (dropdown + tag icon) and ensure Clear Filters resets source. Update playnite, stashapp, and xbvr importers to set source conditionally using a new SOURCE_CATEGORY_MAPPING constant (added to types) so sources are only applied for appropriate media categories.
2026-04-11 00:28:43 +02:00
Lars Behrends
f482807387 Add grid item size setting and UI
Introduce a persistent gridItemSize user setting (1-10) across the app. Updates include: types (UserSettings.gridItemSize), API mappings (grid_item_size in ApiSettingsItem, CreateSettingsInput, convertApiToSettings, convertSettingsToApi), default setting values, and the App handler (handleGridItemSizeChange) to save changes. UI additions: slider control in SettingsView, slider and value in BrowseView (with syncing to incoming API settings), passing the prop and change callback from App, and a mapping from slider values to responsive Tailwind grid column classes so the grid layout adapts to the chosen size. Also added syncing of itemsPerPage in BrowseView and CastView with API-loaded settings.
2026-04-10 23:31:24 +02:00
Lars Behrends
444c908449 Update AddMediaView.tsx 2026-04-10 15:06:20 +02:00
Lars Behrends
b29732a653 Introduce ThemeContext and apply theme tokens
Add a ThemeContext and provider, wrap the app with ThemeProvider, and sync user settings' theme into the context. Replace hardcoded color classes with design token classes (background, muted, foreground, border, card, etc.) across multiple UI components to centralize theming and enable consistent light/dark styling. Files updated include App.tsx (useTheme, setTheme, ThemeProvider, footer/background tokens), several views and components (AddMediaView, BrowseView, CastDetailView, CastView, MediaCard, MediaListItem, SettingsView, ImporterView) to use tokenized classes, and add new src/contexts/ThemeContext.tsx.
2026-04-10 14:59:40 +02:00
Lars Behrends
96593a6235 Update playniteImporter.ts 2026-04-10 14:50:18 +02:00
Lars Behrends
07c3270e12 backend env url 2026-04-10 14:32:54 +02:00
Lars Behrends
04156486e2 Add user settings UI and API integration
Introduce a full user settings feature: add a SettingsView component and UserSettings type, plus API helpers to fetch, create, and update settings (convertors between API and app shapes). App now loads settings on mount, persists category toggles to the API, exposes a /settings route, and passes itemsPerPage into BrowseView and CastView. Header gains a settings icon/link and BrowseView/CastView update pagination option defaults. This enables centralized library/display/content preferences and syncs them with the backend.
2026-04-10 14:14:27 +02:00
51 changed files with 9737 additions and 1492 deletions

View File

@@ -3,6 +3,9 @@
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# Backend API URL
VITE_API_URL="http://192.168.1.102:6400"
# Importer Configurations
# XBVR Importer
VITE_XBVR_URL=""

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ coverage/
*.log
.env*
!.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

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

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

View File

@@ -6,41 +6,158 @@
import { useState, useMemo, useEffect } from 'react';
import { LayoutGroup } from 'motion/react';
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import BrowseView from './components/BrowseView';
import DashboardView from './components/DashboardView';
import DetailView from './components/DetailView';
import CastView from './components/CastView';
import CastDetailView from './components/CastDetailView';
import AddMediaView from './components/AddMediaView';
import ImporterView from './components/ImporterView';
import SettingsView from './components/SettingsView';
import Loading from './components/ui/loading';
import MediaDetailRoute from './components/routes/MediaDetailRoute';
import CastDetailRoute from './components/routes/CastDetailRoute';
import CategoryBrowseRoute from './components/routes/CategoryBrowseRoute';
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory } from './types';
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff } from './api';
import { Media, Staff, MediaCategory, UserSettings } from './types';
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
import { CATEGORY_PATHS, PATH_TO_CATEGORY, DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from './constants';
import { useAppStore } from './store/appStore';
function AppContent() {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
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 [customMedia, setCustomMedia] = useState<Media[]>([]);
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
const { setTheme } = useTheme();
// Load media from API on component mount (only when not on cast routes)
const [apiMedia, setApiMedia] = useState<Media[]>([]);
// Zustand store
const {
apiMedia,
customMedia,
adultMedia,
mediaLoading,
selectedMedia,
selectedPerson,
activeCategory,
enabledCategories,
searchQuery,
settings,
setApiMedia,
setCustomMedia,
setAdultMedia,
setMediaLoading,
setSelectedMedia,
setSelectedPerson,
setActiveCategory,
setEnabledCategories,
setSearchQuery,
setSettings,
} = useAppStore();
// Set category from URL path on mount or location change
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean);
if (pathParts.length === 1 && PATH_TO_CATEGORY[pathParts[0]]) {
const category = PATH_TO_CATEGORY[pathParts[0]];
if (enabledCategories.includes(category)) {
setActiveCategory(category);
}
}
}, [location.pathname, enabledCategories, setActiveCategory]);
useEffect(() => {
const loadSettingsFromApi = async () => {
try {
const loadedSettings = await fetchSettings();
if (loadedSettings) {
setSettings(loadedSettings);
setEnabledCategories(loadedSettings.enabledCategories);
// Sync theme with theme context
setTheme(loadedSettings.theme);
// Set custom page title
if (loadedSettings.pageTitle) {
document.title = loadedSettings.pageTitle;
}
// Set custom favicon
if (loadedSettings.favicon) {
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!faviconLink) {
faviconLink = document.createElement('link');
faviconLink.rel = 'icon';
document.head.appendChild(faviconLink);
}
faviconLink.href = loadedSettings.favicon;
}
}
} catch (error) {
console.error('Failed to load settings from API:', error);
}
};
loadSettingsFromApi();
}, [setTheme]);
// Apply custom colors when settings change
useEffect(() => {
if (settings?.customColors) {
const root = document.documentElement;
const colors = settings.customColors;
if (colors.primary) root.style.setProperty('--color-primary', colors.primary);
if (colors.secondary) root.style.setProperty('--color-secondary', colors.secondary);
if (colors.background) root.style.setProperty('--color-background', colors.background);
if (colors.surface) root.style.setProperty('--color-surface', colors.surface);
if (colors.text) root.style.setProperty('--color-text', colors.text);
if (colors.muted) root.style.setProperty('--color-muted', colors.muted);
if (colors.border) root.style.setProperty('--color-border', colors.border);
}
}, [settings?.customColors]);
const reloadSettings = async () => {
try {
const loadedSettings = await fetchSettings();
if (loadedSettings) {
setSettings(loadedSettings);
setEnabledCategories(loadedSettings.enabledCategories);
// Sync theme with theme context
setTheme(loadedSettings.theme);
// Set custom page title
if (loadedSettings.pageTitle) {
document.title = loadedSettings.pageTitle;
}
// Set custom favicon
if (loadedSettings.favicon) {
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!faviconLink) {
faviconLink = document.createElement('link');
faviconLink.rel = 'icon';
document.head.appendChild(faviconLink);
}
faviconLink.href = loadedSettings.favicon;
}
}
} catch (error) {
console.error('Failed to reload settings from API:', error);
}
};
useEffect(() => {
const loadMediaFromApi = async () => {
setMediaLoading(true);
try {
const media = await fetchAllMedia();
setApiMedia(media);
} catch (error) {
console.error('Failed to load media from API:', error);
} finally {
setMediaLoading(false);
}
};
@@ -50,26 +167,36 @@ function AppContent() {
}
}, [location.pathname]);
const toggleCategory = (category: MediaCategory) => {
setEnabledCategories(prev => {
const isEnabling = !prev.includes(category);
const newList = isEnabling
? [...prev, category]
: prev.filter(c => c !== category);
const toggleCategory = async (category: MediaCategory) => {
const isEnabling = !enabledCategories.includes(category);
const newList = isEnabling
? [...enabledCategories, category]
: enabledCategories.filter(c => c !== category);
// If we disable the current active category, switch to another enabled one
if (!isEnabling && activeCategory === category) {
const nextCategory = newList.find(c => c !== category) || 'Anime';
setActiveCategory(nextCategory as MediaCategory);
// If we disable the current active category, switch to another enabled one
if (!isEnabling && activeCategory === category) {
const nextCategory = newList.find(c => c !== category) || 'Anime';
setActiveCategory(nextCategory as MediaCategory);
}
setEnabledCategories(newList);
// Save to API
const baseSettings = settings || DEFAULT_SETTINGS;
const updatedSettings: UserSettings = {
...baseSettings,
enabledCategories: newList,
};
updateSettings(updatedSettings).then(saved => {
if (saved) {
setSettings(saved);
}
return newList;
});
};
const handleCategoryChange = (category: MediaCategory) => {
setActiveCategory(category);
setSearchParams({ category });
navigate('/');
navigate(`/${CATEGORY_PATHS[category]}`);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
@@ -115,6 +242,19 @@ function AppContent() {
}
};
const handleGridItemSizeChange = async (size: number) => {
const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories };
const updatedSettings: UserSettings = {
...baseSettings,
gridItemSize: size,
};
updateSettings(updatedSettings).then(saved => {
if (saved) {
setSettings(saved);
}
});
};
const allStaff = useMemo(() => {
const staff: Staff[] = [];
// Use API data if available, otherwise fall back to mock data
@@ -215,34 +355,50 @@ function AppContent() {
params.delete('search');
}
setSearchParams(params);
navigate('/');
navigate('/browse');
};
return (
<div className="min-h-screen bg-white font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
<Header
onSearch={handleSearch}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
<div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9] flex">
<Sidebar
enabledCategories={enabledCategories}
onToggleCategory={toggleCategory}
transparent={location.pathname.startsWith('/media/') || location.pathname.startsWith('/cast/')}
pageTitle={settings?.pageTitle}
/>
<main>
<main className="flex-1 lg:ml-72 flex flex-col">
<LayoutGroup>
<Routes>
<Route path="/" element={
<DashboardView
mediaList={apiMedia.length > 0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))}
onMediaClick={handleMediaClick}
loading={mediaLoading}
/>
} />
<Route path="/browse" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory={activeCategory}
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/:category" element={
<CategoryBrowseRoute
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/media/:id" element={
<MediaDetailRoute
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
allMedia={allMedia}
onPersonClick={handlePersonClick}
/>
@@ -251,128 +407,56 @@ function AppContent() {
<CastView
onPersonClick={handlePersonClick}
enabledCategories={enabledCategories}
itemsPerPage={settings?.itemsPerPage}
/>
} />
<Route path="/cast/:id" element={
<CastDetailRoute
selectedPerson={selectedPerson}
setSelectedPerson={setSelectedPerson}
/>
<CastDetailRoute />
} />
<Route path="/add" element={
<AddMediaView
activeCategory={activeCategory}
enabledCategories={enabledCategories}
onAddComplete={handleAddMedia}
/>
} />
<Route path="/import" element={
<ImporterView />
} />
<Route path="/settings" element={
<SettingsView onSettingsSaved={reloadSettings} />
} />
</Routes>
</LayoutGroup>
{/* Footer */}
<footer className="py-8 px-6 border-t border-border/50 bg-muted/30 backdrop-blur-sm mt-auto">
<div className="max-w-[1920px] mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-lg font-black text-muted-foreground">
<div className="w-5 h-5 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-full" />
<span className="bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">{settings?.pageTitle || 'omnyx'}</span>
</div>
<div className="flex items-center gap-6 text-sm font-bold text-muted-foreground">
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Terms</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Privacy</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Contact</a>
</div>
<p className="text-xs font-medium text-muted-foreground">
© 2026 Omnyx Media Discovery. All rights reserved.
</p>
</div>
</footer>
</main>
{/* Footer */}
<footer className="py-12 px-6 border-t border-zinc-100 bg-zinc-50">
<div className="max-w-[1600px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2 text-xl font-black text-zinc-400">
<div className="w-5 h-5 bg-zinc-300 rounded-full" />
kyoo
</div>
<div className="flex items-center gap-8 text-sm font-bold text-zinc-400">
<a href="#" className="hover:text-[#6d28d9] transition-colors">Terms</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Privacy</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Contact</a>
</div>
<p className="text-xs font-medium text-zinc-400">
© 2026 Kyoo Media Discovery. All rights reserved.
</p>
</div>
</footer>
</div>
);
}
// Helper component for media detail route
function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
useEffect(() => {
const loadMedia = async () => {
if (id) {
// First check if media is in allMedia
const media = allMedia.find(m => m.id === id);
if (media) {
setSelectedMedia(media);
} else {
// If not found, fetch from API
try {
const fetchedMedia = await fetchMediaById(id);
if (fetchedMedia) {
setSelectedMedia(fetchedMedia);
} else {
navigate('/');
}
} catch (error) {
console.error('Failed to fetch media:', error);
navigate('/');
}
}
}
};
loadMedia();
}, [id, allMedia]);
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();
useEffect(() => {
const loadCast = async () => {
if (id) {
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');
}
}
};
loadCast();
}, [id]);
if (!selectedPerson) return null;
return (
<CastDetailView
person={selectedPerson}
relatedMedia={[]}
/>
);
}
export default function App() {
return (
<BrowserRouter>
<AppContent />
<ThemeProvider>
<AppContent />
</ThemeProvider>
</BrowserRouter>
);
}

View File

@@ -1,577 +1,14 @@
import { Media, Staff } from './types';
// Re-export all API functions for backward compatibility
export * from './lib/api/mediaApi';
export * from './lib/api/castApi';
export * from './lib/api/settingsApi';
export * from './lib/api/converters';
export * from './lib/api/types';
const BASE_URL = 'http://192.168.1.102:6400';
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 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;
createdAt: string;
updatedAt: string;
genres?: string[];
tags?: string[];
studios?: string[];
staff?: ApiStaff[];
categories?: string[];
platforms?: string[];
developers?: string[];
completionStatus?: string;
source?: string;
playCount?: number;
lastActivity?: string | null;
playtime?: number;
}
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;
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,
status: mediaStatus,
staff: staff.length > 0 ? staff : undefined,
aspectRatio: aspectRatio,
categories: apiItem.categories,
platforms: apiItem.platforms,
developers: apiItem.developers,
completionStatus: apiItem.completionStatus,
source: apiItem.source,
playCount: apiItem.playCount,
lastActivity: apiItem.lastActivity,
playtime: apiItem.playtime
};
}
// Media API Functions
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
try {
const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching media from API:', error);
return [];
}
}
export async function fetchMediaById(id: number | string): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error fetching media by ID:', error);
return null;
}
}
export async function createMedia(media: CreateMediaInput): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(media),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error creating media:', error);
return null;
}
}
export async function updateMedia(id: number | string, media: UpdateMediaInput): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(media),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error updating media:', error);
return null;
}
}
export async function deleteMedia(id: number | string): Promise<boolean> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<{ message: string }> = await response.json();
return data.success;
} catch (error) {
console.error('Error deleting media:', error);
return false;
}
}
// Cast API Functions
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<Staff[]> {
try {
const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<ApiCastItem>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiCastToStaff);
}
return [];
} catch (error) {
console.error('Error fetching cast from API:', error);
return [];
}
}
export async function fetchCastById(id: number | string): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error fetching cast by ID:', error);
return null;
}
}
export async function fetchCastMedia(castId: number | string): Promise<Media[]> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${castId}/media`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching cast media:', error);
return [];
}
}
export async function createCast(cast: CreateCastInput): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cast),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error creating cast:', error);
return null;
}
}
export async function updateCast(id: number | string, cast: UpdateCastInput): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cast),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error updating cast:', error);
return null;
}
}
export async function deleteCast(id: number | string): Promise<boolean> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<{ message: string }> = await response.json();
return data.success;
} catch (error) {
console.error('Error deleting cast:', error);
return false;
}
}
// Legacy function for compatibility - fetches all unique staff members from media
export async function fetchAllActors(): Promise<Array<{id: number, name: string, photo: string | null}>> {
try {
const media = await fetchAllMedia(1, 1000);
const actorMap = new Map<number, {id: number, name: string, photo: string | null}>();
media.forEach(item => {
item.staff?.forEach(staffMember => {
const id = parseInt(staffMember.id);
if (!actorMap.has(id)) {
actorMap.set(id, {
id: id,
name: staffMember.name,
photo: staffMember.photo
});
}
});
});
return Array.from(actorMap.values());
} catch (error) {
console.error('Error fetching all actors:', error);
return [];
}
}
// Legacy function for compatibility - fetches all unique tags from media
// Legacy functions for compatibility
export async function fetchAllTags(): Promise<string[]> {
try {
const { fetchAllMedia } = await import('./lib/api/mediaApi');
const media = await fetchAllMedia(1, 1000);
const tagSet = new Set<string>();
@@ -587,24 +24,9 @@ export async function fetchAllTags(): Promise<string[]> {
}
}
// Legacy function for compatibility - fetches media by actor name
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
try {
const media = await fetchAllMedia(1, 1000);
return media.filter(item =>
item.staff?.some(staffMember =>
staffMember.name.toLowerCase().includes(actorName.toLowerCase())
)
);
} catch (error) {
console.error('Error fetching media by actor:', error);
return [];
}
}
// Legacy function for compatibility - fetches media by tag
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
export async function fetchMediaByTag(tag: string) {
try {
const { fetchAllMedia } = await import('./lib/api/mediaApi');
const media = await fetchAllMedia(1, 1000);
return media.filter(item =>
item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) ||
@@ -616,12 +38,12 @@ export async function fetchMediaByTag(tag: string): Promise<Media[]> {
}
}
// Convenience function - fetch media from API (legacy compatibility)
export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> {
export async function fetchMediaFromApi(apiUrl?: string) {
const { fetchAllMedia } = await import('./lib/api/mediaApi');
return fetchAllMedia();
}
// Convenience function - fetch media from local JSON (legacy compatibility)
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
export async function fetchMediaFromLocalJson() {
const { fetchAllMedia } = await import('./lib/api/mediaApi');
return fetchAllMedia();
}

View File

@@ -5,15 +5,16 @@ import { Label } from '@/components/ui/label';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { createMedia, type CreateMediaInput } from '@/api';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Film, Calendar, Star, User, BookOpen, Music as MusicIcon, Gamepad2, Monitor, Hash, Tag, Users, FileText, Globe, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
interface AddMediaViewProps {
activeCategory: MediaCategory;
enabledCategories: MediaCategory[];
onAddComplete: () => void;
}
export default function AddMediaView({ activeCategory, onAddComplete }: AddMediaViewProps) {
export default function AddMediaView({ activeCategory, enabledCategories, onAddComplete }: AddMediaViewProps) {
const navigate = useNavigate();
const [newMedia, setNewMedia] = useState({
title: '',
@@ -30,6 +31,7 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
director: '',
writer: '',
releaseDate: '',
source: '' as string,
genres: '' as string,
tags: '' as string,
studios: '' as string
@@ -37,6 +39,29 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const [staff, setStaff] = useState<Array<{ name: string; role: string; characterName?: string; photo?: string }>>([]);
const addStaffMember = () => {
const nameInput = document.getElementById('staffName') as HTMLInputElement;
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
const characterInput = document.getElementById('staffCharacter') as HTMLInputElement;
const photoInput = document.getElementById('staffPhoto') as HTMLInputElement;
if (nameInput?.value && roleInput?.value) {
setStaff(prev => [...prev, {
name: nameInput.value,
role: roleInput.value,
characterName: characterInput?.value || undefined,
photo: photoInput?.value || undefined
}]);
// Clear the form
if (nameInput) nameInput.value = '';
if (roleInput) roleInput.value = '';
if (characterInput) characterInput.value = '';
if (photoInput) photoInput.value = '';
}
};
// Update category, default aspect ratio, and default type when activeCategory changes
useEffect(() => {
@@ -105,9 +130,16 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
director: newMedia.director || null,
writer: newMedia.writer || null,
releaseDate: newMedia.releaseDate || null,
source: newMedia.source || null,
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : []
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : [],
staff: staff.length > 0 ? staff.map(s => ({
name: s.name,
role: s.role,
characterName: s.characterName || null,
photo: s.photo || null
})) : undefined
};
try {
@@ -133,10 +165,12 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
director: '',
writer: '',
releaseDate: '',
source: '',
genres: '',
tags: '',
studios: ''
});
setStaff([]);
}
} catch (error) {
setSubmitStatus('error');
@@ -146,82 +180,112 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
}
};
const getCategoryIcon = (category: MediaCategory) => {
const icons: Record<MediaCategory, any> = {
'Anime': <Film size={18} />,
'Movies': <Film size={18} />,
'TV Series': <Film size={18} />,
'Music': <MusicIcon size={18} />,
'Books': <BookOpen size={18} />,
'Games': <Gamepad2 size={18} />,
'Consoles': <Monitor size={18} />,
'Adult': <Star size={18} />
};
return icons[category] || <Film size={18} />;
};
return (
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
<div className="pt-24 pb-12 px-6">
<Button
variant="ghost"
onClick={() => navigate('/')}
className="mb-6 gap-2 text-zinc-600 hover:text-zinc-900"
className="mb-6 gap-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-xl transition-all duration-300"
>
<ArrowLeft size={20} />
Back to Browse
</Button>
<div className="bg-white rounded-3xl shadow-xl p-8">
<h1 className="text-3xl font-black text-zinc-900 mb-2">Add New Media</h1>
<p className="text-zinc-500 font-medium mb-8">
Add a new item to your {activeCategory} library.
</p>
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] flex items-center justify-center shadow-lg shadow-[#6d28d9]/30">
{getCategoryIcon(activeCategory)}
</div>
<div>
<h1 className="text-4xl font-black text-foreground mb-1">Add New Media</h1>
<p className="text-muted-foreground font-medium text-lg">
Add a new item to your {activeCategory} library.
</p>
</div>
</div>
{submitStatus === 'success' && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
<p className="text-green-800 font-bold"> Successfully added to library!</p>
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl backdrop-blur-sm">
<p className="text-green-500 font-bold"> Successfully added to library!</p>
</div>
)}
{submitStatus === 'error' && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
<p className="text-red-800 font-bold"> Error: {errorMessage}</p>
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl backdrop-blur-sm">
<p className="text-red-500 font-bold"> Error: {errorMessage}</p>
</div>
)}
<form onSubmit={handleAddSubmit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="title" className="text-sm font-black text-zinc-700">Title</Label>
<Input
id="title"
value={newMedia.title}
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g. Mob Psycho 100"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="year" className="text-sm font-black text-zinc-700">Year</Label>
<Input
id="year"
value={newMedia.year}
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
placeholder="2024"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
<form onSubmit={handleAddSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Info Card */}
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
<FileText size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
</div>
<div className="grid gap-2">
<Label htmlFor="category" className="text-sm font-black text-zinc-700">Category</Label>
<select
id="category"
value={newMedia.category}
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="type" className="text-sm font-black text-zinc-700">Type</Label>
<select
id="type"
value={newMedia.type}
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{newMedia.category === 'Music' ? (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="title" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Title</Label>
<Input
id="title"
value={newMedia.title}
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g. Mob Psycho 100"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="year" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Year</Label>
<Input
id="year"
value={newMedia.year}
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
placeholder="2024"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="category" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Category</Label>
<select
id="category"
value={newMedia.category}
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
{enabledCategories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="type" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Type</Label>
<select
id="type"
value={newMedia.type}
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
{newMedia.category === 'Music' ? (
<>
<option value="Album">Album</option>
<option value="Single">Single</option>
@@ -250,171 +314,337 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
<option value="Movie">Movie</option>
</>
)}
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="status" className="text-sm font-black text-zinc-700">Status</Label>
<select
id="status"
value={newMedia.status}
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
<option value="Released">Released</option>
<option value="Ongoing">Ongoing</option>
<option value="Upcoming">Upcoming</option>
<option value="Completed">Completed</option>
<option value="Watching">Watching</option>
<option value="Reading">Reading</option>
<option value="Listening">Listening</option>
<option value="Playing">Playing</option>
<option value="Dropped">Dropped</option>
<option value="On Hold">On Hold</option>
</select>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="status" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Status</Label>
<select
id="status"
value={newMedia.status}
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="Released">Released</option>
<option value="Ongoing">Ongoing</option>
<option value="Upcoming">Upcoming</option>
<option value="Completed">Completed</option>
<option value="Watching">Watching</option>
<option value="Reading">Reading</option>
<option value="Listening">Listening</option>
<option value="Playing">Playing</option>
<option value="Dropped">Dropped</option>
<option value="On Hold">On Hold</option>
</select>
</div>
</div>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label>
<select
id="aspectRatio"
value={newMedia.aspectRatio}
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
<option value="2/3">2:3 (Standard Poster)</option>
<option value="16/9">16:9 (Wide Thumbnail)</option>
<option value="1/1">1:1 (Square)</option>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="poster" className="text-sm font-black text-zinc-700">Poster URL</Label>
<Input
id="poster"
value={newMedia.poster}
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
placeholder="https://example.com/poster.jpg"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="banner" className="text-sm font-black text-zinc-700">Banner URL (Optional)</Label>
<Input
id="banner"
value={newMedia.banner}
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
placeholder="https://example.com/banner.jpg"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description" className="text-sm font-black text-zinc-700">Description (Optional)</Label>
<textarea
id="description"
value={newMedia.description}
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
placeholder="Brief description..."
className="bg-zinc-50 border-zinc-100 rounded-xl p-3 h-20 text-sm font-bold text-zinc-700 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-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
<>
{/* Media Info Card */}
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
<Globe size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Media Information</h3>
</div>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="poster" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Poster URL</Label>
<Input
id="poster"
value={newMedia.poster}
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
placeholder="https://example.com/poster.jpg"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="banner" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Banner URL (optional)</Label>
<Input
id="banner"
value={newMedia.banner}
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
placeholder="https://example.com/banner.jpg"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="runtime" className="text-sm font-black text-zinc-700">Runtime (min)</Label>
<Label htmlFor="aspectRatio" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Aspect Ratio</Label>
<select
id="aspectRatio"
value={newMedia.aspectRatio}
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="2/3">2:3 (Poster)</option>
<option value="16/9">16:9 (Banner)</option>
<option value="1/1">1:1 (Square)</option>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="rating" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Rating (0-10)</Label>
<Input
id="rating"
type="number"
min="0"
max="10"
step="0.1"
value={newMedia.rating}
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
placeholder="8.5"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="description" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Description</Label>
<textarea
id="description"
value={newMedia.description}
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
placeholder="Enter a description..."
rows={4}
className="bg-background border-border/50 rounded-xl p-3 text-sm focus:ring-2 focus:ring-[#6d28d9]/50 outline-none resize-none"
/>
</div>
</div>
</div>
{/* Production Details Card - for Movies/TV/Anime */}
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
<Clock size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Production Details</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="runtime" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Runtime (minutes)</Label>
<Input
id="runtime"
type="number"
value={newMedia.runtime}
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
placeholder="120"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="releaseDate" className="text-sm font-black text-zinc-700">Release Date</Label>
<Label htmlFor="releaseDate" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Release Date</Label>
<Input
id="releaseDate"
type="date"
value={newMedia.releaseDate}
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="director" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Director</Label>
<Input
id="director"
value={newMedia.director}
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
placeholder="Director name"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="writer" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Writer</Label>
<Input
id="writer"
value={newMedia.writer}
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
placeholder="Writer name"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="director" className="text-sm font-black text-zinc-700">Director</Label>
<Input
id="director"
value={newMedia.director}
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
placeholder="Director name"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="writer" className="text-sm font-black text-zinc-700">Writer</Label>
<Input
id="writer"
value={newMedia.writer}
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
placeholder="Writer name"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
</>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="genres" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
{/* Classification Card */}
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#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>
<div className="grid gap-2">
<Label htmlFor="tags" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
{/* Cast/Staff Card */}
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50 lg:col-span-2">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
<Users size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Cast & Crew</h3>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Staff List */}
<div className="space-y-2">
{staff.length > 0 && (
<>
{staff.map((member, index) => (
<div key={index} className="flex items-center gap-3 p-3 bg-background rounded-xl border border-border/50">
{member.photo && (
<img
src={member.photo}
alt={member.name}
className="w-12 h-12 rounded-xl object-cover border border-border/30"
referrerPolicy="no-referrer"
/>
)}
<div className="flex-1 min-w-0">
<p className="font-bold text-foreground truncate">{member.name}</p>
<p className="text-xs text-muted-foreground">{member.role}{member.characterName ? ` as ${member.characterName}` : ''}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setStaff(prev => prev.filter((_, i) => i !== index))}
className="h-8 w-8 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-xl"
>
×
</Button>
</div>
))}
</>
)}
{staff.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
No cast members added yet
</div>
)}
</div>
{/* Add Staff Form */}
<div className="grid gap-3 p-4 bg-background rounded-xl border border-border/50">
<div className="grid gap-2">
<Label htmlFor="staffName" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Name</Label>
<Input
id="staffName"
placeholder="Actor name"
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const input = e.target as HTMLInputElement;
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
if (input.value && roleInput?.value) {
addStaffMember();
}
}
}}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="staffRole" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Role</Label>
<Input
id="staffRole"
placeholder="e.g. Actor, Director"
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const input = e.target as HTMLInputElement;
const nameInput = document.getElementById('staffName') as HTMLInputElement;
if (input.value && nameInput?.value) {
addStaffMember();
}
}
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="staffCharacter" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Character (optional)</Label>
<Input
id="staffCharacter"
placeholder="Character name"
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="staffPhoto" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Photo URL (optional)</Label>
<Input
id="staffPhoto"
placeholder="https://example.com/photo.jpg"
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
/>
</div>
<Button
type="button"
onClick={addStaffMember}
variant="outline"
className="w-full border-border/50 text-sm font-bold hover:border-[#6d28d9]/50 hover:bg-[#6d28d9]/10 rounded-xl transition-all duration-300"
>
+ Add Cast Member
</Button>
</div>
</div>
</div>
)}
{/* Submit Button - Full Width */}
<div className="lg:col-span-2">
<Button
type="submit"
disabled={isSubmitting}
className="w-full bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/30 transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
</Button>
</div>
<div className="grid gap-2">
<Label htmlFor="studios" className="text-sm font-black text-zinc-700">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-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<Button
type="submit"
disabled={isSubmitting}
className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
</Button>
</form>
</div>
</div>

View File

@@ -1,8 +1,9 @@
import { Media, MediaCategory } from '@/types';
import MediaCard from './MediaCard';
import MediaListItem from './MediaListItem';
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree } from 'lucide-react';
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react';
import { Button } from '@/components/ui/button';
import Loading from '@/components/ui/loading';
import React, { useState, useMemo, useEffect } from 'react';
import {
DropdownMenu,
@@ -17,13 +18,32 @@ interface BrowseViewProps {
mediaList: Media[];
onMediaClick: (media: Media) => void;
activeCategory: MediaCategory;
itemsPerPage?: number;
gridItemSize?: number;
onGridItemSizeChange?: (size: number) => void;
loading?: boolean;
}
export default function BrowseView({ mediaList, onMediaClick, activeCategory }: BrowseViewProps) {
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12, gridItemSize: initialGridItemSize = 5, onGridItemSizeChange, loading = false }: BrowseViewProps) {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(12);
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
const [sortBy, setSortBy] = useState<string>('default');
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
// Sync itemsPerPage with prop when API settings are loaded
useEffect(() => {
if (initialItemsPerPage) {
setItemsPerPage(initialItemsPerPage);
}
}, [initialItemsPerPage]);
// Sync gridItemSize with prop when API settings are loaded
useEffect(() => {
if (initialGridItemSize !== undefined) {
setGridItemSize(initialGridItemSize);
}
}, [initialGridItemSize]);
// Filter states
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
@@ -31,6 +51,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
// Extract unique values for filters
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
@@ -38,6 +59,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]);
const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
const filteredMedia = useMemo(() => {
return mediaList.filter(media => {
@@ -46,9 +68,10 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
if (selectedSource && media.source !== selectedSource) return false;
return true;
});
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]);
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory, selectedSource]);
// Reset to first page when mediaList or filters change
useEffect(() => {
@@ -66,6 +89,23 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
return list;
}, [filteredMedia, sortBy]);
const gridColsClass = useMemo(() => {
// Map slider value (1-10) to grid columns
const colsMap: Record<number, string> = {
1: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
2: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
3: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6',
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
7: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
8: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
9: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
10: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-12',
};
return `grid ${colsMap[gridItemSize] || colsMap[5]}`;
}, [gridItemSize]);
const totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
const paginatedMedia = useMemo(() => {
@@ -84,14 +124,14 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
};
return (
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
{/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div className="flex flex-wrap items-center gap-2">
{/* Genre Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Star size={16} />
{selectedGenre || 'Genres'}
</button>
@@ -107,7 +147,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
{/* Studio Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
Studios
</button>
</DropdownMenuTrigger>
@@ -123,7 +163,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
{activeCategory === 'Games' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Monitor size={16} />
{selectedPlatform || 'Platforms'}
</button>
@@ -141,7 +181,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
{activeCategory === 'Games' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Users size={16} />
{selectedDeveloper || 'Developers'}
</button>
@@ -159,7 +199,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
{activeCategory === 'Games' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<FolderTree size={16} />
{selectedCategory || 'Categories'}
</button>
@@ -173,17 +213,36 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
</DropdownMenu>
)}
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory) && (
{/* Source Filter */}
{allSources.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Tag size={16} />
{selectedSource || 'Source'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedSource(null)}>All Sources</DropdownMenuItem>
{allSources.sort().map(source => (
<DropdownMenuItem key={source} onClick={() => setSelectedSource(source)}>{source}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory || selectedSource) && (
<Button
variant="link"
size="sm"
className="text-zinc-400 font-bold"
className="text-muted-foreground font-bold hover:text-[#6d28d9] transition-colors"
onClick={() => {
setSelectedGenre(null);
setSelectedStudio(null);
setSelectedPlatform(null);
setSelectedDeveloper(null);
setSelectedCategory(null);
setSelectedSource(null);
}}
>
Clear Filters
@@ -191,10 +250,28 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
)}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
{/* Grid item size slider */}
<div className="flex items-center gap-3 bg-muted/50 backdrop-blur-sm rounded-xl px-4 py-2.5 border border-border/50">
<span className="text-xs font-bold text-muted-foreground">Size</span>
<input
type="range"
min="1"
max="10"
value={gridItemSize}
onChange={(e) => {
const newSize = Number(e.target.value);
setGridItemSize(newSize);
onGridItemSizeChange?.(newSize);
}}
className="w-24 h-2 bg-background rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
/>
<span className="text-xs font-bold text-[#6d28d9] w-5 text-center">{gridItemSize}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button 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-zinc-600 font-bold gap-2">
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 text-muted-foreground font-bold backdrop-blur-sm border-border/50">
<ArrowUpDown size={16} />
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
</button>
@@ -206,13 +283,13 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center bg-zinc-100 rounded-md p-1">
<div className="flex items-center bg-muted/50 backdrop-blur-sm rounded-xl p-1 border border-border/50">
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 transition-all",
viewMode === 'grid' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
"h-8 w-8 transition-all rounded-lg",
viewMode === 'grid' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
)}
onClick={() => setViewMode('grid')}
>
@@ -222,8 +299,8 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 transition-all",
viewMode === 'list' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
"h-8 w-8 transition-all rounded-lg",
viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
)}
onClick={() => setViewMode('list')}
>
@@ -234,9 +311,11 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
</div>
{/* Content */}
{mediaList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<div className="w-16 h-16 bg-zinc-100 rounded-full flex items-center justify-center mb-4">
{loading ? (
<Loading message="Loading media..." />
) : mediaList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
<Search size={32} />
</div>
<p className="text-lg font-bold">No results found</p>
@@ -245,7 +324,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
) : (
<div className={cn(
viewMode === 'grid'
? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-x-4 gap-y-8"
? cn(gridColsClass, "gap-x-4 gap-y-8")
: "flex flex-col gap-2"
)}>
<AnimatePresence mode="popLayout">
@@ -270,18 +349,18 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
{/* Pagination Controls */}
{mediaList.length > 0 && (
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-zinc-100 pt-8">
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border pt-8">
<div className="flex items-center gap-4">
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
<span className="text-sm text-muted-foreground font-medium">Items per page:</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
className="bg-muted border-none rounded-md px-2 py-1 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{[8, 12, 16, 24, 48].map(size => (
{[12, 20, 36, 48, 60].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
@@ -293,7 +372,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 1}
className="gap-2 font-bold border-zinc-200"
className="gap-2 font-bold border-border"
>
<ChevronLeft size={16} />
Previous
@@ -301,8 +380,8 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
<div className="flex items-center gap-2">
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
<span className="text-sm text-zinc-400 font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span>
<span className="text-sm text-muted-foreground font-medium">of</span>
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
</div>
<Button
@@ -310,7 +389,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-zinc-200"
className="gap-2 font-bold border-border"
>
Next
<ChevronRight size={16} />

View File

@@ -1,9 +1,10 @@
import { Staff, Media } from '@/types';
import { useNavigate } from 'react-router-dom';
import { motion } from 'motion/react';
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye } from 'lucide-react';
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye, ChevronDown, ListFilter } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useState } from 'react';
interface CastDetailViewProps {
person: Staff;
@@ -12,28 +13,42 @@ interface CastDetailViewProps {
export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
const navigate = useNavigate();
const [sortBy, setSortBy] = useState<'year' | 'title' | 'role'>('role');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const handleMediaClick = (mediaId: string) => {
navigate(`/media/${mediaId}`);
};
const sortedFilmography = [...(person.filmography || [])].sort((a, b) => {
let comparison = 0;
if (sortBy === 'year') {
comparison = (a.year || 0) - (b.year || 0);
} else if (sortBy === 'title') {
comparison = (a.title || '').localeCompare(b.title || '');
} else if (sortBy === 'role') {
comparison = (a.role || '').localeCompare(b.role || '');
}
return sortOrder === 'asc' ? comparison : -comparison;
});
return (
<div className="min-h-screen bg-white pb-20">
<div className="min-h-screen bg-background pb-20">
{/* Hero Section */}
<div className="relative h-[40vh] md:h-[50vh] overflow-hidden bg-zinc-900">
<div className="relative h-[50vh] md:h-[60vh] overflow-hidden bg-zinc-900">
<img
src={person.photo}
alt={person.name}
className="w-full h-full object-cover opacity-40 blur-xl scale-110"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent" />
<div className="absolute inset-0 flex items-end px-6 pb-12">
<div className="max-w-[1200px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-8">
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-48 h-48 md:w-64 md:h-64 rounded-2xl overflow-hidden border-4 border-white shadow-2xl shrink-0"
className="h-48 md:h-72 rounded-2xl overflow-hidden border-4 border-background shadow-2xl shrink-0"
>
<img
src={person.photo}
@@ -49,15 +64,20 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
<h1 className="text-4xl md:text-6xl font-black text-zinc-900 mb-4 drop-shadow-sm">
<h1 className="text-5xl md:text-7xl font-black text-foreground mb-4 drop-shadow-sm">
{person.name}
</h1>
<div className="flex flex-wrap justify-center md:justify-start gap-3">
{person.occupations?.map(occ => (
<Badge key={occ} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] border-none font-bold px-4 py-1">
<Badge key={occ} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 font-bold px-4 py-1.5 backdrop-blur-sm">
{occ}
</Badge>
))}
{person.filmography && person.filmography.length > 0 && (
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold px-4 py-1.5">
{person.filmography.length} Role{person.filmography.length !== 1 ? 's' : ''}
</Badge>
)}
</div>
</motion.div>
</div>
@@ -68,100 +88,100 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="absolute top-24 left-6 bg-white/20 hover:bg-white/40 text-white rounded-full backdrop-blur-md"
className="absolute top-24 left-6 bg-white/30 hover:bg-white/50 text-white rounded-2xl backdrop-blur-md transition-all duration-300 hover:scale-110 border border-white/20"
>
<ArrowLeft size={24} />
</Button>
</div>
{/* Content Section */}
<div className="max-w-[1200px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
<div className="max-w-[1920px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Sidebar Info */}
<div className="space-y-8">
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6">
<h3 className="text-xl font-black text-zinc-900">Personal Info</h3>
<div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
<h3 className="text-2xl font-black text-foreground">Personal Info</h3>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Calendar size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Date</p>
<p className="font-bold text-zinc-700">{person.birthDate || 'Unknown'}</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Date</p>
<p className="font-bold text-foreground">{person.birthDate || 'Unknown'}</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<MapPin size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Place</p>
<p className="font-bold text-zinc-700">{person.birthPlace || 'Unknown'}</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Place</p>
<p className="font-bold text-foreground">{person.birthPlace || 'Unknown'}</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Briefcase size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Known For</p>
<p className="font-bold text-zinc-700">{person.role}</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Known For</p>
<p className="font-bold text-foreground">{person.role}</p>
</div>
</div>
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<User size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Ethnicity</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Ethnicity</p>
<p className="font-bold text-foreground">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
</div>
</div>
)}
</div>
</div>
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6">
<h3 className="text-xl font-black text-zinc-900">Measurements</h3>
<div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
<h3 className="text-2xl font-black text-foreground">Measurements</h3>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Height</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.height || person.height} cm</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Height</p>
<p className="font-bold text-foreground">{person.adult_specifics?.height || person.height} cm</p>
</div>
</div>
{(person.weight || person.adult_specifics?.weight) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Weight</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.weight || person.weight} kg</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Weight</p>
<p className="font-bold text-foreground">{person.adult_specifics?.weight || person.weight} kg</p>
</div>
</div>
)}
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Measurements</p>
<p className="font-bold text-zinc-700">
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Measurements</p>
<p className="font-bold text-foreground">
{person.adult_specifics?.measurements || (
<>
{person.bust_size && `${person.bust_size}`}
@@ -179,48 +199,48 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{(person.hair_color || person.adult_specifics?.hair_color) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Hair Color</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.hair_color || person.hair_color}</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Hair Color</p>
<p className="font-bold text-foreground">{person.adult_specifics?.hair_color || person.hair_color}</p>
</div>
</div>
)}
{(person.eye_color || person.adult_specifics?.eye_color) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Eye size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Eye Color</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.eye_color || person.eye_color}</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Eye Color</p>
<p className="font-bold text-foreground">{person.adult_specifics?.eye_color || person.eye_color}</p>
</div>
</div>
)}
{person.adult_specifics?.tattoos && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Tattoos</p>
<p className="font-bold text-zinc-700">{person.adult_specifics.tattoos}</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Tattoos</p>
<p className="font-bold text-foreground">{person.adult_specifics.tattoos}</p>
</div>
</div>
)}
{person.adult_specifics?.piercings && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Piercings</p>
<p className="font-bold text-zinc-700">{person.adult_specifics.piercings}</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Piercings</p>
<p className="font-bold text-foreground">{person.adult_specifics.piercings}</p>
</div>
</div>
)}
@@ -232,10 +252,10 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
<div className="lg:col-span-2 space-y-12">
{person.bio && (
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<h2 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3">
Biography
</h2>
<p className="text-zinc-600 leading-relaxed text-lg">
<p className="text-foreground leading-relaxed text-lg">
{person.bio}
</p>
</section>
@@ -243,7 +263,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.filmography && person.filmography.length > 0 && (
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<h2 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3">
<User className="text-[#6d28d9]" />
Characters
</h2>
@@ -251,9 +271,9 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.filmography.map(item => (
<div
key={`${item.id}-char`}
className="flex items-center gap-4 p-4 rounded-2xl bg-zinc-50 border border-zinc-100"
className="flex items-center gap-4 p-5 rounded-2xl bg-muted/50 border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg transition-all duration-300"
>
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-white">
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-background">
<img
src={item.poster || person.photo}
alt={item.title}
@@ -262,14 +282,19 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Character</p>
<h4 className="font-black text-zinc-900 truncate">{item.characterName || item.role}</h4>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Character</p>
<h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4>
<button
onClick={() => handleMediaClick(item.id.toString())}
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left"
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left transition-colors"
>
in {item.title}
</button>
{item.category && (
<Badge variant="secondary" className="text-[10px] font-bold mt-2 bg-muted text-muted-foreground border-none">
{item.category}
</Badge>
)}
</div>
</div>
))}
@@ -279,18 +304,39 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.filmography && person.filmography.length > 0 && (
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<Film className="text-[#6d28d9]" />
Filmography
</h2>
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-black text-foreground flex items-center gap-3">
<Film className="text-[#6d28d9]" />
Filmography
</h2>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
className="rounded-xl border-border hover:border-[#6d28d9]/50 transition-all duration-300"
>
<ListFilter size={16} />
</Button>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'year' | 'title' | 'role')}
className="bg-muted/50 backdrop-blur-sm border border-border/50 rounded-xl px-4 py-2 text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-[#6d28d9]/50"
>
<option value="year">Year</option>
<option value="title">Title</option>
<option value="role">Role</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{person.filmography.map(item => (
{sortedFilmography.map(item => (
<div
key={item.id}
onClick={() => handleMediaClick(item.id.toString())}
className="group flex items-center gap-4 p-4 rounded-2xl bg-white border border-zinc-100 hover:border-[#6d28d9]/30 hover:shadow-lg transition-all cursor-pointer"
className="group flex items-center gap-4 p-4 rounded-2xl bg-card border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300 cursor-pointer"
>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 shadow-sm">
<div className="w-16 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border border-border/30">
<img
src={item.poster || person.photo}
alt={item.title}
@@ -299,16 +345,21 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
/>
</div>
<div className="min-w-0">
<h4 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
<h4 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
{item.title}
</h4>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-1">
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1">
{item.year || 'Unknown'}
</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-zinc-200">
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-border/50">
{item.role}
</Badge>
{item.category && (
<Badge variant="secondary" className="text-[10px] font-bold py-0 h-5 bg-muted text-muted-foreground border-none">
{item.category}
</Badge>
)}
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import Loading from '@/components/ui/loading';
import { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/lib/utils';
import { fetchAllCast } from '@/api';
@@ -12,20 +13,21 @@ import { fetchAllCast } from '@/api';
interface CastViewProps {
onPersonClick: (person: Staff) => void;
enabledCategories: MediaCategory[];
itemsPerPage?: number;
}
export default function CastView({ onPersonClick, enabledCategories }: CastViewProps) {
export default function CastView({ onPersonClick, enabledCategories, itemsPerPage: initialItemsPerPage = 12 }: CastViewProps) {
const navigate = useNavigate();
const [staffList, setStaffList] = useState<Staff[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(() => {
return localStorage.getItem('castSearchQuery') || '';
});
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height'>(() => {
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height') || 'name';
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height' | 'roleCount'>(() => {
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height' | 'roleCount') || 'roleCount';
});
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'asc';
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
});
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
return localStorage.getItem('castFilterOccupation') || '';
@@ -34,9 +36,16 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
return localStorage.getItem('castFilterMediaType') || '';
});
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(12);
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
const [showFilters, setShowFilters] = useState(false);
// Sync itemsPerPage with prop when API settings are loaded
useEffect(() => {
if (initialItemsPerPage) {
setItemsPerPage(initialItemsPerPage);
}
}, [initialItemsPerPage]);
// Persist filters and sorts
useEffect(() => {
localStorage.setItem('castSearchQuery', searchQuery);
@@ -60,13 +69,13 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
const handleResetFilters = () => {
setSearchQuery('');
setSortBy('name');
setSortOrder('asc');
setSortBy('roleCount');
setSortOrder('desc');
setFilterOccupation('');
setFilterMediaType('');
};
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'name' || sortOrder !== 'asc';
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
useEffect(() => {
const loadCast = async () => {
@@ -129,6 +138,10 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
const heightA = a.height || 0;
const heightB = b.height || 0;
comparison = heightA - heightB;
} else if (sortBy === 'roleCount') {
const roleCountA = a.filmography?.length || 0;
const roleCountB = b.filmography?.length || 0;
comparison = roleCountA - roleCountB;
}
return sortOrder === 'desc' ? -comparison : comparison;
@@ -173,27 +186,29 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
};
return (
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
<div>
<h1 className="text-4xl font-black text-zinc-900 mb-2">Cast & Staff</h1>
<p className="text-zinc-500 font-medium">Discover the people behind your favorite media</p>
<h1 className="text-5xl font-black text-foreground mb-3 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
Cast & Staff
</h1>
<p className="text-muted-foreground font-medium text-lg">Discover the people behind your favorite media</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
<Input
placeholder="Search cast..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 w-full md:w-[300px] bg-zinc-100 border-none rounded-full h-11"
className="pl-10 w-full md:w-[300px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-11"
/>
</div>
<Button
variant={showFilters ? 'default' : 'outline'}
size="icon"
className={`rounded-full h-11 w-11 ${showFilters ? 'bg-[#6d28d9] text-white border-[#6d28d9]' : 'border-zinc-200'}`}
className={`rounded-xl h-11 w-11 transition-all duration-300 ${showFilters ? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white border-[#6d28d9]' : 'border-border hover:border-[#6d28d9]/50'}`}
onClick={() => setShowFilters(!showFilters)}
>
<Filter size={20} />
@@ -201,7 +216,7 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
<Button
variant="outline"
size="icon"
className="rounded-full h-11 w-11 border-zinc-200"
className="rounded-xl h-11 w-11 border-border hover:border-[#6d28d9]/50 transition-all duration-300"
onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
>
<ArrowUpDown size={20} />
@@ -210,7 +225,7 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
<Button
variant="ghost"
size="icon"
className="rounded-full h-11 w-11 text-zinc-400 hover:text-zinc-900"
className="rounded-xl h-11 w-11 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-300"
onClick={handleResetFilters}
title="Reset filters"
>
@@ -225,28 +240,29 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="bg-zinc-50 rounded-2xl p-6 mb-6"
className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 mb-6 border border-border/50"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="text-sm font-bold text-zinc-700 mb-2 block">Sort By</label>
<label className="text-sm font-bold text-foreground mb-2 block">Sort By</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="name">Name</option>
<option value="role">Role</option>
<option value="birthDate">Birth Date</option>
<option value="height">Height</option>
<option value="roleCount">Role Count</option>
</select>
</div>
<div>
<label className="text-sm font-bold text-zinc-700 mb-2 block">Occupation</label>
<label className="text-sm font-bold text-foreground mb-2 block">Occupation</label>
<select
value={filterOccupation}
onChange={(e) => setFilterOccupation(e.target.value)}
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="">All Occupations</option>
{uniqueOccupations.map(occ => (
@@ -255,11 +271,11 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
</select>
</div>
<div>
<label className="text-sm font-bold text-zinc-700 mb-2 block">Media Type</label>
<label className="text-sm font-bold text-foreground mb-2 block">Media Type</label>
<select
value={filterMediaType}
onChange={(e) => setFilterMediaType(e.target.value)}
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="">All Media Types</option>
{uniqueMediaTypes.map(type => (
@@ -270,33 +286,33 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
</div>
<div className="mt-4 flex items-center gap-2">
{searchQuery && (
<Badge variant="secondary" className="gap-1">
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Search: {searchQuery}
<button onClick={() => setSearchQuery('')} className="hover:text-zinc-900">
<button onClick={() => setSearchQuery('')} className="hover:text-foreground">
<X size={12} />
</button>
</Badge>
)}
{filterOccupation && (
<Badge variant="secondary" className="gap-1">
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Occupation: {filterOccupation}
<button onClick={() => setFilterOccupation('')} className="hover:text-zinc-900">
<button onClick={() => setFilterOccupation('')} className="hover:text-foreground">
<X size={12} />
</button>
</Badge>
)}
{filterMediaType && (
<Badge variant="secondary" className="gap-1">
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Media Type: {filterMediaType}
<button onClick={() => setFilterMediaType('')} className="hover:text-zinc-900">
<button onClick={() => setFilterMediaType('')} className="hover:text-foreground">
<X size={12} />
</button>
</Badge>
)}
{(sortBy !== 'name' || sortOrder !== 'asc') && (
<Badge variant="secondary" className="gap-1">
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Sort: {sortBy} ({sortOrder})
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-zinc-900">
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-foreground">
<X size={12} />
</button>
</Badge>
@@ -306,14 +322,13 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
)}
{loading ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#6d28d9] mb-4" />
<p className="text-lg font-bold">Loading cast...</p>
</div>
<Loading message="Loading cast..." />
) : filteredStaff.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<User size={48} className="mb-4 opacity-20" />
<p className="text-lg font-bold">No cast members found</p>
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-sm border border-border/50">
<User size={40} />
</div>
<p className="text-xl font-bold">No cast members found</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
@@ -325,11 +340,11 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="group bg-white rounded-2xl p-4 shadow-sm border border-zinc-100 hover:shadow-xl hover:border-[#6d28d9]/20 transition-all duration-300 cursor-pointer"
className="group bg-card rounded-2xl p-5 shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 hover:shadow-[#6d28d9]/10 transition-all duration-300 cursor-pointer"
onClick={() => onPersonClick(person)}
>
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-zinc-100 group-hover:border-[#6d28d9] transition-colors">
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-border/50 group-hover:border-[#6d28d9] transition-colors duration-300">
<img
src={person.photo}
alt={person.name}
@@ -337,19 +352,24 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<h3 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
<div className="min-w-0 flex-1">
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
{person.name}
</h3>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider">
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{person.role}
</p>
</div>
{person.filmography && person.filmography.length > 0 && (
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold text-[10px] px-2 py-0.5 shrink-0">
{person.filmography.length}
</Badge>
)}
</div>
{person.filmography && person.filmography.length > 0 && (
<div className="bg-zinc-50 rounded-xl p-3 flex items-center gap-3">
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-white">
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-3 flex items-center gap-3 border border-border/30">
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background border border-border/30">
<img
src={person.filmography[0].poster || person.photo}
alt={person.filmography[0].title}
@@ -358,8 +378,8 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
/>
</div>
<div className="min-w-0">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest leading-none mb-1">Latest Role</p>
<p className="text-xs font-bold text-zinc-700 truncate">{person.filmography[0].title}</p>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest leading-none mb-1">Latest Role</p>
<p className="text-xs font-bold text-foreground truncate">{person.filmography[0].title}</p>
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">{person.filmography[0].role}</p>
</div>
</div>
@@ -372,17 +392,17 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
{/* Pagination Controls */}
{filteredStaff.length > 0 && (
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-zinc-100 pt-8">
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border/50 pt-8">
<div className="flex items-center gap-4">
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
<span className="text-sm text-muted-foreground font-medium">Items per page:</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
}}
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
className="bg-muted/50 backdrop-blur-sm border-none rounded-xl px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
{[8, 12, 16, 24, 48].map(size => (
{[12, 20, 36, 48, 60].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
@@ -394,7 +414,7 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 1}
className="gap-2 font-bold border-zinc-200"
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300"
>
<ChevronLeft size={16} />
Previous
@@ -402,8 +422,8 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
<div className="flex items-center gap-2">
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
<span className="text-sm text-zinc-400 font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span>
<span className="text-sm text-muted-foreground font-medium">of</span>
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
</div>
<Button
@@ -411,7 +431,7 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-zinc-200"
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300"
>
Next
<ChevronRight size={16} />

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

@@ -1,5 +1,6 @@
import { Media, Staff } from '@/types';
import { Media, Staff, Track } from '@/types';
import { useNavigate } from 'react-router-dom';
import { useState, useMemo, useEffect } from 'react';
import {
Play,
Bookmark,
@@ -8,7 +9,11 @@ import {
ChevronLeft,
ChevronRight,
Search,
ListFilter
ListFilter,
ChevronDown,
Calendar,
Clock,
Eye
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -23,34 +28,96 @@ interface DetailViewProps {
export default function DetailView({ media, onPersonClick }: DetailViewProps) {
const navigate = useNavigate();
const [castLimit, setCastLimit] = useState(6);
const [showAllCast, setShowAllCast] = useState(false);
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
const [progress, setProgress] = useState(70.8);
const hasEpisodes = media.episodes && media.episodes.length > 0;
const hasTracks = media.tracks && media.tracks.length > 0;
const hasCast = media.staff && media.staff.length > 0;
const tabs = [
'Overview',
...(hasCast ? ['Cast'] : []),
'Actions',
'History',
...(hasEpisodes ? ['Seasons'] : []),
...(hasTracks ? ['Tracks'] : []),
'Reviews',
'Suggestions',
'Watch On'
];
const [activeTab, setActiveTab] = useState(tabs[0]);
// Group episodes by season
const episodesBySeason = useMemo(() => {
if (!media.episodes) return {};
const grouped: Record<number, typeof media.episodes> = {};
media.episodes.forEach(episode => {
if (!grouped[episode.season]) {
grouped[episode.season] = [];
}
grouped[episode.season].push(episode);
});
// Sort episodes within each season by episode number
Object.keys(grouped).forEach(season => {
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
});
return grouped;
}, [media.episodes]);
// Expand first season by default on mount
useEffect(() => {
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
if (seasons.length > 0) {
setExpandedSeasons(new Set([seasons[0]]));
}
}, [episodesBySeason]);
const toggleSeason = (season: number) => {
setExpandedSeasons(prev => {
const newSet = new Set(prev);
if (newSet.has(season)) {
newSet.delete(season);
} else {
newSet.add(season);
}
return newSet;
});
};
const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []);
const hasMoreCast = (media.staff?.length || 0) > castLimit;
return (
<div className="min-h-screen bg-zinc-50">
<div className="min-h-screen bg-background">
{/* Banner */}
<div className="relative h-[400px] w-full overflow-hidden">
<div className="relative h-[450px] w-full overflow-hidden">
<img
src={media.banner || media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-zinc-50 via-zinc-50/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" />
<button
onClick={() => navigate(-1)}
className="absolute top-24 left-6 p-2 bg-black/20 hover:bg-black/40 text-white rounded-full transition-colors z-10"
className="absolute top-24 left-6 p-3 bg-black/30 hover:bg-black/50 backdrop-blur-md text-white rounded-2xl transition-all duration-300 hover:scale-110 z-10 border border-white/20 lg:left-80"
>
<ChevronLeft size={24} />
</button>
</div>
{/* Content */}
<div className="max-w-[1400px] mx-auto px-6 -mt-32 relative z-10 pb-24">
<div className="flex flex-col md:flex-row gap-8">
{/* Left Column: Poster */}
<div className="w-full md:w-[300px] shrink-0">
<div className="max-w-[1920px] mx-auto px-6 py-8 pb-24 -mt-32 relative z-10">
<div className="flex flex-col lg:flex-row gap-8">
{/* Left Column: Cover Image */}
<div className="w-full lg:w-[400px] shrink-0">
<motion.div
layoutId={`media-${media.id}`}
className={`rounded-xl overflow-hidden shadow-2xl bg-zinc-800 ${
className={`rounded-2xl overflow-hidden shadow-2xl bg-card border border-border/50 ${
media.aspectRatio === '16/9' ? 'aspect-video' :
media.aspectRatio === '1/1' ? 'aspect-square' :
'aspect-[2/3]'
@@ -66,218 +133,246 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
</div>
{/* Right Column: Info */}
<div className="flex-1 pt-32 md:pt-40">
<div className="flex flex-wrap items-end justify-between gap-4 mb-6">
<div>
<h1 className="text-4xl font-black text-zinc-900 mb-2">
{media.title} <span className="text-zinc-400 font-medium">({media.year})</span>
</h1>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button size="icon" className="rounded-full bg-[#6d28d9] hover:bg-[#5b21b6]">
<Play size={20} fill="currentColor" />
</Button>
<Button size="icon" variant="outline" className="rounded-full border-zinc-300">
<Bookmark size={20} />
</Button>
<Button size="icon" variant="outline" className="rounded-full border-zinc-300">
<MoreHorizontal size={20} />
</Button>
</div>
<div className="flex items-center gap-1 text-zinc-600 font-bold">
<Star size={18} className="text-yellow-500" fill="currentColor" />
{media.rating} / 10
</div>
</div>
</div>
<div className="hidden lg:block text-right">
<h3 className="text-xs font-black text-[#6d28d9] uppercase tracking-wider mb-2">Genres</h3>
<div className="flex flex-col items-end gap-1">
{media.genres?.map(genre => (
<span key={genre} className="text-sm font-bold text-zinc-600 hover:text-[#6d28d9] cursor-pointer transition-colors">
{genre}
</span>
))}
</div>
</div>
</div>
<p className="text-zinc-600 leading-relaxed mb-8 max-w-3xl">
{media.description}
</p>
{/* Tags */}
<div className="flex flex-wrap gap-2 mb-8">
{media.tags?.map(tag => (
<Badge key={tag} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20 border-none px-3 py-1 font-bold text-[10px] uppercase tracking-wider">
{tag}
<div className="flex-1">
{/* Header with tags */}
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-4xl lg:text-5xl font-black text-foreground">
{media.title}
</h1>
{media.status && (
<Badge className={
media.status === 'watching' || media.status === 'reading' || media.status === 'listening' || media.status === 'playing'
? 'bg-green-500/20 text-green-400 border-green-500/30 font-bold'
: media.status === 'completed'
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 font-bold'
: 'bg-gray-500/20 text-gray-400 border-gray-500/30 font-bold'
}>
{media.status.toUpperCase()}
</Badge>
))}
</div>
<div className="space-y-4">
{media.studios && media.studios.length > 0 && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Studios:</span>
{media.studios.join(', ')}
</p>
)}
{media.developers && media.developers.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Developers:</span>
{media.developers.map(dev => (
<Badge key={dev} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 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-zinc-400 uppercase tracking-widest">Platforms:</span>
{media.platforms.map(platform => (
<Badge key={platform} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 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-zinc-400 uppercase tracking-widest">Categories:</span>
{media.categories.map(category => (
<Badge key={category} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
{category}
</Badge>
))}
</div>
)}
{media.completionStatus && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Completion:</span>
{media.completionStatus}
</p>
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30 font-bold">{media.completionStatus.toUpperCase()}</Badge>
)}
{media.source && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Source:</span>
{media.source}
</p>
)}
{media.playCount !== undefined && media.playCount !== null && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 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-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Playtime:</span>
{media.playtime}h
</p>
)}
{media.lastActivity && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Last Activity:</span>
{media.lastActivity}
</p>
)}
<div className="flex items-center gap-4">
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Links:</span>
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">AniDb</Button>
</div>
</div>
</div>
</div>
{/* Staff Section - Only show if staff data exists */}
{media.staff && media.staff.length > 0 && (
<section className="mt-20">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-black text-zinc-900">Cast & Crew</h2>
<div className="flex gap-2">
<Button variant="outline" size="icon" className="rounded-full border-zinc-200">
<ChevronLeft size={18} />
</Button>
<Button variant="outline" size="icon" className="rounded-full border-zinc-200">
<ChevronRight size={18} />
</Button>
{/* Show Details */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar size={16} />
<span>{media.year}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{media.status ? media.status.charAt(0).toUpperCase() + media.status.slice(1) : 'Unknown'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock size={16} />
<span>{media.playtime ? `${media.playtime}h` : '12h 30m'}</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{media.staff.map(person => (
{/* Progress Bar */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-foreground">Progress</span>
<span className="text-sm font-bold text-[#6d28d9]">{progress}%</span>
</div>
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
key={person.id}
className="flex items-center gap-4 bg-white p-3 rounded-xl shadow-sm border border-zinc-100 hover:shadow-md transition-shadow cursor-pointer group"
onClick={() => onPersonClick(person)}
className="h-full bg-gradient-to-r from-[#6d28d9] to-[#8b5cf6] transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Navigation Tabs */}
<div className="flex flex-wrap gap-2 mb-6 border-b border-border/50 pb-4">
{tabs.map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0">
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" referrerPolicy="no-referrer" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
<p className="text-xs text-zinc-500 truncate">{person.role}</p>
</div>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-zinc-50">
<img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
</div>
</div>
{tab}
</button>
))}
</div>
</section>
)}
{/* Episodes Section - Only show if episodes data exists */}
{media.episodes && media.episodes.length > 0 && (
{/* Genre Tags */}
{activeTab === 'Overview' && (
<div className="flex flex-wrap gap-2 mb-6">
{media.genres?.map(genre => (
<Badge key={genre} variant="secondary" className="bg-muted/50 text-foreground hover:bg-muted/80 border border-border/50 px-3 py-1 font-bold text-sm">
{genre}
</Badge>
))}
</div>
)}
{/* Description */}
{activeTab === 'Overview' && (
<div
className="text-foreground leading-relaxed mb-8 max-w-4xl prose prose-sm dark:prose-invert"
dangerouslySetInnerHTML={{ __html: media.description || '' }}
/>
)}
{/* Acting Section - Horizontal Scrollable */}
{media.staff && media.staff.length > 0 && activeTab === 'Cast' && (
<section className="mt-12">
<h2 className="text-2xl font-black text-foreground mb-6">Acting</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
{displayedCast.map(person => (
<div
key={person.id}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 cursor-pointer group"
onClick={() => onPersonClick(person)}
>
<div className="w-full h-56 rounded-xl overflow-hidden mb-3 border border-border/30">
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" referrerPolicy="no-referrer" />
</div>
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4>
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
</div>
))}
{hasMoreCast && (
<button
onClick={() => setShowAllCast(!showAllCast)}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 flex items-center justify-center"
>
<span className="font-bold text-[#6d28d9]">
{showAllCast ? 'Show Less' : `+${media.staff!.length - castLimit} more`}
</span>
</button>
)}
</div>
</section>
)}
{/* Episodes Section - Only show if episodes data exists and Seasons tab is active */}
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
<section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-xl">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
<span className="opacity-40">{media.episodes.length}</span> Episode{media.episodes.length !== 1 ? 's' : ''}
</div>
<div className="text-sm font-bold text-muted-foreground">
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-zinc-100 border-none rounded-full h-9 text-sm" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-zinc-400">
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-zinc-400">
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-6">
{media.episodes.map(episode => (
<div key={episode.id} className="group cursor-pointer">
<div className="flex flex-col md:flex-row gap-6">
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-xl overflow-hidden shadow-sm relative">
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
</div>
<div className="flex-1 py-1">
<div className="flex items-center justify-between mb-2">
<h3 className="font-black text-zinc-900 group-hover:text-[#6d28d9] transition-colors">
S1:E{episode.number} {episode.title}
</h3>
<span className="text-xs font-bold text-zinc-400">{episode.date} {episode.duration}</span>
<div className="space-y-4">
{Object.keys(episodesBySeason)
.map(Number)
.sort((a, b) => a - b)
.map(season => (
<div key={season} className="border border-border/50 rounded-2xl overflow-hidden bg-card/50 backdrop-blur-sm">
<button
onClick={() => toggleSeason(season)}
className="w-full flex items-center justify-between p-6 bg-card/50 hover:bg-muted/50 transition-colors duration-300"
>
<div className="flex items-center gap-4">
<h3 className="text-2xl font-black text-foreground">Season {season}</h3>
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold">
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
</Badge>
</div>
<p className="text-sm text-zinc-500 leading-relaxed line-clamp-3">
{episode.description}
</p>
</div>
<ChevronDown
size={24}
className={`transition-transform duration-300 text-muted-foreground ${
expandedSeasons.has(season) ? 'rotate-180' : ''
}`}
/>
</button>
{expandedSeasons.has(season) && (
<div className="p-6 pt-0 space-y-6">
{episodesBySeason[season].map(episode => (
<div key={episode.id} className="group cursor-pointer">
<div className="flex flex-col md:flex-row gap-6">
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-2xl overflow-hidden shadow-sm relative border border-border/30">
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
</div>
<div className="flex-1 py-1">
<div className="flex items-center justify-between mb-2">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
E{episode.episode_number} {episode.title}
</h3>
<span className="text-xs font-bold text-muted-foreground">{episode.air_date} {episode.duration}m</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
{episode.description}
</p>
</div>
</div>
<Separator className="mt-6 bg-border/50" />
</div>
))}
</div>
)}
</div>
<Separator className="mt-6 bg-zinc-200" />
))}
</div>
</section>
)}
{/* Tracks Section - Only show if tracks data exists and Tracks tab is active */}
{media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
<section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
<span className="opacity-40">{media.tracks.length}</span> Track{media.tracks.length !== 1 ? 's' : ''}
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-2">
{media.tracks.map(track => (
<div key={track.id} className="group cursor-pointer flex items-center gap-4 p-4 rounded-2xl hover:bg-muted/50 transition-colors duration-300 border border-transparent hover:border-border/30">
<span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
<div className="flex-1">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
{track.title}
</h3>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
<span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span>
</div>
))}
</div>
</section>
)}
</div>
</div>
</div>
</div>
);

View File

@@ -1,9 +1,9 @@
import { Search, User, X, Plus, Download } from 'lucide-react';
import { Search, User, X, Plus, Download, Settings, Menu } from 'lucide-react';
import { cn } from '@/lib/utils';
import React, { useState } from 'react';
import { Link, NavLink } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Link, NavLink, useLocation } from 'react-router-dom';
import { MediaCategory } from '@/types';
import LibrarySettings from './LibrarySettings';
import { useTheme } from '@/contexts/ThemeContext';
interface HeaderProps {
onSearch: (query: string) => void;
@@ -24,6 +24,31 @@ export default function Header({
}: HeaderProps) {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [scrolled, setScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { theme } = useTheme();
const location = useLocation();
// Map category names to URL-friendly paths
const categoryPaths: Record<MediaCategory, string> = {
'Anime': 'anime',
'Movies': 'movies',
'TV Series': 'tv-series',
'Music': 'music',
'Books': 'books',
'Games': 'games',
'Consoles': 'consoles',
'Adult': 'adult'
};
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
@@ -42,82 +67,156 @@ export default function Header({
return (
<header
className={cn(
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-300",
transparent ? "bg-transparent" : "bg-[#6d28d9]"
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-500",
transparent && !scrolled
? "bg-transparent"
: transparent && scrolled
? "backdrop-blur-xl bg-background/70 border-b border-border/30"
: "backdrop-blur-xl bg-gradient-to-r from-[#6d28d9]/90 via-[#8b5cf6]/90 to-[#6d28d9]/90 border-b border-white/10"
)}
>
<div className="flex items-center gap-8">
<Link
to="/"
className="text-2xl font-black text-white flex items-center gap-1"
className={cn(
"text-2xl font-black flex items-center gap-2 transition-all duration-300 hover:scale-105",
(transparent && !scrolled) || !transparent ? "text-white" : "text-foreground"
)}
>
<div className="w-6 h-6 bg-white rounded-full flex items-center justify-center">
<div className="w-3 h-3 bg-[#6d28d9] rounded-full" />
<div className={cn(
"w-8 h-8 rounded-xl flex items-center justify-center shadow-lg transition-all duration-300",
(transparent && !scrolled) || !transparent
? "bg-white/20 backdrop-blur-sm border border-white/30"
: "bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] shadow-[#6d28d9]/30"
)}>
<div className={cn(
"w-4 h-4 rounded-full",
(transparent && !scrolled) || !transparent ? "bg-white" : "bg-white"
)} />
</div>
kyoo
<span className="bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
omnyx
</span>
</Link>
<nav className="hidden md:flex items-center gap-6">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className={cn(
"md:hidden p-2 rounded-lg transition-all duration-300 hover:bg-white/10",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Menu size={20} />
</button>
<nav className="hidden md:flex items-center gap-1">
{enabledCategories.map(cat => (
<button
<NavLink
key={cat}
onClick={() => onCategoryChange(cat)}
className={cn(
"text-sm font-bold transition-colors uppercase tracking-wider",
activeCategory === cat ? "text-white" : "text-white/60 hover:text-white"
to={`/${categoryPaths[cat]}`}
className={({ isActive }) => cn(
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg relative",
(transparent && !scrolled) || !transparent
? isActive
? "text-white bg-white/10"
: "text-white/70 hover:text-white hover:bg-white/5"
: isActive
? "text-foreground bg-[#6d28d9]/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{cat}
</button>
</NavLink>
))}
<div className="w-px h-4 bg-white/20 mx-2" />
<div className={cn(
"w-px h-6 mx-2",
(transparent && !scrolled) || !transparent ? "bg-white/20" : "bg-border"
)} />
<NavLink
to="/cast"
className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider",
isActive ? "text-white" : "text-white/60 hover:text-white"
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg",
(transparent && !scrolled) || !transparent
? isActive ? "text-white bg-white/10" : "text-white/70 hover:text-white hover:bg-white/5"
: isActive ? "text-foreground bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
CAST
</NavLink>
</nav>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className={cn(
"flex items-center transition-all duration-300 overflow-hidden",
isSearchOpen ? "w-48 md:w-64 bg-white/10 rounded-full px-3 py-1" : "w-0"
"flex items-center transition-all duration-300 overflow-hidden rounded-2xl",
isSearchOpen ? "w-48 md:w-72 px-4 py-2.5" : "w-0",
(transparent && !scrolled) || !transparent
? "bg-white/10 backdrop-blur-md border border-white/20"
: "bg-muted/50 backdrop-blur-md border border-border"
)}>
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={handleSearchChange}
className="bg-transparent border-none outline-none text-white text-sm w-full placeholder:text-white/50"
className={cn(
"bg-transparent border-none outline-none text-sm w-full placeholder:opacity-60",
(transparent && !scrolled) || !transparent
? "text-white placeholder:text-white"
: "text-foreground placeholder:text-muted-foreground"
)}
autoFocus={isSearchOpen}
/>
</div>
<button
onClick={toggleSearch}
className="p-2 text-white/90 hover:text-white transition-colors"
className={cn(
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
{isSearchOpen ? <X size={20} /> : <Search size={20} />}
{isSearchOpen ? <X size={18} /> : <Search size={18} />}
</button>
<Link
to="/add"
className="p-2 text-white/90 hover:text-white transition-colors"
className={cn(
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Plus size={20} />
<Plus size={18} />
</Link>
<Link
to="/import"
className="p-2 text-white/90 hover:text-white transition-colors"
className={cn(
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Download size={20} />
<Download size={18} />
</Link>
<LibrarySettings
enabledCategories={enabledCategories}
onToggleCategory={onToggleCategory}
/>
<button className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20">
<Link
to="/settings"
className={cn(
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Settings size={18} />
</Link>
<button className={cn(
"w-9 h-9 rounded-xl overflow-hidden border-2 transition-all duration-300 hover:scale-110 hover:shadow-lg",
(transparent && !scrolled) || !transparent
? "border-white/30 hover:border-white/50"
: "border-border hover:border-[#6d28d9]/50"
)}>
<img
src="https://picsum.photos/seed/user/100/100"
alt="User"
@@ -126,6 +225,38 @@ export default function Header({
/>
</button>
</div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden absolute top-full left-0 right-0 bg-background border-b border-border shadow-lg">
<nav className="flex flex-col p-4 gap-2">
{enabledCategories.map(cat => (
<NavLink
key={cat}
to={`/${categoryPaths[cat]}`}
onClick={() => setIsMobileMenuOpen(false)}
className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
isActive ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{cat}
</NavLink>
))}
<div className="w-full h-px bg-border my-2" />
<NavLink
to="/cast"
onClick={() => setIsMobileMenuOpen(false)}
className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
isActive ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
CAST
</NavLink>
</nav>
</div>
)}
</header>
);
}

View File

@@ -6,19 +6,92 @@ import { cn } from '@/lib/utils';
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter';
import { fetchSettings, updateSettings } from '@/api';
const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:3000';
export default function ImporterView() {
const navigate = useNavigate();
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: import.meta.env.VITE_XBVR_URL || '' });
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: import.meta.env.VITE_XBVR_URL || BASE_URL, updateExisting: true });
const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({
url: import.meta.env.VITE_STASHAPP_URL || '',
apiKey: import.meta.env.VITE_STASHAPP_API_KEY || ''
apiKey: import.meta.env.VITE_STASHAPP_API_KEY || '',
updateExisting: true
});
const [playniteConfig, setPlayniteConfig] = useState<PlayniteConfig>({
ip: import.meta.env.VITE_PLAYNITE_IP || '',
apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '',
port: parseInt(import.meta.env.VITE_PLAYNITE_PORT || '19821')
port: import.meta.env.VITE_PLAYNITE_PORT ? parseInt(import.meta.env.VITE_PLAYNITE_PORT) : undefined,
updateExisting: true
});
const [jellyfinConfig, setJellyfinConfig] = useState<JellyfinConfig>({
url: import.meta.env.VITE_JELLYFIN_URL || '',
apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || ''
});
const [jellyfinOptions, setJellyfinOptions] = useState<JellyfinImportOptions>({
importMovies: true,
importSeries: true,
importMusic: true,
importCast: true,
limit: undefined,
libraryMappings: [],
updateExisting: true
});
const [jellyfinLibraries, setJellyfinLibraries] = useState<Array<{ Id: string; Name: string; CollectionType: string }>>([]);
const [libraryMappings, setLibraryMappings] = useState<LibraryMapping[]>([]);
const [showLibraryMapping, setShowLibraryMapping] = useState(false);
const [isInitialLoad, setIsInitialLoad] = useState(true);
// Load library mappings from API on mount
useEffect(() => {
const loadMappings = async () => {
try {
const settings = await fetchSettings();
if (settings?.jellyfinLibraryMappings) {
const mappings = JSON.parse(settings.jellyfinLibraryMappings);
setLibraryMappings(mappings);
setShowLibraryMapping(true);
}
} catch (error) {
console.error('Failed to load library mappings from API:', error);
// Fallback to localStorage
const savedMappings = localStorage.getItem('jellyfinLibraryMappings');
if (savedMappings) {
try {
setLibraryMappings(JSON.parse(savedMappings));
setShowLibraryMapping(true);
} catch (error) {
console.error('Failed to parse saved library mappings:', error);
}
}
}
setIsInitialLoad(false);
};
loadMappings();
}, []);
// Save library mappings to API and localStorage when they change
useEffect(() => {
if (libraryMappings.length > 0 && !isInitialLoad) {
// Save to localStorage as fallback
localStorage.setItem('jellyfinLibraryMappings', JSON.stringify(libraryMappings));
// Save to API
const saveMappings = async () => {
try {
const settings = await fetchSettings();
if (settings) {
settings.jellyfinLibraryMappings = JSON.stringify(libraryMappings);
await updateSettings(settings);
}
} catch (error) {
console.error('Failed to save library mappings to API:', error);
}
};
saveMappings();
}
}, [libraryMappings, isInitialLoad]);
const [progress, setProgress] = useState<ImportProgress>({
current: 0,
total: 0,
@@ -135,6 +208,120 @@ export default function ImporterView() {
setProgress(result);
};
const handleJellyfinImport = async () => {
setProgress({
current: 0,
total: 0,
stage: 'fetching',
message: 'Connecting to Jellyfin API...',
videosImported: 0,
actorsImported: 0,
errors: []
});
setImportLog([]);
// Update options with current library mappings
const optionsWithMappings = {
...jellyfinOptions,
libraryMappings: libraryMappings
};
const result = await importFromJellyfin(
jellyfinConfig,
optionsWithMappings,
addLog,
(progressUpdate) => {
setProgress(prev => ({ ...prev, ...progressUpdate }));
}
);
setProgress(result);
};
const handleJellyfinCleanup = async () => {
setProgress({
current: 0,
total: 0,
stage: 'fetching',
message: 'Connecting to Jellyfin API for cleanup...',
videosImported: 0,
actorsImported: 0,
errors: []
});
setImportLog([]);
const result = await cleanupJellyfinMedia(
jellyfinConfig,
jellyfinOptions,
addLog,
(progressUpdate) => {
setProgress(prev => ({ ...prev, ...progressUpdate }));
}
);
setProgress(result);
};
const handleFetchJellyfinLibraries = async () => {
try {
const libraries = await fetchJellyfinLibraries(jellyfinConfig);
setJellyfinLibraries(libraries);
// Merge existing mappings with new libraries
const newMappings: LibraryMapping[] = libraries.map(lib => {
// Check if mapping already exists
const existing = libraryMappings.find(m => m.libraryName === lib.Name);
if (existing) {
return existing;
}
// Create new mapping with default category
let defaultCategory: 'TV Series' | 'Anime' | 'Movies' | 'Music' = 'TV Series';
if (lib.CollectionType === 'movies') {
defaultCategory = 'Movies';
} else if (lib.CollectionType === 'music') {
defaultCategory = 'Music';
} else if (lib.CollectionType === 'tvshows') {
defaultCategory = 'TV Series';
}
return {
libraryName: lib.Name,
category: defaultCategory
};
});
setLibraryMappings(newMappings);
setShowLibraryMapping(true);
addLog(`Fetched ${libraries.length} libraries from Jellyfin`);
} catch (error) {
addLog(`Failed to fetch libraries: ${error}`);
}
};
const handleLibraryMappingChange = (libraryName: string, category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip') => {
setLibraryMappings(prev => {
const existing = prev.find(m => m.libraryName === libraryName);
if (existing) {
return prev.map(m => m.libraryName === libraryName ? { ...m, category } : m);
} else {
return [...prev, { libraryName, category }];
}
});
};
const handleLibraryPathSegmentsChange = (libraryName: string, value: string) => {
const segments = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
setLibraryMappings(prev => {
const existing = prev.find(m => m.libraryName === libraryName);
if (existing) {
return prev.map(m => m.libraryName === libraryName ? { ...m, pathSegments: segments } : m);
} else {
return [...prev, { libraryName, category: 'TV Series', pathSegments: segments }];
}
});
};
const resetImport = () => {
setProgress({
current: 0,
@@ -154,7 +341,7 @@ export default function ImporterView() {
};
return (
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
@@ -162,13 +349,13 @@ export default function ImporterView() {
variant="ghost"
size="icon"
onClick={() => navigate('/')}
className="text-zinc-600 hover:text-[#6d28d9]"
className="text-muted-foreground hover:text-[#6d28d9] hover:bg-muted/50 rounded-xl transition-all duration-300"
>
<ArrowLeft size={20} />
</Button>
<div>
<h1 className="text-2xl font-black text-zinc-900">Media Importers</h1>
<p className="text-sm text-zinc-500 font-medium">Import media from external platforms</p>
<h1 className="text-4xl font-black text-foreground mb-1">Media Importers</h1>
<p className="text-sm text-muted-foreground font-medium">Import media from external platforms</p>
</div>
</div>
</div>
@@ -177,41 +364,52 @@ export default function ImporterView() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{/* XBVR Importer Card */}
{xbvrConfig.url && (
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<Film className="text-purple-600" size={24} />
</div>
<div>
<h3 className="font-bold text-zinc-900">XBVR</h3>
<p className="text-xs text-zinc-500 font-medium">Adult Video Manager</p>
<h3 className="font-bold text-foreground">XBVR</h3>
<p className="text-xs text-muted-foreground font-medium">Adult Video Manager</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-zinc-200"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-zinc-600 mb-4">
<p className="text-sm text-muted-foreground mb-4">
Import adult videos and actors from your XBVR database.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">XBVR URL</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">XBVR URL</label>
<input
type="text"
value={xbvrConfig.url}
onChange={(e) => setXbvrConfig({ ...xbvrConfig, url: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="http://192.168.1.102:10001"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="xbvr-update-existing"
checked={xbvrConfig.updateExisting}
onChange={(e) => setXbvrConfig({ ...xbvrConfig, updateExisting: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<label htmlFor="xbvr-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
</div>
<Button
onClick={handleXBVRImport}
disabled={progress.stage !== 'idle' || !xbvrConfig.url}
@@ -235,52 +433,63 @@ export default function ImporterView() {
{/* StashAPP Importer Card */}
{stashappConfig.url && (
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<Film className="text-blue-600" size={24} />
</div>
<div>
<h3 className="font-bold text-zinc-900">StashAPP</h3>
<p className="text-xs text-zinc-500 font-medium">Adult Content Manager</p>
<h3 className="font-bold text-foreground">StashAPP</h3>
<p className="text-xs text-muted-foreground font-medium">Adult Content Manager</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-zinc-200"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-zinc-600 mb-4">
<p className="text-sm text-muted-foreground mb-4">
Import adult videos and performers from your StashAPP database.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">StashAPP URL</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">StashAPP URL</label>
<input
type="text"
value={stashappConfig.url}
onChange={(e) => setStashappConfig({ ...stashappConfig, url: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="http://192.168.1.102:10001"
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Key (optional)</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Key (optional)</label>
<input
type="password"
value={stashappConfig.apiKey || ''}
onChange={(e) => setStashappConfig({ ...stashappConfig, apiKey: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="Enter API key if required"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="stashapp-update-existing"
checked={stashappConfig.updateExisting}
onChange={(e) => setStashappConfig({ ...stashappConfig, updateExisting: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<label htmlFor="stashapp-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
</div>
<Button
onClick={handleStashAPPImport}
disabled={progress.stage !== 'idle' || !stashappConfig.url}
@@ -304,38 +513,38 @@ export default function ImporterView() {
{/* StashAPP Actor Updater Card */}
{stashappConfig.url && (
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<Users className="text-green-600" size={24} />
</div>
<div>
<h3 className="font-bold text-zinc-900">StashAPP Actor Updater</h3>
<p className="text-xs text-zinc-500 font-medium">Update existing actors</p>
<h3 className="font-bold text-foreground">StashAPP Actor Updater</h3>
<p className="text-xs text-muted-foreground font-medium">Update existing actors</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-zinc-200"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-zinc-600 mb-4">
<p className="text-sm text-muted-foreground mb-4">
Update existing actors with fresh data from StashAPP and create missing ones.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Key (optional)</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Key (optional)</label>
<input
type="password"
value={stashappConfig.apiKey || ''}
onChange={(e) => setStashappConfig({ ...stashappConfig, apiKey: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="Enter API key if required"
/>
</div>
@@ -362,63 +571,74 @@ export default function ImporterView() {
{/* Playnite Importer Card */}
{playniteConfig.ip && playniteConfig.apiToken && (
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
<Film className="text-orange-600" size={24} />
</div>
<div>
<h3 className="font-bold text-zinc-900">Playnite</h3>
<p className="text-xs text-zinc-500 font-medium">Game Library Manager</p>
<h3 className="font-bold text-foreground">Playnite</h3>
<p className="text-xs text-muted-foreground font-medium">Game Library Manager</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-zinc-200"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-zinc-600 mb-4">
<p className="text-sm text-muted-foreground mb-4">
Import games from your Playnite library via Bridge API.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">IP Address</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">IP Address</label>
<input
type="text"
value={playniteConfig.ip}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, ip: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="localhost"
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">Port</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Port</label>
<input
type="number"
value={playniteConfig.port || 19821}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, port: parseInt(e.target.value) || 19821 })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="19821"
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Token</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Token</label>
<input
type="password"
value={playniteConfig.apiToken}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, apiToken: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="pb_your_token_here"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="playnite-update-existing"
checked={playniteConfig.updateExisting}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, updateExisting: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<label htmlFor="playnite-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
</div>
<Button
onClick={handlePlayniteImport}
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
@@ -439,29 +659,303 @@ export default function ImporterView() {
</div>
</div>
)}
{/* Jellyfin Importer Card */}
{jellyfinConfig.url && (
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<Film className="text-indigo-600" size={24} />
</div>
<div>
<h3 className="font-bold text-foreground">Jellyfin</h3>
<p className="text-xs text-muted-foreground font-medium">Media Server</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Import movies, series, music and cast from your Jellyfin server.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Jellyfin URL</label>
<input
type="text"
value={jellyfinConfig.url}
onChange={(e) => setJellyfinConfig({ ...jellyfinConfig, url: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="http://192.168.1.102:8096"
/>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Key</label>
<input
type="password"
value={jellyfinConfig.apiKey || ''}
onChange={(e) => setJellyfinConfig({ ...jellyfinConfig, apiKey: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="Enter API key"
/>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-2 block">Import Options</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMovies}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMovies: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Movies</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importSeries}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importSeries: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Series</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMusic}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMusic: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Music</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importCast}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importCast: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Cast</span>
</label>
</div>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Limit (optional, for testing)</label>
<input
type="number"
value={jellyfinOptions.limit || ''}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, limit: e.target.value ? parseInt(e.target.value) : undefined })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="e.g. 10"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="jellyfin-update-existing"
checked={jellyfinOptions.updateExisting}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, updateExisting: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<label htmlFor="jellyfin-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-2 block">Library Category Mapping</label>
<Button
onClick={handleFetchJellyfinLibraries}
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
variant="outline"
className="w-full mb-3 font-bold border-border"
>
<RefreshCw size={16} className="mr-2" />
Fetch Libraries
</Button>
{showLibraryMapping && libraryMappings.length > 0 && (
<div className="space-y-2 max-h-48 overflow-y-auto">
{libraryMappings.map(mapping => (
<div key={mapping.libraryName} className="space-y-1 p-2 border border-border rounded-lg">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted-foreground flex-1 truncate">{mapping.libraryName}</span>
<select
value={mapping.category}
onChange={(e) => handleLibraryMappingChange(mapping.libraryName, e.target.value as any)}
disabled={progress.stage !== 'idle'}
className="text-xs px-2 py-1 border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
>
<option value="TV Series">TV Series</option>
<option value="Anime">Anime</option>
<option value="Movies">Movies</option>
<option value="Music">Music</option>
<option value="skip">Nicht importieren</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Pfad-Segmente (kommagetrennt):</span>
<input
type="text"
value={mapping.pathSegments?.join(', ') || ''}
onChange={(e) => handleLibraryPathSegmentsChange(mapping.libraryName, e.target.value)}
disabled={progress.stage !== 'idle'}
placeholder="z.B. Serien, Animes"
className="text-xs px-2 py-1 border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed flex-1"
/>
</div>
</div>
))}
</div>
)}
</div>
<Button
onClick={handleJellyfinImport}
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold"
>
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
Importing...
</>
) : (
<>
<Download size={16} className="mr-2" />
Import from Jellyfin
</>
)}
</Button>
</div>
</div>
)}
{/* Jellyfin Cleanup Card */}
{jellyfinConfig.url && (
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<RefreshCw className="text-red-600" size={24} />
</div>
<div>
<h3 className="font-bold text-foreground">Jellyfin Cleanup</h3>
<p className="text-xs text-muted-foreground font-medium">Remove deleted media</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Remove Jellyfin media and cast that no longer exist in your Jellyfin server.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-muted-foreground mb-2 block">Cleanup Options</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMovies}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMovies: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Movies</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importSeries}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importSeries: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Series</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMusic}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMusic: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Music</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importCast}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importCast: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Cast</span>
</label>
</div>
</div>
<Button
onClick={handleJellyfinCleanup}
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
className="w-full bg-red-600 hover:bg-red-700 text-white font-bold"
>
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
Cleaning up...
</>
) : (
<>
<RefreshCw size={16} className="mr-2" />
Cleanup Jellyfin Media
</>
)}
</Button>
</div>
</div>
)}
</div>
{/* Progress Section */}
{progress.stage !== 'idle' && (
<div className="bg-white border border-zinc-200 rounded-xl p-6">
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
{progress.stage === 'complete' ? (
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="text-green-600" size={20} />
<div className="w-10 h-10 bg-green-500/10 rounded-full flex items-center justify-center border border-green-500/30">
<CheckCircle className="text-green-500" size={20} />
</div>
) : progress.stage === 'error' ? (
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<XCircle className="text-red-600" size={20} />
<div className="w-10 h-10 bg-red-500/10 rounded-full flex items-center justify-center border border-red-500/30">
<XCircle className="text-red-500" size={20} />
</div>
) : (
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
<Loader2 className="text-purple-600 animate-spin" size={20} />
<div className="w-10 h-10 bg-purple-500/10 rounded-full flex items-center justify-center border border-purple-500/30">
<Loader2 className="text-purple-500 animate-spin" size={20} />
</div>
)}
<div>
<h3 className="font-bold text-zinc-900">{progress.message}</h3>
<p className="text-xs text-zinc-500 font-medium">
<h3 className="font-bold text-foreground">{progress.message}</h3>
<p className="text-xs text-muted-foreground font-medium">
{progress.stage === 'fetching' && 'Connecting to external service...'}
{progress.stage === 'importing' && `Processing items... ${getProgressPercentage()}%`}
{progress.stage === 'complete' && 'Import finished'}
@@ -474,7 +968,7 @@ export default function ImporterView() {
variant="outline"
size="sm"
onClick={resetImport}
className="gap-2 font-bold border-zinc-200"
className="gap-2 font-bold border-border/50 hover:border-[#6d28d9]/50 transition-all duration-300"
>
<RefreshCw size={16} />
Reset
@@ -485,16 +979,16 @@ export default function ImporterView() {
{/* Progress Bar */}
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
<div className="mb-6">
<div className="h-2 bg-zinc-100 rounded-full overflow-hidden">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full transition-all duration-300 ease-out",
progress.stage === 'error' ? "bg-red-500" : "bg-[#6d28d9]"
progress.stage === 'error' ? "bg-gradient-to-r from-red-500 to-red-600" : "bg-gradient-to-r from-[#6d28d9] to-[#8b5cf6]"
)}
style={{ width: `${getProgressPercentage()}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-xs text-zinc-500 font-medium">
<div className="flex justify-between mt-2 text-xs text-muted-foreground font-medium">
<span>{progress.current} / {progress.total} items</span>
<span>{getProgressPercentage()}%</span>
</div>
@@ -503,26 +997,36 @@ export default function ImporterView() {
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-zinc-50 rounded-lg p-4">
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
<div className="flex items-center gap-2 mb-2">
<Film size={16} className="text-zinc-400" />
<span className="text-xs font-bold text-zinc-500">{(progress as any).gamesImported !== undefined ? 'Games' : 'Videos'}</span>
<Film size={16} className="text-[#6d28d9]" />
<span className="text-xs font-bold text-muted-foreground">
{(progress as any).gamesImported !== undefined ? 'Games' :
(progress as any).moviesImported !== undefined ? 'Movies' :
(progress as any).seriesImported !== undefined ? 'Series' :
(progress as any).musicImported !== undefined ? 'Music' : 'Videos'}
</span>
</div>
<p className="text-2xl font-black text-zinc-900">{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported : progress.videosImported}</p>
<p className="text-2xl font-black text-foreground">
{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported :
(progress as any).moviesImported !== undefined ? (progress as any).moviesImported :
(progress as any).seriesImported !== undefined ? (progress as any).seriesImported :
(progress as any).musicImported !== undefined ? (progress as any).musicImported : progress.videosImported}
</p>
</div>
<div className="bg-zinc-50 rounded-lg p-4">
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
<div className="flex items-center gap-2 mb-2">
<Users size={16} className="text-zinc-400" />
<span className="text-xs font-bold text-zinc-500">Actors</span>
<Users size={16} className="text-[#6d28d9]" />
<span className="text-xs font-bold text-muted-foreground">{(progress as any).castImported !== undefined ? 'Cast' : 'Actors'}</span>
</div>
<p className="text-2xl font-black text-zinc-900">{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 className="bg-zinc-50 rounded-lg p-4">
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
<div className="flex items-center gap-2 mb-2">
<AlertCircle size={16} className="text-zinc-400" />
<span className="text-xs font-bold text-zinc-500">Errors</span>
<AlertCircle size={16} className="text-red-500" />
<span className="text-xs font-bold text-muted-foreground">Errors</span>
</div>
<p className="text-2xl font-black text-zinc-900">{progress.errors.length}</p>
<p className="text-2xl font-black text-foreground">{progress.errors.length}</p>
</div>
</div>
@@ -530,7 +1034,7 @@ export default function ImporterView() {
{importLog.length > 0 && (
<div
ref={logContainerRef}
className="bg-zinc-900 rounded-lg p-4 max-h-64 overflow-y-auto"
className="bg-zinc-900/90 backdrop-blur-sm rounded-xl p-4 max-h-64 overflow-y-auto border border-border/50"
>
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">
{importLog.join('\n')}
@@ -541,10 +1045,10 @@ export default function ImporterView() {
{/* Errors */}
{progress.errors.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-bold text-red-600 mb-2">Errors</h4>
<div className="bg-red-50 border border-red-200 rounded-lg p-3 max-h-32 overflow-y-auto">
<h4 className="text-sm font-bold text-red-500 mb-2">Errors</h4>
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 max-h-32 overflow-y-auto backdrop-blur-sm">
{progress.errors.map((error, index) => (
<p key={index} className="text-xs text-red-700 font-medium mb-1">
<p key={index} className="text-xs text-red-500 font-medium mb-1">
{error}
</p>
))}

View File

@@ -21,6 +21,7 @@ interface LibrarySettingsProps {
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
Anime: <Tv size={18} />,
Movies: <Film size={18} />,
'TV Series': <Tv size={18} />,
Music: <Music size={18} />,
Books: <Book size={18} />,
Consoles: <Gamepad2 size={18} />,
@@ -34,29 +35,29 @@ export default function LibrarySettings({ enabledCategories, onToggleCategory }:
return (
<Dialog>
<DialogTrigger asChild>
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-colors">
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-all duration-300 hover:scale-110">
<Settings size={20} />
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
<DialogContent className="sm:max-w-[425px] bg-card/50 backdrop-blur-sm rounded-3xl border border-border/50">
<DialogHeader>
<DialogTitle className="text-2xl font-black text-zinc-900">Library Settings</DialogTitle>
<DialogDescription className="text-zinc-500 font-medium">
<DialogTitle className="text-2xl font-black text-foreground">Library Settings</DialogTitle>
<DialogDescription className="text-muted-foreground font-medium">
Toggle which media areas you want to see in your library.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-6">
{categories.map((category) => (
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 border border-zinc-100 transition-all hover:border-[#6d28d9]/20">
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-muted/30 border border-border/50 transition-all hover:border-[#6d28d9]/30 hover:bg-muted/50">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/30">
{CATEGORY_ICONS[category]}
</div>
<div>
<Label htmlFor={category} className="text-sm font-black text-zinc-900 cursor-pointer">
<Label htmlFor={category} className="text-sm font-black text-foreground cursor-pointer">
{category}
</Label>
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
{enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
</p>
</div>

View File

@@ -1,6 +1,7 @@
import { Media } from '@/types';
import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import { Star } from 'lucide-react';
interface MediaCardProps {
key?: string;
@@ -48,34 +49,58 @@ export default function MediaCard({ media, onClick }: MediaCardProps) {
layoutId={`media-${media.id}`}
className="group cursor-pointer"
onClick={() => onClick(media)}
whileHover={{ y: -4 }}
transition={{ duration: 0.2 }}
whileHover={{ y: -8, scale: 1.02 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<div className={cn(
"relative rounded-lg overflow-hidden shadow-lg bg-zinc-800 transition-all duration-300",
"relative rounded-2xl overflow-hidden bg-card transition-all duration-500 shadow-lg group-hover:shadow-2xl group-hover:shadow-[#6d28d9]/20",
aspectRatioClass
)}>
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
referrerPolicy="no-referrer"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{/* Rating Badge */}
{media.rating && (
<div className="absolute top-3 right-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-full flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-all duration-500 transform translate-y-[-10px] group-hover:translate-y-0">
<Star size={12} className="text-yellow-400 fill-yellow-400" />
<span className="text-xs font-bold text-white">{media.rating}</span>
</div>
)}
{media.status && (
<div className={cn(
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
"absolute top-3 left-3 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-lg z-10",
statusColors[media.status]
)} />
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
{/* Glow Effect on Hover */}
<div className="absolute inset-0 rounded-2xl ring-2 ring-[#6d28d9]/0 group-hover:ring-[#6d28d9]/50 transition-all duration-500 pointer-events-none" />
</div>
<div className="mt-3 space-y-1">
<h3 className="text-sm font-bold text-zinc-900 line-clamp-1 group-hover:text-[#6d28d9] transition-colors">
<div className="mt-4 space-y-1.5">
<h3 className="text-sm font-bold text-foreground line-clamp-2 group-hover:text-[#6d28d9] transition-colors duration-300">
{media.title}
</h3>
<p className="text-xs font-medium text-zinc-500">
{media.year}
</p>
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-muted-foreground">
{media.year}
</p>
{media.genres && media.genres.length > 0 && (
<>
<span className="text-xs text-muted-foreground/50"></span>
<p className="text-xs font-medium text-muted-foreground/70 line-clamp-1">
{media.genres[0]}
</p>
</>
)}
</div>
</div>
</motion.div>
);

View File

@@ -44,11 +44,11 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="group flex items-center gap-6 p-4 rounded-xl hover:bg-zinc-50 transition-colors cursor-pointer border border-transparent hover:border-zinc-200"
className="group flex items-center gap-6 p-5 rounded-xl hover:bg-muted/50 transition-all duration-300 cursor-pointer border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg hover:shadow-[#6d28d9]/10"
onClick={() => onClick(media)}
>
<div className={cn(
"relative rounded-lg overflow-hidden shrink-0 shadow-md bg-zinc-800 transition-all duration-300",
"relative rounded-xl overflow-hidden shrink-0 shadow-md bg-card transition-all duration-300 group-hover:scale-105 border border-border/30",
aspectRatioClass
)}>
<img
@@ -57,6 +57,7 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
{media.status && (
<div className={cn(
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
@@ -67,32 +68,32 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
<h3 className="text-lg font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
{media.title}
</h3>
<span className="text-sm font-bold text-zinc-400">({media.year})</span>
<span className="text-sm font-bold text-muted-foreground">({media.year})</span>
</div>
<div className="flex items-center gap-4 mb-3">
<div className="flex items-center gap-1 text-xs font-bold text-zinc-500">
<div className="flex items-center gap-1 text-xs font-bold text-muted-foreground">
<Star size={14} className="text-yellow-500" fill="currentColor" />
{media.rating || 'N/A'}
</div>
<div className="text-xs font-bold text-zinc-400 uppercase tracking-wider">
<div className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{media.genres?.slice(0, 3).join(' • ') || 'Anime'}
</div>
</div>
<p className="text-sm text-zinc-500 line-clamp-2 max-w-2xl">
<p className="text-sm text-muted-foreground line-clamp-2 max-w-2xl">
{media.description || "No description available for this title."}
</p>
</div>
<div className="hidden md:flex items-center gap-2">
<Button size="icon" variant="ghost" className="rounded-full text-zinc-400 hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
<Button size="icon" variant="ghost" className="rounded-xl text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10 transition-all duration-300">
<Play size={18} fill="currentColor" />
</Button>
<Button size="icon" variant="ghost" className="rounded-full text-zinc-400 hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
<Button size="icon" variant="ghost" className="rounded-xl text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10 transition-all duration-300">
<Bookmark size={18} />
</Button>
</div>

View File

@@ -0,0 +1,499 @@
import React, { useState, useEffect } from 'react';
import { MediaCategory, UserSettings, CustomColors } from '@/types';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Film, Music, Book, Tv, Gamepad2, ShieldAlert, LayoutGrid, List, Globe, Monitor, Sun, Moon, Save, ArrowLeft, Type, Image, Palette } from 'lucide-react';
import { Link } from 'react-router-dom';
import { fetchSettings, updateSettings } from '@/api';
import { useTheme } from '@/contexts/ThemeContext';
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
Anime: <Tv size={18} />,
Movies: <Film size={18} />,
'TV Series': <Tv size={18} />,
Music: <Music size={18} />,
Books: <Book size={18} />,
Consoles: <Gamepad2 size={18} />,
Games: <Gamepad2 size={18} />,
Adult: <ShieldAlert size={18} />,
};
const ITEMS_PER_PAGE_OPTIONS = [12, 20, 36, 48, 60];
const LANGUAGE_OPTIONS = [
{ value: 'en', label: 'English' },
{ value: 'de', label: 'Deutsch' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
];
interface SettingsViewProps {
onSettingsSaved?: () => void;
}
export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
const { setTheme } = useTheme();
const [settings, setSettings] = useState<UserSettings>({
enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'],
itemsPerPage: 20,
gridItemSize: 5,
defaultView: 'grid',
showAdultContent: false,
autoPlayTrailers: false,
language: 'en',
theme: 'system',
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
// Page Settings State
const [pageTitle, setPageTitle] = useState<string>('');
const [favicon, setFavicon] = useState<string>('');
const [customColors, setCustomColors] = useState<CustomColors>({});
const [faviconPreview, setFaviconPreview] = useState<string>('');
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const loadedSettings = await fetchSettings();
if (loadedSettings) {
setSettings(loadedSettings);
setPageTitle(loadedSettings.pageTitle || '');
setFavicon(loadedSettings.favicon || '');
setCustomColors(loadedSettings.customColors || {});
setFaviconPreview(loadedSettings.favicon || '');
}
} catch (error) {
console.error('Failed to load settings:', error);
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
setIsSaving(true);
setSaveStatus('idle');
try {
const updatedSettings: UserSettings = {
...settings,
pageTitle: pageTitle || undefined,
favicon: favicon || undefined,
customColors: Object.keys(customColors).length > 0 ? customColors : undefined,
};
const savedSettings = await updateSettings(updatedSettings);
if (savedSettings) {
setSettings(savedSettings);
setSaveStatus('success');
// Sync theme with theme context
setTheme(savedSettings.theme);
onSettingsSaved?.();
} else {
setSaveStatus('error');
}
} catch (error) {
console.error('Failed to save settings:', error);
setSaveStatus('error');
} finally {
setIsSaving(false);
setTimeout(() => setSaveStatus('idle'), 3000);
}
};
const toggleCategory = (category: MediaCategory) => {
setSettings(prev => ({
...prev,
enabledCategories: prev.enabledCategories.includes(category)
? prev.enabledCategories.filter(c => c !== category)
: [...prev.enabledCategories, category]
}));
};
const handleFaviconUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
setFavicon(base64);
setFaviconPreview(base64);
};
reader.readAsDataURL(file);
}
};
const handleRemoveFavicon = () => {
setFavicon('');
setFaviconPreview('');
};
const handleColorChange = (colorKey: keyof CustomColors, value: string) => {
setCustomColors(prev => ({
...prev,
[colorKey]: value || undefined,
}));
};
if (isLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-muted-foreground font-medium">Loading settings...</div>
</div>
);
}
return (
<div className="min-h-screen bg-background pt-20">
{/* Content */}
<div className="max-w-[1920px] mx-auto px-6 py-12">
<div className="flex items-center justify-between mb-8">
<div>
<Link
to="/"
className="inline-flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-[#6d28d9] transition-colors mb-2 hover:bg-muted/50 px-3 py-1 rounded-xl transition-all duration-300"
>
<ArrowLeft size={16} />
Back to home
</Link>
<h1 className="text-4xl font-black text-foreground">Settings</h1>
</div>
<button
onClick={handleSave}
disabled={isSaving}
className="bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-bold px-6 py-3 h-12 rounded-xl flex items-center gap-2 transition-all duration-300 hover:scale-[1.02] shadow-lg shadow-[#6d28d9]/30 disabled:opacity-50 disabled:hover:scale-100"
>
{isSaving ? (
'Saving...'
) : (
<>
<Save size={16} />
Save Changes
</>
)}
</button>
</div>
{saveStatus === 'success' && (
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl text-green-500 font-medium backdrop-blur-sm">
Settings saved successfully!
</div>
)}
{saveStatus === 'error' && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-500 font-medium backdrop-blur-sm">
Failed to save settings. Please try again.
</div>
)}
<div className="grid gap-8">
{/* Library Settings */}
<section>
<h2 className="text-2xl font-black text-foreground mb-6">Library Settings</h2>
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<p className="text-sm font-medium text-muted-foreground mb-4">
Toggle which media areas you want to see in your library.
</p>
<div className="grid gap-4">
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => (
<div key={category} className="flex items-center justify-between p-4 rounded-xl bg-background border border-border/50 transition-all hover:border-[#6d28d9]/30 hover:bg-muted/50">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-muted flex items-center justify-center text-[#6d28d9] border border-border/30">
{CATEGORY_ICONS[category]}
</div>
<div>
<Label htmlFor={category} className="text-sm font-black text-foreground cursor-pointer">
{category}
</Label>
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
{settings.enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
</p>
</div>
</div>
<Switch
id={category}
checked={settings.enabledCategories.includes(category)}
onCheckedChange={() => toggleCategory(category)}
/>
</div>
))}
</div>
</div>
</section>
{/* Display Settings */}
<section>
<h2 className="text-2xl font-black text-foreground mb-6">Display Settings</h2>
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50 space-y-6">
{/* Items per page */}
<div>
<Label className="text-sm font-black text-foreground mb-2 block">Items per page</Label>
<div className="flex gap-2 flex-wrap">
{ITEMS_PER_PAGE_OPTIONS.map((option) => (
<button
key={option}
onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: option }))}
className={`px-4 py-2 rounded-xl text-sm font-bold transition-all ${
settings.itemsPerPage === option
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`}
>
{option}
</button>
))}
</div>
</div>
{/* Default view */}
<div>
<Label className="text-sm font-black text-foreground mb-2 block">Default view</Label>
<div className="flex gap-2">
<button
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all ${
settings.defaultView === 'grid'
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`}
>
<LayoutGrid size={18} />
Grid
</button>
<button
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'list' }))}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all ${
settings.defaultView === 'list'
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`}
>
<List size={18} />
List
</button>
</div>
</div>
{/* Grid item size */}
<div>
<Label className="text-sm font-black text-foreground mb-2 block">Grid item size</Label>
<div className="flex items-center gap-4">
<span className="text-xs font-bold text-muted-foreground">Small</span>
<input
type="range"
min="1"
max="10"
value={settings.gridItemSize}
onChange={(e) => setSettings(prev => ({ ...prev, gridItemSize: Number(e.target.value) }))}
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
/>
<span className="text-xs font-bold text-muted-foreground">Large</span>
<span className="text-sm font-bold text-[#6d28d9] w-8 text-center">{settings.gridItemSize}</span>
</div>
</div>
{/* Theme */}
<div>
<Label className="text-sm font-black text-foreground mb-2 block">Theme</Label>
<div className="flex gap-2">
{(['light', 'dark', 'system'] as const).map((theme) => (
<button
key={theme}
onClick={() => setSettings(prev => ({ ...prev, theme }))}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all ${
settings.theme === theme
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`}
>
{theme === 'light' && <Sun size={18} />}
{theme === 'dark' && <Moon size={18} />}
{theme === 'system' && <Monitor size={18} />}
{theme.charAt(0).toUpperCase() + theme.slice(1)}
</button>
))}
</div>
</div>
</div>
</section>
{/* Content Settings */}
<section>
<h2 className="text-2xl font-black text-foreground mb-6">Content Settings</h2>
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50 space-y-4">
{/* Show adult content */}
<div className="flex items-center justify-between p-4 rounded-xl bg-background border border-border/50 hover:border-[#6d28d9]/30 transition-all">
<div>
<Label htmlFor="showAdult" className="text-sm font-black text-foreground cursor-pointer">
Show adult content
</Label>
<p className="text-xs font-medium text-muted-foreground mt-1">
Display adult media in your library
</p>
</div>
<Switch
id="showAdult"
checked={settings.showAdultContent}
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, showAdultContent: checked }))}
/>
</div>
{/* Auto-play trailers */}
<div className="flex items-center justify-between p-4 rounded-xl bg-background border border-border/50 hover:border-[#6d28d9]/30 transition-all">
<div>
<Label htmlFor="autoPlay" className="text-sm font-black text-foreground cursor-pointer">
Auto-play trailers
</Label>
<p className="text-xs font-medium text-muted-foreground mt-1">
Automatically play trailers when viewing media
</p>
</div>
<Switch
id="autoPlay"
checked={settings.autoPlayTrailers}
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))}
/>
</div>
</div>
</section>
{/* Language Settings */}
<section>
<h2 className="text-2xl font-black text-foreground mb-6">Language</h2>
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-2 mb-4">
<Globe size={18} className="text-[#6d28d9]" />
<Label className="text-sm font-black text-foreground">Interface language</Label>
</div>
<div className="flex gap-2 flex-wrap">
{LANGUAGE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => setSettings(prev => ({ ...prev, language: option.value }))}
className={`px-4 py-2 rounded-xl text-sm font-bold transition-all ${
settings.language === option.value
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</section>
{/* Page Settings */}
<section>
<h2 className="text-2xl font-black text-foreground mb-6">Page Settings</h2>
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50 space-y-6">
{/* Page Title */}
<div>
<div className="flex items-center gap-2 mb-2">
<Type size={18} className="text-[#6d28d9]" />
<Label className="text-sm font-black text-foreground">Custom Page Title</Label>
</div>
<input
type="text"
value={pageTitle}
onChange={(e) => setPageTitle(e.target.value)}
placeholder="Leave empty for default title"
className="w-full px-4 py-3 rounded-xl bg-background border border-border/50 text-foreground placeholder:text-muted-foreground/50 focus:border-[#6d28d9] focus:outline-none transition-all"
/>
<p className="text-xs font-medium text-muted-foreground mt-2">
Custom title for your page. Leave empty to use the default title.
</p>
</div>
{/* Favicon Upload */}
<div>
<div className="flex items-center gap-2 mb-2">
<Image size={18} className="text-[#6d28d9]" />
<Label className="text-sm font-black text-foreground">Favicon / Icon</Label>
</div>
<div className="flex items-center gap-4">
{faviconPreview && (
<div className="relative">
<img
src={faviconPreview}
alt="Favicon preview"
className="w-16 h-16 rounded-xl object-cover border border-border/50"
/>
<button
onClick={handleRemoveFavicon}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
>
×
</button>
</div>
)}
<div className="flex-1">
<input
type="file"
accept="image/*"
onChange={handleFaviconUpload}
className="hidden"
id="favicon-upload"
/>
<label
htmlFor="favicon-upload"
className="inline-flex items-center gap-2 px-4 py-3 rounded-xl bg-background border border-border/50 text-foreground hover:bg-muted hover:border-[#6d28d9]/30 cursor-pointer transition-all"
>
<Image size={16} />
{favicon ? 'Change favicon' : 'Upload favicon'}
</label>
</div>
</div>
<p className="text-xs font-medium text-muted-foreground mt-2">
Upload a custom favicon or icon. The image will be converted to Base64 format.
</p>
</div>
{/* Custom Colors */}
<div>
<div className="flex items-center gap-2 mb-4">
<Palette size={18} className="text-[#6d28d9]" />
<Label className="text-sm font-black text-foreground">Custom Colors</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ key: 'primary', label: 'Primary Color' },
{ key: 'secondary', label: 'Secondary Color' },
{ key: 'background', label: 'Background Color' },
{ key: 'surface', label: 'Surface Color' },
{ key: 'text', label: 'Text Color' },
{ key: 'muted', label: 'Muted Text Color' },
{ key: 'border', label: 'Border Color' },
].map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 p-3 rounded-xl bg-background border border-border/50">
<input
type="color"
value={customColors[key as keyof CustomColors] || '#6d28d9'}
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
className="w-10 h-10 rounded-lg cursor-pointer border-0"
/>
<div className="flex-1">
<Label className="text-xs font-black text-foreground">{label}</Label>
<input
type="text"
value={customColors[key as keyof CustomColors] || ''}
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
placeholder="#6d28d9"
className="w-full mt-1 px-2 py-1 rounded-lg bg-muted border border-border/30 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-[#6d28d9] focus:outline-none transition-all"
/>
</div>
</div>
))}
</div>
<p className="text-xs font-medium text-muted-foreground mt-2">
Leave color fields empty to use the default theme colors.
</p>
</div>
</div>
</section>
</div>
</div>
</div>
);
}

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

View File

@@ -0,0 +1,14 @@
import { Loader2 } from 'lucide-react';
interface LoadingProps {
message?: string;
}
export default function Loading({ message = 'Loading...' }: LoadingProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Loader2 className="animate-spin h-12 w-12 text-[#6d28d9] mb-4" />
<p className="text-lg font-bold">{message}</p>
</div>
);
}

49
src/constants.ts Normal file
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

@@ -0,0 +1,74 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
effectiveTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem('theme') as Theme;
return stored || 'system';
});
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const root = window.document.documentElement;
const applyTheme = () => {
let resolved: 'light' | 'dark';
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} else {
resolved = theme;
}
setEffectiveTheme(resolved);
if (resolved === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
};
applyTheme();
// Listen for system theme changes when in system mode
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
applyTheme();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<ThemeContext.Provider value={{ theme, setTheme, effectiveTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

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

View File

@@ -83,7 +83,7 @@
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--radius: 0.75rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
@@ -92,40 +92,60 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* Custom gradient colors */
--gradient-purple: linear-gradient(135deg, #6d28d9 0%, #8b5cf6 50%, #a78bfa 100%);
--gradient-blue: linear-gradient(135deg, #3b82f6 0%, #60a5fa 50%, #93c5fd 100%);
--gradient-green: linear-gradient(135deg, #22c55e 0%, #4ade80 50%, #86efac 100%);
--gradient-yellow: linear-gradient(135deg, #eab308 0%, #facc15 50%, #fde047 100%);
}
.dark {
--background: oklch(0.145 0 0);
--background: oklch(0.12 0.01 264);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.18 0.02 264);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.18 0.02 264);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary: oklch(0.269 0.01 264);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted: oklch(0.25 0.01 264);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent: oklch(0.269 0.01 264);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--border: oklch(0.985 0 0 / 15%);
--input: oklch(0.985 0 0 / 20%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar: oklch(0.18 0.02 264);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-border: oklch(0.985 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/* Custom gradient colors for dark mode - more vibrant */
--gradient-purple: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 50%, #a78bfa 100%);
--gradient-blue: linear-gradient(135deg, #2563eb 0%, #3b82f6 50%, #60a5fa 100%);
--gradient-green: linear-gradient(135deg, #16a34a 0%, #22c55e 50%, #4ade80 100%);
--gradient-yellow: linear-gradient(135deg, #ca8a04 0%, #eab308 50%, #facc15 100%);
--gradient-pink: linear-gradient(135deg, #db2777 0%, #ec4899 50%, #f472b6 100%);
--gradient-orange: linear-gradient(135deg, #ea580c 0%, #f97316 50%, #fb923c 100%);
--gradient-cyan: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
/* Background gradients for dark mode */
--bg-gradient-subtle: radial-gradient(circle at top right, rgba(124, 58, 237, 0.1) 0%, transparent 50%),
radial-gradient(circle at bottom left, rgba(139, 92, 246, 0.1) 0%, transparent 50%);
--bg-gradient-mesh: linear-gradient(135deg, rgba(124, 58, 237, 0.05) 0%, rgba(139, 92, 246, 0.05) 50%, rgba(167, 139, 250, 0.05) 100%);
}
@layer base {
@@ -138,4 +158,41 @@
html {
@apply font-sans;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.708 0 0);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.556 0 0);
}
/* Glassmorphism utility */
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
}
}

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

1456
src/lib/jellyfinImporter.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +1,153 @@
/**
* Playnite Importer Module
*
* This module provides functionality to import games from a Playnite library into the Omnyx media database.
* It fetches game data from the Playnite API, converts it to the Omnyx media format, and handles both
* new imports and updates to existing entries.
*
* @module playniteImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to a Playnite instance
*/
export interface PlayniteConfig {
/** IP address of the Playnite server */
ip: string;
/** API token for authentication with Playnite */
apiToken: string;
/** Port number of the Playnite API (default: 19821) */
port?: number;
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean;
}
/**
* Progress tracking for the import operation
*/
export interface ImportProgress {
/** Current number of items processed */
current: number;
/** Total number of items to process */
total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string;
/** Number of games successfully imported */
gamesImported: number;
/** Array of error messages encountered during import */
errors: string[];
}
/**
* Game data structure as returned by the Playnite API
*/
export interface PlayniteGame {
/** Unique identifier for the game */
id: string;
/** Game name */
name: string;
/** Alternate name for sorting purposes */
sortingName?: string;
/** Game description */
description?: string;
/** User notes */
notes?: string;
/** Game version */
version?: string;
/** Whether the game is hidden */
hidden?: boolean;
/** Whether the game is marked as favorite */
favorite?: boolean;
/** User rating (0-100) */
userScore?: number;
/** Community rating (0-100) */
communityScore?: number;
/** Critic rating (0-100) */
criticScore?: number;
/** Release date in ISO format */
releaseDate?: string;
/** Completion status (e.g., 'Completed', 'Playing', 'Abandoned') */
completionStatus?: string;
/** Game categories */
categories?: string[];
/** Game tags */
tags?: string[];
/** Game features */
features?: string[];
/** Game genres */
genres?: string[];
/** Developer names */
developers?: string[];
/** Publisher names */
publishers?: string[];
/** Series name */
series?: string[];
/** Platform names */
platforms?: string[];
/** Age rating names */
ageRatings?: string[];
/** Region names */
regions?: string[];
/** External links */
links?: Array<{
name: string;
url: string;
}>;
/** Total playtime in seconds */
playtime?: number;
/** Number of times played */
playCount?: number;
/** Last activity timestamp */
lastActivity?: string;
/** Date added to library */
added?: string;
/** Last played date */
lastPlayed?: string;
/** Source platform/library */
source?: string;
/** Whether the game is currently installed */
isInstalled?: boolean;
/** Cover image as base64 data URI */
coverBase64?: string;
/** Background image as base64 data URI */
backgroundBase64?: string;
/** Icon image as base64 data URI */
iconBase64?: string;
}
/**
* Response structure for the Playnite games API endpoint
*/
export interface PlayniteGamesResponse {
/** Total number of games available */
total: number;
/** Offset for pagination */
offset: number;
/** Limit for pagination */
limit: number;
/** Array of game objects */
games: PlayniteGame[];
}
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void;
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/*
async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
try {
const coverResponse = await fetch(`${baseUrl}/api/games/${gameId}/cover`, {
@@ -83,6 +171,75 @@ async function fetchGameCover(baseUrl: string, headers: Record<string, string>,
}
}
async function fetchGameBackground(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
try {
const backgroundResponse = await fetch(`${baseUrl}/api/games/${gameId}/background`, {
method: 'GET',
headers
});
if (!backgroundResponse.ok) {
return null;
}
const blob = await backgroundResponse.blob();
const arrayBuffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const mimeType = blob.type || 'image/jpeg';
return `data:${mimeType};base64,${base64}`;
} catch (error) {
return null;
}
}
async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
try {
const iconResponse = await fetch(`${baseUrl}/api/games/${gameId}/icon`, {
method: 'GET',
headers
});
if (!iconResponse.ok) {
return null;
}
const blob = await iconResponse.blob();
const arrayBuffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const mimeType = blob.type || 'image/png';
return `data:${mimeType};base64,${base64}`;
} catch (error) {
return null;
}
}
*/
/**
* Imports games from a Playnite library into the Omnyx media database
*
* This function performs the following steps:
* 1. Fetches existing media from Omnyx to check for duplicates
* 2. Fetches all games from the Playnite API
* 3. Fetches detailed information for each game
* 4. Converts Playnite game data to Omnyx media format
* 5. Imports or updates each game in the Omnyx database
*
* @param config - Configuration for connecting to Playnite
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromPlaynite(
* { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.gamesImported} games`);
* ```
*/
export async function importFromPlaynite(
config: PlayniteConfig,
logCallback: LogCallback,
@@ -107,11 +264,11 @@ export async function importFromPlaynite(
logCallback('Starting Playnite import...');
// Step 0: Fetch existing media to check for duplicates and enable updates
logCallback('Fetching existing media from Kyoo API...');
const existingMediaResponse = await fetch('http://192.168.1.102:6400/api/media?limit=1000');
logCallback('Fetching existing media from Omnyx API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
const existingMediaData = await existingMediaResponse.json();
const existingMedia = new Map(
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
);
logCallback(`Found ${existingMedia.size} existing games in database`);
@@ -153,6 +310,18 @@ export async function importFromPlaynite(
if (detailResponse.ok) {
const detailData: PlayniteGame = await detailResponse.json();
/*
// Fetch images
const [cover, background, icon] = await Promise.all([
fetchGameCover(baseUrl, headers, game.id),
fetchGameBackground(baseUrl, headers, game.id),
fetchGameIcon(baseUrl, headers, game.id)
]);
detailData.coverBase64 = cover;
detailData.backgroundBase64 = background;
detailData.iconBase64 = icon;
*/
detailedGames.push(detailData);
logCallback(`✓ Fetched details for: ${game.name}`);
} else {
@@ -189,6 +358,15 @@ export async function importFromPlaynite(
const existingGame = existingMedia.get(game.name);
const isUpdate = existingGame !== undefined;
// Skip if updateExisting is false and item already exists
if (!config.updateExisting && isUpdate) {
logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`);
progressCallback({
current: i + 1
});
continue;
}
try {
// Parse release date
let year = new Date().getFullYear();
@@ -216,7 +394,7 @@ export async function importFromPlaynite(
}
// Staff is for actors/performers only - leave empty for games
const staff: any[] = [];
const staff: Staff[] = [];
// Determine type based on genres/features
let type = 'Game';
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
@@ -239,7 +417,7 @@ export async function importFromPlaynite(
series: game.series ? [game.series] : [],
ageRatings: game.ageRatings || [],
regions: game.regions || [],
source: game.source || null,
source: SOURCE_CATEGORY_MAPPING['playnite']?.includes('Games') ? (game.source || 'playnite') : null,
gameId: game.id,
pluginId: null,
completionStatus: game.completionStatus || 'Not Played',
@@ -265,7 +443,8 @@ export async function importFromPlaynite(
achievements: [],
year: year.toString(),
poster: game.coverBase64 || null,
banner: null,
banner: game.backgroundBase64 || null,
icon: game.iconBase64 || null,
rating: rating,
category: 'Game',
status: game.completionStatus === 'Completed' ? 'completed' :
@@ -279,13 +458,13 @@ export async function importFromPlaynite(
let response;
if (isUpdate) {
response = await fetch(`http://192.168.1.102:6400/api/media/${(existingGame as any).id}`, {
response = await fetch(`${BASE_URL}/api/media/${(existingGame as any).id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaData)
});
} else {
response = await fetch('http://192.168.1.102:6400/api/media', {
response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaData)

View File

@@ -1,30 +1,77 @@
/**
* StashAPP Importer Module
*
* This module provides functionality to import adult video content and performers from a StashAPP instance
* into the Omnyx media database. It fetches scene and performer data via GraphQL, converts it to the Omnyx
* media format, and handles both new imports and updates to existing entries.
*
* @module stashappImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to a StashAPP instance
*/
export interface StashAPPConfig {
/** URL of the StashAPP server */
url: string;
/** API key for authentication (optional) */
apiKey?: string;
blacklist?: ['/AI/', 'temp', 'backup'];
/** List of path patterns to blacklist during import */
blacklist?: string[];
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean;
}
/**
* Progress tracking for the import operation
*/
export interface ImportProgress {
/** Current number of items processed */
current: number;
/** Total number of items to process */
total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string;
/** Number of videos successfully imported */
videosImported: number;
/** Number of actors successfully imported */
actorsImported: number;
/** Array of error messages encountered during import */
errors: string[];
}
/**
* Scene data structure as returned by the StashAPP GraphQL API
*/
export interface StashAPPScene {
/** Unique identifier for the scene */
id: string;
/** Scene title */
title: string;
/** Scene description/details */
details: string;
/** Scene URL */
url: string;
/** Release date in ISO format */
date: string;
/** Rating on a 0-100 scale */
rating100: number;
/** Whether the scene is organized */
organized: boolean;
/** O-counter value */
o_counter: number;
/** Creation timestamp */
created_at: string;
/** Last update timestamp */
updated_at: string;
/** File paths for various media assets */
paths: {
screenshot: string;
preview: string;
@@ -35,6 +82,7 @@ export interface StashAPPScene {
funscript: string;
caption: string;
};
/** Array of file information */
files: Array<{
size: number;
duration: number;
@@ -44,6 +92,7 @@ export interface StashAPPScene {
height: number;
path: string;
}>;
/** Array of performers in the scene */
performers: Array<{
id: string;
name: string;
@@ -75,7 +124,30 @@ export interface StashAPPScene {
export interface StashAPPScenePerformer {
id: string;
name: string;
disambiguation: string;
url: string;
gender: string;
birthdate: string;
ethnicity: string;
country: string;
eye_color: string;
height_cm: number;
measurements: string;
fake_tits: boolean;
career_length: string;
tattoos: string;
piercings: string;
alias_list: string[];
favorite: boolean;
ignore_auto_tag: boolean;
created_at?: string;
updated_at?: string;
details: string;
death_date: string;
hair_color: string;
weight: number;
image_path: string;
scene_count: number;
}
export interface StashAPPPerformer {
@@ -125,9 +197,24 @@ export interface StashAPPPerformersResponse {
};
}
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/**
* Checks if a file path matches any blacklist pattern
* @param filePath - The file path to check
* @param blacklist - Array of blacklist patterns
* @returns True if the path is blacklisted, false otherwise
*/
function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
if (!blacklist || blacklist.length === 0) {
return false;
@@ -135,6 +222,17 @@ function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
return blacklist.some(pattern => filePath.includes(pattern));
}
/**
* Updates or creates actor entries from StashAPP performers
*
* This function fetches all performers from StashAPP and updates or creates
* corresponding actor entries in the Omnyx database.
*
* @param config - Configuration for connecting to StashAPP
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*/
export async function updateActorsFromStashAPP(
config: StashAPPConfig,
logCallback: LogCallback,
@@ -153,12 +251,12 @@ export async function updateActorsFromStashAPP(
try {
logCallback('Starting StashAPP actor update...');
// Fetch existing cast from Kyoo API
logCallback('Fetching existing cast from Kyoo API...');
const existingCastResponse = await fetch('http://192.168.1.102:6400/api/cast');
// Fetch existing cast from Omnyx API
logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
const existingCastData = await existingCastResponse.json();
const existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
const existingActors = new Map<string, Staff>(
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
@@ -243,12 +341,12 @@ export async function updateActorsFromStashAPP(
for (let i = 0; i < performers.length; i++) {
const performer = performers[i];
const existingActor: any = existingActors.get(performer.name);
const existingActor: Staff | undefined = existingActors.get(performer.name);
try {
if (existingActor) {
// Update existing actor
const updateData: any = {
const updateData: Partial<Staff> = {
name: performer.name,
};
@@ -274,7 +372,7 @@ export async function updateActorsFromStashAPP(
updateData.birthPlace = performer.country;
}
const response = await fetch(`http://192.168.1.102:6400/api/cast/${existingActor.id}`, {
const response = await fetch(`${BASE_URL}/api/cast/${existingActor.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
@@ -290,7 +388,7 @@ export async function updateActorsFromStashAPP(
}
} else {
// Create new actor
const response = await fetch('http://192.168.1.102:6400/api/cast/adult', {
const response = await fetch(`${BASE_URL}/api/cast/adult`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -357,6 +455,31 @@ export async function updateActorsFromStashAPP(
}
}
/**
* Imports scenes and performers from a StashAPP instance into the Omnyx media database
*
* This function performs the following steps:
* 1. Fetches existing media and cast from Omnyx to check for duplicates
* 2. Fetches all scenes from StashAPP via GraphQL
* 3. Extracts unique performers from all scenes
* 4. Imports or updates performers first
* 5. Imports or updates scenes with their associated performers
*
* @param config - Configuration for connecting to StashAPP
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromStashAPP(
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
* ```
*/
export async function importFromStashAPP(
config: StashAPPConfig,
logCallback: LogCallback,
@@ -376,19 +499,19 @@ export async function importFromStashAPP(
logCallback('Starting StashAPP import...');
// Step 0: Fetch existing media and cast to check for duplicates
logCallback('Fetching existing media from Kyoo API...');
const existingMediaResponse = await fetch('http://192.168.1.102:6400/api/media');
logCallback('Fetching existing media from Omnyx API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
const existingMediaData = await existingMediaResponse.json();
const existingTitles = new Set(
existingMediaData.data?.items?.map((m: any) => m.title) || []
existingMediaData.data?.items?.map((m: Media) => m.title) || []
);
logCallback(`Found ${existingTitles.size} existing videos in database`);
logCallback('Fetching existing cast from Kyoo API...');
const existingCastResponse = await fetch('http://192.168.1.102:6400/api/cast');
logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
const existingCastData = await existingCastResponse.json();
const existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
const existingActors = new Map<string, Staff>(
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
@@ -519,12 +642,12 @@ export async function importFromStashAPP(
for (let i = 0; i < uniquePerformers.length; i++) {
const performer = uniquePerformers[i];
const existingActor: any = existingActors.get(performer.name);
const existingActor: Staff | undefined = existingActors.get(performer.name);
try {
if (existingActor) {
// Update existing actor
const updateData: any = {
const updateData: Partial<Staff> = {
name: performer.name,
};
@@ -550,7 +673,7 @@ export async function importFromStashAPP(
updateData.birthPlace = performer.country;
}
const response = await fetch(`http://192.168.1.102:6400/api/cast/${existingActor.id}`, {
const response = await fetch(`${BASE_URL}/api/cast/${existingActor.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
@@ -566,7 +689,7 @@ export async function importFromStashAPP(
}
} else {
// Create new actor
const response = await fetch('http://192.168.1.102:6400/api/cast/adult', {
const response = await fetch(`${BASE_URL}/api/cast/adult`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -640,11 +763,14 @@ export async function importFromStashAPP(
// Check for duplicate
if (existingTitles.has(scene.title)) {
logCallback(`⊘ Skipped duplicate: ${scene.title}`);
progressCallback({
current: uniquePerformers.length + i + 1
});
continue;
if (!config.updateExisting) {
logCallback(`⊘ Skipped duplicate: ${scene.title} (updateExisting is false)`);
progressCallback({
current: uniquePerformers.length + i + 1
});
continue;
}
logCallback(`→ Updating existing: ${scene.title}`);
}
try {
@@ -700,13 +826,14 @@ export async function importFromStashAPP(
director: null,
writer: null,
releaseDate: releaseDate,
source: SOURCE_CATEGORY_MAPPING['stashapp']?.includes('Adult') ? 'stashapp' : null,
genres: [],
tags: [],
studios: [],
staff: staff
};
const response = await fetch('http://192.168.1.102:6400/api/media', {
const response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaData)

View File

@@ -1,42 +1,96 @@
/**
* XBVR Importer Module
*
* This module provides functionality to import VR adult video content from an XBVR instance into the Omnyx media database.
* It fetches scene data from the DeoVR API endpoint, extracts actors and video details, and handles both new imports
* and updates to existing entries. The module specifically filters for content in the 'Recent' scene group.
*
* @module xbvrImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to an XBVR instance
*/
export interface XBVRConfig {
/** URL of the XBVR server */
url: string;
/** API key for authentication (optional) */
apiKey?: string;
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean;
}
/**
* Progress tracking for the import operation
*/
export interface ImportProgress {
/** Current number of items processed */
current: number;
/** Total number of items to process */
total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string;
/** Number of videos successfully imported */
videosImported: number;
/** Number of actors successfully imported */
actorsImported: number;
/** Array of error messages encountered during import */
errors: string[];
}
/**
* Basic video information from the DeoVR scene list
*/
export interface XBVRVideo {
/** Video title */
title: string;
/** Video length in seconds */
videoLength: number;
/** URL to the video thumbnail */
thumbnailUrl: string;
/** URL to fetch detailed video information */
video_url: string;
}
/**
* Detailed video information as returned by the XBVR API
*/
export interface XBVRVideoDetail {
/** Unique video identifier */
id: number;
/** Video title */
title: string;
/** Video description */
description: string;
/** Release date as Unix timestamp */
date: number;
/** URL to the video thumbnail */
thumbnailUrl: string;
/** Average rating */
rating_avg: number;
/** Screen type (e.g., '180', '360', 'dome') */
screenType: string;
/** Stereo mode (e.g., 'sbs', 'tb') */
stereoMode: string;
/** Video length in seconds */
videoLength: number;
/** Pay site information */
paysite?: {
name: string;
};
/** Array of actors in the video */
actors: Array<{
id: number;
name: string;
}>;
/** Array of category tags */
categories: Array<{
tag: {
name: string;
@@ -44,16 +98,59 @@ export interface XBVRVideoDetail {
}>;
}
/**
* Scene list structure as returned by the DeoVR API
*/
export interface XBVRSceneList {
/** Array of scene groups */
scenes: Array<{
/** Name of the scene group (e.g., 'Recent', 'Favorites') */
name: string;
/** List of videos in this group */
list: XBVRVideo[];
}>;
}
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/**
* Imports VR adult videos and actors from an XBVR instance into the Omnyx media database
*
* This function performs the following steps:
* 1. Fetches existing media and cast from Omnyx to check for duplicates
* 2. Fetches the scene list from the DeoVR API endpoint
* 3. Extracts videos from the 'Recent' scene group
* 4. Fetches detailed information for each video
* 5. Imports or updates actors first
* 6. Imports or updates videos with their associated actors
*
* Videos and actors containing 'aka:' in their name are automatically skipped.
*
* @param config - Configuration for connecting to XBVR
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromXBVR(
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
* ```
*/
export async function importFromXBVR(
config: XBVRConfig,
logCallback: LogCallback,
@@ -73,19 +170,19 @@ export async function importFromXBVR(
logCallback('Starting DeoVR import...');
// Step 0: Fetch existing media and cast to check for duplicates
logCallback('Fetching existing media from Kyoo API...');
const existingMediaResponse = await fetch('http://192.168.1.102:6400/api/media?limit=1000');
logCallback('Fetching existing media from Omnyx API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
const existingMediaData = await existingMediaResponse.json();
const existingTitles = new Set(
existingMediaData.data?.items?.map((m: any) => m.title) || []
existingMediaData.data?.items?.map((m: Media) => m.title) || []
);
logCallback(`Found ${existingTitles.size} existing videos in database`);
logCallback('Fetching existing cast from Kyoo API...');
const existingCastResponse = await fetch('http://192.168.1.102:6400/api/cast?limit=1000');
logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
const existingCastData = await existingCastResponse.json();
const existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
@@ -194,7 +291,7 @@ export async function importFromXBVR(
logCallback(`⊘ Actor already exists: ${actor.name}`);
} else {
// Create new actor
const response = await fetch('http://192.168.1.102:6400/api/cast', {
const response = await fetch(`${BASE_URL}/api/cast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -253,11 +350,14 @@ export async function importFromXBVR(
// Check for duplicate
if (existingTitles.has(video.title)) {
logCallback(`⊘ Skipped duplicate: ${video.title}`);
progressCallback({
current: uniqueActors.length + i + 1
});
continue;
if (!config.updateExisting) {
logCallback(`⊘ Skipped duplicate: ${video.title} (updateExisting is false)`);
progressCallback({
current: uniqueActors.length + i + 1
});
continue;
}
logCallback(`→ Updating existing: ${video.title}`);
}
try {
@@ -306,13 +406,14 @@ export async function importFromXBVR(
director: null,
writer: null,
releaseDate: releaseDate,
source: SOURCE_CATEGORY_MAPPING['xbvr']?.includes('Adult') ? 'xbvr' : null,
genres: categories,
tags: categories,
studios: video.paysite?.name ? [video.paysite.name] : [],
staff: staff
};
const response = await fetch('http://192.168.1.102:6400/api/media', {
const response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaData)

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

@@ -16,6 +16,7 @@ export interface Media {
studios?: string[];
status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold';
episodes?: Episode[];
tracks?: Track[];
staff?: Staff[];
categories?: string[];
platforms?: string[];
@@ -28,15 +29,26 @@ export interface Media {
}
export interface Episode {
id: string;
number: number;
id: number;
media_id: number;
season: number;
episode_number: number;
title: string;
date: string;
duration: string;
description: string;
air_date: string;
duration: number;
thumbnail: string;
}
export interface Track {
id: number;
media_id: number;
track_number: number;
title: string;
duration: number | null;
artist: string;
}
export interface Staff {
id: string;
name: string;
@@ -95,3 +107,42 @@ export interface AdultSpecifics {
measurements?: string | null;
shoe_size?: number | null;
}
export interface UserSettings {
id?: number;
enabledCategories: MediaCategory[];
itemsPerPage: number;
gridItemSize: number; // 1-10 scale
defaultView: 'grid' | 'list';
showAdultContent: boolean;
autoPlayTrailers: boolean;
language: string;
theme: 'light' | 'dark' | 'system';
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[]
// Page Settings
pageTitle?: string; // Custom page title
favicon?: string; // Base64 encoded favicon/image
customColors?: CustomColors; // Custom color scheme
createdAt?: string;
updatedAt?: string;
}
export interface CustomColors {
primary?: string; // Primary accent color (hex)
secondary?: string; // Secondary accent color (hex)
background?: string; // Background color (hex)
surface?: string; // Surface/card color (hex)
text?: string; // Text color (hex)
muted?: string; // Muted text color (hex)
border?: string; // Border color (hex)
}
// Source to Category mapping - ensures sources are only used with appropriate categories
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
'xbvr': ['Adult'],
'stashapp': ['Adult'],
'playnite': ['Games'],
'manual': ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Adult', 'Consoles', 'Games'],
};

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: {
// 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',
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: [],
},
};
});