Compare commits

...

30 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
Lars Behrends
f5c3e96823 Add routing, cast API conversion, and filters
Introduce client-side routing and cast API support. Key changes:

- Add react-router-dom dependency and wire BrowserRouter in App.
- Convert App to route-based structure (/, /media/:id, /cast, /cast/:id, /add, /import) with MediaDetailRoute and CastDetailRoute helpers.
- Extend API types for cast items and add convertApiCastToStaff; fetchAllCast now returns Staff[] (mapped via converter).
- Update components to use react-router hooks (useNavigate, useParams, useLocation, Link/NavLink): Header links, DetailView, CastDetailView, AddMediaView, ImporterView and others now navigate via routes.
- Enhance CastView: fetch cast list, loading state, persistent search/sort/filter controls, filtering by occupations/media types and enabled categories, improved pagination and UI controls.
- Update stashapp importer: add configurable blacklist check to skip scenes, increase per_page for queries.

These changes consolidate navigation, improve cast data handling from the API, and add richer filtering and importer controls.
2026-04-10 13:46:52 +02:00
Lars Behrends
a610ce304e Update README.md 2026-04-10 12:26:51 +02:00
Lars Behrends
6438a23301 Update .env.example 2026-04-10 12:25:36 +02:00
Lars Behrends
d6ad4c80b3 imports are cool xD 2026-04-10 12:24:54 +02:00
Lars Behrends
73c578f1ec playnite init 2026-04-10 08:46:56 +02:00
Lars Behrends
1caadd12e1 imports :) 2026-04-09 17:13:04 +02:00
Lars Behrends
6d5397505a add media page 2026-04-09 13:02:58 +02:00
Lars Behrends
d6a0aac5f7 banbaa 2026-04-09 12:46:32 +02:00
77 changed files with 13194 additions and 4720 deletions

View File

@@ -2,3 +2,19 @@
# AI Studio automatically injects this at runtime with the Cloud Run service URL. # AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints. # Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL" APP_URL="MY_APP_URL"
# Backend API URL
VITE_API_URL="http://192.168.1.102:6400"
# Importer Configurations
# XBVR Importer
VITE_XBVR_URL=""
# StashAPP Importer
VITE_STASHAPP_URL=""
VITE_STASHAPP_API_KEY=""
# Playnite Importer
VITE_PLAYNITE_IP="localhost"
VITE_PLAYNITE_PORT="19821"
VITE_PLAYNITE_API_TOKEN=""

1
.gitignore vendored
View File

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

View File

@@ -0,0 +1,198 @@
---
name: react
description: Modern React patterns and principles. Hooks, composition, performance, TypeScript best practices.
allowed-tools: Read, Write, Edit, Glob, Grep
---
# React Patterns
> Principles for building production-ready React applications.
---
## 1. Component Design Principles
### Component Types
| Type | Use | State |
|------|-----|-------|
| **Server** | Data fetching, static | None |
| **Client** | Interactivity | useState, effects |
| **Presentational** | UI display | Props only |
| **Container** | Logic/state | Heavy state |
### Design Rules
- One responsibility per component
- Props down, events up
- Composition over inheritance
- Prefer small, focused components
---
## 2. Hook Patterns
### When to Extract Hooks
| Pattern | Extract When |
|---------|-------------|
| **useLocalStorage** | Same storage logic needed |
| **useDebounce** | Multiple debounced values |
| **useFetch** | Repeated fetch patterns |
| **useForm** | Complex form state |
### Hook Rules
- Hooks at top level only
- Same order every render
- Custom hooks start with "use"
- Clean up effects on unmount
---
## 3. State Management Selection
| Complexity | Solution |
|------------|----------|
| Simple | useState, useReducer |
| Shared local | Context |
| Server state | React Query, SWR |
| Complex global | Zustand, Redux Toolkit |
### State Placement
| Scope | Where |
|-------|-------|
| Single component | useState |
| Parent-child | Lift state up |
| Subtree | Context |
| App-wide | Global store |
---
## 4. React 19 Patterns
### New Hooks
| Hook | Purpose |
|------|---------|
| **useActionState** | Form submission state |
| **useOptimistic** | Optimistic UI updates |
| **use** | Read resources in render |
### Compiler Benefits
- Automatic memoization
- Less manual useMemo/useCallback
- Focus on pure components
---
## 5. Composition Patterns
### Compound Components
- Parent provides context
- Children consume context
- Flexible slot-based composition
- Example: Tabs, Accordion, Dropdown
### Render Props vs Hooks
| Use Case | Prefer |
|----------|--------|
| Reusable logic | Custom hook |
| Render flexibility | Render props |
| Cross-cutting | Higher-order component |
---
## 6. Performance Principles
### When to Optimize
| Signal | Action |
|--------|--------|
| Slow renders | Profile first |
| Large lists | Virtualize |
| Expensive calc | useMemo |
| Stable callbacks | useCallback |
### Optimization Order
1. Check if actually slow
2. Profile with DevTools
3. Identify bottleneck
4. Apply targeted fix
---
## 7. Error Handling
### Error Boundary Usage
| Scope | Placement |
|-------|-----------|
| App-wide | Root level |
| Feature | Route/feature level |
| Component | Around risky component |
### Error Recovery
- Show fallback UI
- Log error
- Offer retry option
- Preserve user data
---
## 8. TypeScript Patterns
### Props Typing
| Pattern | Use |
|---------|-----|
| Interface | Component props |
| Type | Unions, complex |
| Generic | Reusable components |
### Common Types
| Need | Type |
|------|------|
| Children | ReactNode |
| Event handler | MouseEventHandler |
| Ref | RefObject<Element> |
---
## 9. Testing Principles
| Level | Focus |
|-------|-------|
| Unit | Pure functions, hooks |
| Integration | Component behavior |
| E2E | User flows |
### Test Priorities
- User-visible behavior
- Edge cases
- Error states
- Accessibility
---
## 10. Anti-Patterns
| ❌ Don't | ✅ Do |
|----------|-------|
| Prop drilling deep | Use context |
| Giant components | Split smaller |
| useEffect for everything | Server components |
| Premature optimization | Profile first |
| Index as key | Stable unique ID |
---
> **Remember:** React is about composition. Build small, combine thoughtfully.

338
AGENTS.md Normal file
View File

@@ -0,0 +1,338 @@
---
name: "Modern React Project Template"
description: "A comprehensive development guide for modern frontend projects based on React 18 + TypeScript + Vite, including complete development standards and best practices"
category: "Frontend Framework"
author: "Agents.md Collection"
authorUrl: "https://github.com/gakeez/agents_md_collection"
tags: ["react", "typescript", "vite", "frontend", "spa"]
lastUpdated: "2024-12-19"
---
# Modern React Project Development Guide
## Project Overview
This is a modern frontend project template based on React 18, TypeScript, and Vite. It's suitable for building high-performance Single Page Applications (SPA) with integrated modern development toolchain and best practices.
## Tech Stack
- **Frontend Framework**: React 18 + TypeScript
- **Build Tool**: Vite
- **State Management**: Zustand / Redux Toolkit
- **Routing**: React Router v6
- **UI Components**: Ant Design / Material-UI
- **Styling**: Tailwind CSS / Styled-components
- **Testing Framework**: Vitest + React Testing Library
- **Code Quality**: ESLint + Prettier + Husky
## Project Structure
```
react-project/
├── public/ # Static assets
│ ├── favicon.ico
│ └── index.html
├── src/
│ ├── components/ # Reusable components
│ │ ├── common/ # Common components
│ │ └── ui/ # UI components
│ ├── pages/ # Page components
│ ├── hooks/ # Custom Hooks
│ ├── store/ # State management
│ ├── services/ # API services
│ ├── utils/ # Utility functions
│ ├── types/ # TypeScript type definitions
│ ├── styles/ # Global styles
│ ├── constants/ # Constants
│ ├── App.tsx
│ └── main.tsx
├── tests/ # Test files
├── docs/ # Project documentation
├── .env.example # Environment variables example
├── package.json
├── tsconfig.json
├── vite.config.ts
└── README.md
```
## Development Guidelines
### Component Development Standards
1. **Function Components First**: Use function components and Hooks
2. **TypeScript Types**: Define interfaces for all props
3. **Component Naming**: Use PascalCase, file name matches component name
4. **Single Responsibility**: Each component handles only one functionality
```tsx
// Example: Button Component
interface ButtonProps {
variant: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant,
size = 'medium',
disabled = false,
onClick,
children
}) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
};
```
### State Management Standards
Using Zustand for state management:
```tsx
// store/userStore.ts
import { create } from 'zustand';
interface User {
id: string;
name: string;
email: string;
}
interface UserState {
user: User | null;
isLoading: boolean;
setUser: (user: User) => void;
clearUser: () => void;
setLoading: (loading: boolean) => void;
}
export const useUserStore = create<UserState>((set) => ({
user: null,
isLoading: false,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
setLoading: (isLoading) => set({ isLoading }),
}));
```
### API Service Standards
```tsx
// services/api.ts
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
});
// Request interceptor
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor
api.interceptors.response.use(
(response) => response.data,
(error) => {
console.error('API Error:', error);
return Promise.reject(error);
}
);
export default api;
```
## Environment Setup
### Development Requirements
- Node.js >= 18.0.0
- npm >= 8.0.0 or yarn >= 1.22.0
### Installation Steps
```bash
# 1. Create project
npm create vite@latest my-react-app -- --template react-ts
# 2. Navigate to project directory
cd my-react-app
# 3. Install dependencies
npm install
# 4. Install additional dependencies
npm install zustand react-router-dom axios
npm install -D @types/node
# 5. Start development server
npm run dev
```
### Environment Variables Configuration
```env
# .env.local
VITE_API_URL=http://localhost:3001/api
VITE_APP_TITLE=My React App
VITE_ENABLE_MOCK=false
```
## Routing Configuration
```tsx
// App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { HomePage } from './pages/HomePage';
import { AboutPage } from './pages/AboutPage';
import { NotFoundPage } from './pages/NotFoundPage';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;
```
## Testing Strategy
### Unit Testing Example
```tsx
// tests/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from '../src/components/Button';
describe('Button Component', () => {
test('renders button with text', () => {
render(<Button variant="primary">Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(
<Button variant="primary" onClick={handleClick}>
Click me
</Button>
);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
```
## Performance Optimization
### Code Splitting
```tsx
import { lazy, Suspense } from 'react';
const LazyComponent = lazy(() => import('./LazyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
}
```
### Memory Optimization
```tsx
import { memo, useMemo, useCallback } from 'react';
const ExpensiveComponent = memo(({ data, onUpdate }) => {
const processedData = useMemo(() => {
return data.map(item => ({ ...item, processed: true }));
}, [data]);
const handleUpdate = useCallback((id) => {
onUpdate(id);
}, [onUpdate]);
return (
<div>
{processedData.map(item => (
<div key={item.id} onClick={() => handleUpdate(item.id)}>
{item.name}
</div>
))}
</div>
);
});
```
## Deployment Configuration
### Build Production Version
```bash
npm run build
```
### Vite Configuration Optimization
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
},
},
},
},
server: {
port: 3000,
open: true,
},
});
```
## Common Issues
### Issue 1: Vite Development Server Slow Startup
**Solution**:
- Check dependency pre-build cache
- Use `npm run dev -- --force` to force rebuild
- Optimize optimizeDeps configuration in vite.config.ts
### Issue 2: TypeScript Type Errors
**Solution**:
- Ensure correct type definition packages are installed
- Check tsconfig.json configuration
- Use `npm run type-check` for type checking
## Reference Resources
- [React Official Documentation](https://react.dev/)
- [Vite Official Documentation](https://vitejs.dev/)
- [TypeScript Official Documentation](https://www.typescriptlang.org/)
- [React Router Documentation](https://reactrouter.com/)
- [Zustand Documentation](https://github.com/pmndrs/zustand)

170
README.md
View File

@@ -1,20 +1,164 @@
<div align="center"> ![Omnyx Logo](img/logo.png)
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app # Omnyx - Media Discovery Platform
This contains everything you need to run your app locally. ![Omnyx Banner](img/banner.png)
View your app in AI Studio: https://ai.studio/apps/e9b611d4-7585-400f-9a12-81904b28ce1c A modern web application for browsing, managing, and discovering media across multiple categories. Omnyx provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR.
## Run Locally ## Features
**Prerequisites:** Node.js - **Multi-Category Media Management**: Browse and organize Anime, Movies, TV Series, Music, Books, Consoles, Games, and Adult content
- **Detailed Media Views**: Rich information display including descriptions, genres, studios, cast, and ratings
- **Cast & Staff Directory**: Explore actors, directors, and other staff across your media library
- **Search & Filter**: Quickly find media by title, year, genre, or studio
- **Custom Media**: Add your own media entries manually
- **External Importers**: Import libraries from popular media managers:
- **Playnite**: Import your game library with playtime, achievements, and metadata
- **StashAPP**: Import adult content with scene and performer details
- **XBVR**: Import VR content with scene information
- **Category Toggles**: Enable/disable categories to customize your view
- **Responsive Design**: Beautiful UI built with Tailwind CSS and shadcn/ui components
## Tech Stack
1. Install dependencies: - **Frontend Framework**: React 19 with TypeScript
`npm install` - **Build Tool**: Vite 6
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key - **Styling**: Tailwind CSS 4
3. Run the app: - **UI Components**: shadcn/ui, Base UI
`npm run dev` - **Icons**: Lucide React
- **Animations**: Motion (Framer Motion)
- **Fonts**: Geist Variable Font
## Prerequisites
- Node.js (v18 or higher)
- npm or yarn
- Backend API server (configured via environment variables)
## Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd frontend
```
2. Install dependencies:
```bash
npm install
```
3. Configure environment variables:
```bash
cp .env.example .env
```
Edit `.env` and configure your settings (see [Configuration](#configuration) below).
4. Start the development server:
```bash
npm run dev
```
The app will be available at `http://localhost:3000`
## Configuration
Create a `.env` file based on `.env.example`:
```env
# App URL (auto-injected by AI Studio in production)
APP_URL="http://localhost:3000"
# XBVR Importer
VITE_XBVR_URL="http://localhost:9999"
# StashAPP Importer
VITE_STASHAPP_URL="http://localhost:9999"
VITE_STASHAPP_API_KEY="your-api-key"
# Playnite Importer
VITE_PLAYNITE_IP="localhost"
VITE_PLAYNITE_PORT="19821"
VITE_PLAYNITE_API_TOKEN="your-api-token"
```
### Importer Configuration
#### Playnite
1. Install the [Playnite Bridge extension](https://github.com/JosefNemec/PlayniteExtensions) in Playnite
2. Enable the HTTP API server in Playnite settings
3. Generate an API token in the Playnite Bridge settings
4. Configure the IP, port, and token in your `.env` file
5. See [playnite_skill.md](playnite_skill.md) for detailed API documentation
#### StashAPP
1. Ensure StashAPP is running and accessible
2. Generate an API key in StashAPP settings
3. Configure the URL and API key in your `.env` file
#### XBVR
1. Ensure XBVR is running and accessible
2. Configure the URL in your `.env` file
## Usage
### Browsing Media
- Navigate between categories using the header navigation
- Toggle categories on/off using the category buttons
- Search for media using the search bar
- Click on any media item to view detailed information
### Managing Media
- **Add Media**: Click the "+" button to add custom media entries
- **Import**: Use the import feature to bring in libraries from Playnite, StashAPP, or XBVR
- **View Cast**: Browse the cast directory to see all staff across your library
### Importing from External Sources
1. Click the import button in the header
2. Select the importer you want to use
3. Follow the on-screen instructions to configure and run the import
4. Progress will be displayed in real-time
## Available Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
- `npm run lint` - Run TypeScript type checking
- `npm run clean` - Remove build artifacts
## Project Structure
```
src/
├── components/ # React components
│ ├── ui/ # shadcn/ui components
│ ├── AddMediaView.tsx
│ ├── BrowseView.tsx
│ ├── CastDetailView.tsx
│ ├── CastView.tsx
│ ├── DetailView.tsx
│ └── ImporterView.tsx
├── lib/ # Utility functions
│ ├── playniteImporter.ts
│ ├── stashappImporter.ts
│ └── utils.ts
├── App.tsx # Main application component
├── api.ts # API client functions
├── data.ts # Mock data
└── types.ts # TypeScript type definitions
```
## API Documentation
For detailed API documentation, see [api.md](api.md).
## License
SPDX-License-Identifier: Apache-2.0
## Support
For issues, questions, or contributions, please refer to the project repository.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
{
"name": "Jane Smith",
"photo": "https://example.com/jane-smith.jpg",
"bio": "Adult film actress and model",
"birthDate": "1998-03-20",
"birthPlace": "Miami, Florida",
"occupations": ["Actress", "Model"],
"adult_specifics": {
"bust_size": "36",
"cup_size": "DD",
"waist_size": "26",
"hip_size": "38",
"height": "170",
"weight": "58",
"hair_color": "Brunette",
"eye_color": "Green",
"ethnicity": "Latina",
"tattoos": "Lower back",
"piercings": "None",
"measurements": "36-26-38",
"shoe_size": "8"
}
}

View File

@@ -0,0 +1,28 @@
{
"title": "Thriller",
"year": 1982,
"poster": "https://example.com/thriller-cover.jpg",
"description": "Sixth studio album by Michael Jackson",
"rating": 9.0,
"category": "Music",
"type": "Album",
"status": "Released",
"genres": ["Pop", "Funk", "Rock"],
"tags": ["Classic", "Best-selling"],
"studios": ["Epic Records"],
"staff": [],
"tracks": [
{
"track_number": 1,
"title": "Wanna Be Startin' Somethin'",
"duration": "6:03",
"artist": "Michael Jackson"
},
{
"track_number": 2,
"title": "Baby Be Mine",
"duration": "4:20",
"artist": "Michael Jackson"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"name": "Tom Hardy",
"photo": "https://example.com/tom.jpg",
"bio": "English actor known for versatile roles",
"birthDate": "1977-09-15",
"birthPlace": "Hammersmith, London, England",
"occupations": ["Actor", "Producer", "Writer"]
}

View File

@@ -0,0 +1,9 @@
{
"season": 1,
"episode_number": 3,
"title": "...And the Bag's in the River",
"description": "Walter and Jesse deal with the aftermath.",
"air_date": "2008-02-03",
"duration": 47,
"thumbnail": "https://example.com/ep3.jpg"
}

View File

@@ -0,0 +1,32 @@
{
"title": "The Matrix",
"year": 1999,
"poster": "https://example.com/matrix-poster.jpg",
"banner": "https://example.com/matrix-banner.jpg",
"description": "A computer hacker learns about the true nature of reality.",
"rating": 8.7,
"category": "Movie",
"type": "Movie",
"status": "Released",
"aspectRatio": "2.39:1",
"runtime": 136,
"director": "The Wachowskis",
"writer": "The Wachowskis",
"releaseDate": "1999-03-31",
"genres": ["Sci-Fi", "Action"],
"tags": ["Cyberpunk", "AI", "Simulation"],
"studios": ["Warner Bros."],
"staff": [
{
"name": "Keanu Reeves",
"photo": "https://example.com/keanu.jpg",
"bio": "Canadian actor",
"birthDate": "1964-09-02",
"birthPlace": "Beirut, Lebanon",
"role": "Actor",
"characterName": "Neo",
"characterImage": null,
"occupations": ["Actor"]
}
]
}

View File

@@ -0,0 +1,6 @@
{
"track_number": 3,
"title": "On the Run",
"duration": "3:35",
"artist": "Pink Floyd"
}

View File

@@ -0,0 +1,29 @@
{
"title": "Stranger Things",
"year": 2016,
"poster": "https://example.com/st-poster.jpg",
"description": "When a young boy disappears, his mother uncovers a mystery.",
"rating": 8.7,
"category": "TV",
"type": "TV",
"status": "Ongoing",
"runtime": 50,
"director": "The Duffer Brothers",
"writer": "The Duffer Brothers",
"releaseDate": "2016-07-15",
"genres": ["Sci-Fi", "Horror", "Drama"],
"tags": ["80s", "Supernatural", "Government Conspiracy"],
"studios": ["Netflix"],
"staff": [],
"episodes": [
{
"season": 1,
"episode_number": 1,
"title": "Chapter One: The Vanishing of Will Byers",
"description": "On his way home from a friend's house, young Will sees something terrifying.",
"air_date": "2016-07-15",
"duration": 47,
"thumbnail": "https://example.com/st-ep1.jpg"
}
]
}

View File

@@ -0,0 +1,30 @@
{
"success": true,
"data": {
"items": [
{
"id": 10,
"name": "Jane Doe",
"photo": "https://example.com/jane.jpg",
"bio": "Adult film actress",
"birthDate": "1995-05-15",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"occupations": ["Actress"],
"bust_size": "34",
"cup_size": "D",
"waist_size": "24",
"hip_size": "34",
"height": "165",
"weight": "52",
"hair_color": "Blonde",
"eye_color": "Blue",
"ethnicity": "Caucasian"
}
],
"total": 25,
"page": 1,
"limit": 10
}
}

View File

@@ -0,0 +1,32 @@
{
"success": true,
"data": {
"id": 10,
"name": "Jane Doe",
"photo": "https://example.com/jane.jpg",
"bio": "Adult film actress",
"birthDate": "1995-05-15",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"occupations": ["Actress"],
"filmography": [],
"adult_specifics": {
"id": 5,
"cast_id": 10,
"bust_size": "34",
"cup_size": "D",
"waist_size": "24",
"hip_size": "34",
"height": "165",
"weight": "52",
"hair_color": "Blonde",
"eye_color": "Blue",
"ethnicity": "Caucasian",
"tattoos": "None",
"piercings": "Ears",
"measurements": "34-24-34",
"shoe_size": "7"
}
}
}

View File

@@ -0,0 +1,20 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"name": "Leonardo DiCaprio",
"photo": "https://example.com/leo.jpg",
"bio": "American actor and film producer",
"birthDate": "1974-11-11",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00"
}
],
"total": 5,
"page": 1,
"limit": 10
}
}

View File

@@ -0,0 +1,27 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Dom Cobb"
},
{
"id": 2,
"title": "The Revenant",
"year": 2015,
"poster": "https://example.com/revenant.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Hugh Glass"
}
]
}
}

View File

@@ -0,0 +1,36 @@
{
"success": true,
"data": {
"id": 1,
"name": "Leonardo DiCaprio",
"photo": "https://example.com/leo.jpg",
"bio": "American actor and film producer",
"birthDate": "1974-11-11",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"occupations": ["Actor", "Producer"],
"filmography": [
{
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Dom Cobb"
},
{
"id": 2,
"title": "The Revenant",
"year": 2015,
"poster": "https://example.com/revenant.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Hugh Glass"
}
]
}
}

View File

@@ -0,0 +1,29 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"media_id": 2,
"season": 1,
"episode_number": 1,
"title": "Pilot",
"description": "Walter White is diagnosed with lung cancer.",
"air_date": "2008-01-20",
"duration": 49,
"thumbnail": "https://example.com/ep1.jpg"
},
{
"id": 2,
"media_id": 2,
"season": 1,
"episode_number": 2,
"title": "Cat's in the Bag...",
"description": "Walter and Jesse attempt to dispose of the body.",
"air_date": "2008-01-27",
"duration": 48,
"thumbnail": "https://example.com/ep2.jpg"
}
]
}
}

View File

@@ -0,0 +1,30 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"banner": null,
"description": "A thief who steals corporate secrets through dream-sharing technology.",
"rating": 8.8,
"category": "Movie",
"type": "Movie",
"status": "Released",
"aspectRatio": "2.39:1",
"runtime": 148,
"director": "Christopher Nolan",
"writer": "Christopher Nolan",
"releaseDate": "2010-07-16",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00"
}
],
"total": 150,
"page": 1,
"limit": 10,
"totalPages": 15
}
}

View File

@@ -0,0 +1,39 @@
{
"success": true,
"data": {
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"banner": null,
"description": "A thief who steals corporate secrets through dream-sharing technology.",
"rating": 8.8,
"category": "Movie",
"type": "Movie",
"status": "Released",
"aspectRatio": "2.39:1",
"runtime": 148,
"director": "Christopher Nolan",
"writer": "Christopher Nolan",
"releaseDate": "2010-07-16",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"genres": ["Sci-Fi", "Action", "Thriller"],
"tags": ["Mind-bending", "Dream", "Heist"],
"studios": ["Warner Bros.", "Legendary Pictures"],
"staff": [
{
"id": 1,
"name": "Leonardo DiCaprio",
"photo": "https://example.com/leo.jpg",
"bio": "American actor and film producer",
"birthDate": "1974-11-11",
"birthPlace": "Los Angeles, California",
"role": "Actor",
"characterName": "Dom Cobb",
"characterImage": null,
"occupations": ["Actor", "Producer"]
}
]
}
}

View File

@@ -0,0 +1,23 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"media_id": 3,
"track_number": 1,
"title": "Speak to Me",
"duration": "1:30",
"artist": "Pink Floyd"
},
{
"id": 2,
"media_id": 3,
"track_number": 2,
"title": "Breathe",
"duration": "2:43",
"artist": "Pink Floyd"
}
]
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "Jane Smith (Updated)",
"bio": "Updated bio",
"adult_specifics": {
"hair_color": "Red",
"weight": "56"
}
}

View File

@@ -0,0 +1,4 @@
{
"name": "Tom Hardy (Updated)",
"bio": "Updated bio description"
}

View File

@@ -0,0 +1,4 @@
{
"title": "Updated Episode Title",
"description": "Updated description"
}

View File

@@ -0,0 +1,32 @@
{
"type": "Game",
"title": "1-2-Switch",
"playtime": 120,
"completionStatus": "Completed",
"favorite": true,
"communityScore": 55,
"userScore": 80,
"achievements": [
{
"name": "First Victory",
"description": "Win your first game",
"icon": "https://example.com/achievement-icon.png",
"unlocked": true,
"unlocked_date": "2026-04-09T18:00:00"
},
{
"name": "Master Player",
"description": "Win 100 games",
"icon": "https://example.com/master-icon.png",
"unlocked": true,
"unlocked_date": "2026-04-09T20:30:00"
},
{
"name": "Champion",
"description": "Win 1000 games",
"icon": "https://example.com/champion-icon.png",
"unlocked": false,
"unlocked_date": null
}
]
}

View File

@@ -0,0 +1,5 @@
{
"title": "The Matrix (Updated)",
"rating": 8.8,
"status": "Released"
}

View File

@@ -0,0 +1,4 @@
{
"title": "Updated Track Title",
"duration": "4:00"
}

BIN
img/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

BIN
img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

View File

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

View File

@@ -1,5 +1,5 @@
{ {
"name": "Kyoo - Media Discovery", "name": "Omnyx - Media Discovery",
"description": "A polished media discovery and tracking application inspired by modern anime platforms.", "description": "A polished media discovery and tracking application inspired by modern anime platforms.",
"requestFramePermissions": [] "requestFramePermissions": []
} }

1213
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,12 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"docs": "typedoc",
"docs:serve": "typedoc && npx serve docs"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
@@ -24,18 +29,24 @@
"motion": "^12.38.0", "motion": "^12.38.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.14.0",
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vite": "^6.2.0" "vite": "^6.2.0",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitest/ui": "^4.1.4",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"jsdom": "^29.0.2",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typedoc": "^0.28.19",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0",
"vitest": "^4.1.4"
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -5,80 +5,271 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { LayoutGroup } from 'motion/react'; import { LayoutGroup } from 'motion/react';
import Header from './components/Header'; import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
import Sidebar from './components/Sidebar';
import BrowseView from './components/BrowseView'; import BrowseView from './components/BrowseView';
import DashboardView from './components/DashboardView';
import DetailView from './components/DetailView'; import DetailView from './components/DetailView';
import CastView from './components/CastView'; import CastView from './components/CastView';
import CastDetailView from './components/CastDetailView'; import CastDetailView from './components/CastDetailView';
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 { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory } from './types'; import { Media, Staff, MediaCategory, UserSettings } from './types';
import { fetchMediaFromLocalJson, fetchMediaById } from './api'; import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
import { CATEGORY_PATHS, PATH_TO_CATEGORY, DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from './constants';
import { useAppStore } from './store/appStore';
export default function App() { function AppContent() {
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail'>('browse'); const navigate = useNavigate();
const [activeCategory, setActiveCategory] = useState<MediaCategory>('Anime'); const location = useLocation();
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null); const [searchParams, setSearchParams] = useSearchParams();
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null); const { setTheme } = useTheme();
const [searchQuery, setSearchQuery] = useState('');
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
const [customMedia, setCustomMedia] = useState<Media[]>([]);
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
// Load adult media on component mount // 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(() => { useEffect(() => {
const loadAdultMedia = async () => { const pathParts = location.pathname.split('/').filter(Boolean);
if (pathParts.length === 1 && PATH_TO_CATEGORY[pathParts[0]]) {
const category = PATH_TO_CATEGORY[pathParts[0]];
if (enabledCategories.includes(category)) {
setActiveCategory(category);
}
}
}, [location.pathname, enabledCategories, setActiveCategory]);
useEffect(() => {
const loadSettingsFromApi = async () => {
try { try {
const media = await fetchMediaFromLocalJson(); const loadedSettings = await fetchSettings();
// Add category to adult media if (loadedSettings) {
const categorizedMedia = media.map(m => ({ ...m, category: 'Adult' as MediaCategory })); setSettings(loadedSettings);
setAdultMedia(categorizedMedia); 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) { } catch (error) {
console.error('Failed to load adult media:', error); console.error('Failed to load settings from API:', error);
} }
}; };
loadAdultMedia();
}, []);
const toggleCategory = (category: MediaCategory) => { loadSettingsFromApi();
setEnabledCategories(prev => { }, [setTheme]);
const isEnabling = !prev.includes(category);
const newList = isEnabling
? [...prev, category]
: prev.filter(c => c !== category);
// If we disable the current active category, switch to another enabled one // Apply custom colors when settings change
if (!isEnabling && activeCategory === category) { useEffect(() => {
const nextCategory = newList.find(c => c !== category) || 'Anime'; if (settings?.customColors) {
setActiveCategory(nextCategory as MediaCategory); 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);
}
};
// Only load media if not on cast routes
if (!location.pathname.startsWith('/cast')) {
loadMediaFromApi();
}
}, [location.pathname]);
const toggleCategory = async (category: MediaCategory) => {
const isEnabling = !enabledCategories.includes(category);
const newList = isEnabling
? [...enabledCategories, category]
: enabledCategories.filter(c => c !== category);
// If we disable the current active category, switch to another enabled one
if (!isEnabling && activeCategory === category) {
const nextCategory = newList.find(c => c !== category) || 'Anime';
setActiveCategory(nextCategory as MediaCategory);
}
setEnabledCategories(newList);
// Save to API
const baseSettings = settings || DEFAULT_SETTINGS;
const updatedSettings: UserSettings = {
...baseSettings,
enabledCategories: newList,
};
updateSettings(updatedSettings).then(saved => {
if (saved) {
setSettings(saved);
} }
return newList;
}); });
}; };
const handleCategoryChange = (category: MediaCategory) => { const handleCategoryChange = (category: MediaCategory) => {
setActiveCategory(category); setActiveCategory(category);
setCurrentView('browse'); navigate(`/${CATEGORY_PATHS[category]}`);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleAddMediaView = () => {
navigate('/add');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleImporterView = () => {
navigate('/import');
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
const allMedia = useMemo(() => { const allMedia = useMemo(() => {
// Merge mock media, adult media, detail media and custom media // Use API data if available, otherwise fall back to mock data
const list = [...MOCK_MEDIA, ...adultMedia, ...customMedia]; let list: Media[] = [];
if (apiMedia.length > 0) {
// API has data, use it
list = [...apiMedia];
} else {
// API is empty, use mock data as fallback
list = [...MOCK_MEDIA];
}
// Add custom media and detail media
list = [...list, ...customMedia];
if (!list.find(m => m.id === DETAIL_MEDIA.id)) { if (!list.find(m => m.id === DETAIL_MEDIA.id)) {
list.push(DETAIL_MEDIA); list.push(DETAIL_MEDIA);
} }
// Filter by active category AND ensure it's enabled // Filter by active category AND ensure it's enabled
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category)); return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
}, [activeCategory, enabledCategories, customMedia, adultMedia]); }, [activeCategory, enabledCategories, customMedia, apiMedia]);
const handleAddMedia = (newMedia: Media) => { const handleAddMedia = async () => {
setCustomMedia(prev => [...prev, newMedia]); // Reload all media from API to get the newly added item
try {
const media = await fetchAllMedia();
setApiMedia(media);
} catch (error) {
console.error('Failed to reload media from API:', error);
}
};
const handleGridItemSizeChange = async (size: number) => {
const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories };
const updatedSettings: UserSettings = {
...baseSettings,
gridItemSize: size,
};
updateSettings(updatedSettings).then(saved => {
if (saved) {
setSettings(saved);
}
});
}; };
const allStaff = useMemo(() => { const allStaff = useMemo(() => {
const staff: Staff[] = []; const staff: Staff[] = [];
// Use all available media (mock + adult + custom + detail) but filter by enabled categories // Use API data if available, otherwise fall back to mock data
const baseList = [...MOCK_MEDIA, ...adultMedia, ...customMedia]; let baseList: Media[] = [];
if (apiMedia.length > 0) {
// API has data, use it
baseList = [...apiMedia];
} else {
// API is empty, use mock data as fallback
baseList = [...MOCK_MEDIA];
}
// Add custom media and detail media
baseList = [...baseList, ...customMedia];
if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) { if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) {
baseList.push(DETAIL_MEDIA); baseList.push(DETAIL_MEDIA);
} }
@@ -95,7 +286,7 @@ export default function App() {
}); });
}); });
return staff; return staff;
}, [enabledCategories, customMedia, adultMedia]); }, [enabledCategories, customMedia, apiMedia]);
const filteredMedia = useMemo(() => { const filteredMedia = useMemo(() => {
if (!searchQuery.trim()) return allMedia; if (!searchQuery.trim()) return allMedia;
@@ -127,17 +318,17 @@ export default function App() {
// For non-adult media, use the original media // For non-adult media, use the original media
setSelectedMedia(media); setSelectedMedia(media);
} }
setCurrentView('detail'); navigate(`/media/${media.id}`);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
const handleBack = () => { const handleBack = () => {
setCurrentView('browse'); navigate('/');
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
const handleCastClick = () => { const handleCastClick = () => {
setCurrentView('cast'); navigate('/cast');
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
@@ -151,85 +342,121 @@ export default function App() {
occupations: ['Voice Actor', 'Singer', 'Narrator'] occupations: ['Voice Actor', 'Singer', 'Narrator']
}; };
setSelectedPerson(enrichedPerson); setSelectedPerson(enrichedPerson);
setCurrentView('castDetail'); navigate(`/cast/${person.id}`);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
const handleSearch = (query: string) => { const handleSearch = (query: string) => {
setSearchQuery(query); setSearchQuery(query);
if (currentView !== 'browse' && currentView !== 'cast') { const params = new URLSearchParams(searchParams);
setCurrentView('browse'); if (query) {
params.set('search', query);
} else {
params.delete('search');
} }
setSearchParams(params);
navigate('/browse');
}; };
return ( return (
<div className="min-h-screen bg-white font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]"> <div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9] flex">
<Header <Sidebar
onBrowse={handleBack}
onCast={handleCastClick}
onSearch={handleSearch}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
enabledCategories={enabledCategories} enabledCategories={enabledCategories}
onToggleCategory={toggleCategory} onToggleCategory={toggleCategory}
transparent={currentView === 'detail' || currentView === 'castDetail'} pageTitle={settings?.pageTitle}
/> />
<main> <main className="flex-1 lg:ml-72 flex flex-col">
<LayoutGroup> <LayoutGroup>
{currentView === 'browse' ? ( <Routes>
<BrowseView <Route path="/" element={
mediaList={filteredMedia} <DashboardView
onMediaClick={handleMediaClick} mediaList={apiMedia.length > 0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))}
onAddMedia={handleAddMedia} onMediaClick={handleMediaClick}
activeCategory={activeCategory} loading={mediaLoading}
/>
) : currentView === 'cast' ? (
<CastView
staffList={allStaff}
onPersonClick={handlePersonClick}
/>
) : currentView === 'castDetail' ? (
selectedPerson && (
<CastDetailView
person={selectedPerson}
onBack={handleCastClick}
onMediaClick={(id) => {
const media = allMedia.find(m => m.id === id);
if (media) handleMediaClick(media);
}}
relatedMedia={allMedia.filter(m => m.staff?.some(s => s.id === selectedPerson.id))}
/> />
) } />
) : ( <Route path="/browse" element={
selectedMedia && ( <BrowseView
<DetailView mediaList={filteredMedia}
media={selectedMedia} onMediaClick={handleMediaClick}
onBack={handleBack} activeCategory={activeCategory}
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/:category" element={
<CategoryBrowseRoute
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/media/:id" element={
<MediaDetailRoute
allMedia={allMedia}
onPersonClick={handlePersonClick} onPersonClick={handlePersonClick}
/> />
) } />
)} <Route path="/cast" element={
<CastView
onPersonClick={handlePersonClick}
enabledCategories={enabledCategories}
itemsPerPage={settings?.itemsPerPage}
/>
} />
<Route path="/cast/:id" element={
<CastDetailRoute />
} />
<Route path="/add" element={
<AddMediaView
activeCategory={activeCategory}
enabledCategories={enabledCategories}
onAddComplete={handleAddMedia}
/>
} />
<Route path="/import" element={
<ImporterView />
} />
<Route path="/settings" element={
<SettingsView onSettingsSaved={reloadSettings} />
} />
</Routes>
</LayoutGroup> </LayoutGroup>
</main>
{/* Footer */} {/* Footer */}
<footer className="py-12 px-6 border-t border-zinc-100 bg-zinc-50"> <footer className="py-8 px-6 border-t border-border/50 bg-muted/30 backdrop-blur-sm mt-auto">
<div className="max-w-[1600px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6"> <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-xl font-black text-zinc-400"> <div className="flex items-center gap-2 text-lg font-black text-muted-foreground">
<div className="w-5 h-5 bg-zinc-300 rounded-full" /> <div className="w-5 h-5 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-full" />
kyoo <span className="bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">{settings?.pageTitle || 'omnyx'}</span>
</div>
<div className="flex items-center gap-6 text-sm font-bold text-muted-foreground">
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Terms</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Privacy</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Contact</a>
</div>
<p className="text-xs font-medium text-muted-foreground">
© 2026 Omnyx Media Discovery. All rights reserved.
</p>
</div> </div>
<div className="flex items-center gap-8 text-sm font-bold text-zinc-400"> </footer>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Terms</a> </main>
<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> </div>
); );
} }
export default function App() {
return (
<BrowserRouter>
<ThemeProvider>
<AppContent />
</ThemeProvider>
</BrowserRouter>
);
}

View File

@@ -1,290 +1,49 @@
import { Media, Staff } from './types'; // Re-export all API functions for backward compatibility
export * from './lib/api/mediaApi';
export * from './lib/api/castApi';
export * from './lib/api/settingsApi';
export * from './lib/api/converters';
export * from './lib/api/types';
const BASE_URL = 'http://192.168.1.102:57000'; // Legacy functions for compatibility
export async function fetchAllTags(): Promise<string[]> {
function normalizeUrl(url: string | null): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Remove leading slash if present and add base URL
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
return `${BASE_URL}/${cleanPath}`;
}
export interface ApiResponse {
success: boolean;
data: {
items: ApiMediaItem[];
};
}
export interface ApiMediaItem {
id: number;
title: string;
overview: string;
poster_url: string;
poster_aspect_ratio: string | null;
backdrop_url: string | null;
backdrop_aspect_ratio: string | null;
rating: string;
runtime_minutes: number;
release_date: string;
director: string | null;
writer: string | null;
cast: string | null;
genre: string | null;
metadata: string;
actors?: Array<{
id: number;
name: string;
thumbnail_path: string | null;
metadata?: string;
created_at?: string;
updated_at?: string;
}>;
}
export interface ApiMetadata {
xbvr_id: number;
xbvr_url: string | null;
cast: string[];
actors: Array<{
id: number;
name: string;
thumbnail_path: string | null;
}>;
tags: string[];
is_available: boolean;
is_watched: boolean;
watch_count: number;
video_length: number;
video_width: number | null;
video_height: number | null;
video_codec: string | null;
file_path: string | null;
cover_url: string;
[key: string]: any;
}
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
let metadata: ApiMetadata;
try { try {
metadata = JSON.parse(apiItem.metadata); const { fetchAllMedia } = await import('./lib/api/mediaApi');
} catch (e) { const media = await fetchAllMedia(1, 1000);
metadata = { const tagSet = new Set<string>();
xbvr_id: 0,
xbvr_url: null,
cast: [],
actors: [],
tags: [],
is_available: false,
is_watched: false,
watch_count: 0,
video_length: 0,
video_width: null,
video_height: null,
video_codec: null,
file_path: null,
cover_url: apiItem.poster_url,
};
}
// Use actors from the main item if available, otherwise from metadata media.forEach(item => {
const actors = apiItem.actors || metadata.actors || []; item.tags?.forEach(tag => tagSet.add(tag));
const staff: Staff[] = actors.map((actor, index) => ({ item.genres?.forEach(genre => tagSet.add(genre));
id: `actor-${actor.id}`, });
name: actor.name,
role: 'Actor',
photo: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
characterName: actor.name,
characterImage: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
}));
return Array.from(tagSet).sort();
// Determine aspect ratio from poster_aspect_ratio or default to 2/3
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
if (apiItem.poster_aspect_ratio) {
const ratio = apiItem.poster_aspect_ratio.toLowerCase();
if (ratio.includes('16:9') || ratio.includes('1.78')) {
aspectRatio = '16/9';
} else if (ratio.includes('1:1') || ratio.includes('1.00')) {
aspectRatio = '1/1';
}
}
return {
id: apiItem.id.toString() || undefined,
title: apiItem.title || undefined,
year: apiItem.release_date ? new Date(apiItem.release_date).getFullYear().toString() : 'Unknown',
poster: normalizeUrl(apiItem.poster_url) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
banner: normalizeUrl(apiItem.backdrop_url) || undefined,
description: apiItem.overview || undefined,
rating: apiItem.rating ? parseFloat(apiItem.rating) : undefined,
genres: metadata.tags || [],
tags: metadata.tags || [],
studios: apiItem.director ? [apiItem.director] : undefined,
type: 'Movie',
status: 'completed',
staff: staff.length > 0 ? staff : undefined,
runtime: apiItem.runtime_minutes,
director: apiItem.director || undefined,
writer: apiItem.writer || undefined,
releaseDate: apiItem.release_date || undefined,
aspectRatio: aspectRatio
};
}
export async function fetchMediaFromApi(apiUrl: string = `${BASE_URL}/api/adult`): Promise<Media[]> {
console.error('Error fetching');
try {
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) { } catch (error) {
console.error('Error fetching media from API:', error); console.error('Error fetching all tags:', error);
return []; return [];
} }
} }
export async function fetchMediaFromLocalJson(): Promise<Media[]> { export async function fetchMediaByTag(tag: string) {
try { try {
const response = await fetch('/adult.json'); const { fetchAllMedia } = await import('./lib/api/mediaApi');
if (!response.ok) { const media = await fetchAllMedia(1, 1000);
throw new Error(`HTTP error! status: ${response.status}`); return media.filter(item =>
} item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) ||
const data: ApiResponse = await response.json(); item.genres?.some(g => g.toLowerCase().includes(tag.toLowerCase()))
if (data.data.items) { );
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching media from local JSON:', error);
return [];
}
}
export async function fetchMediaById(id: number): Promise<Media | null> {
try {
const response = await fetch('/adult.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
const item = data.data.items.find(item => item.id === id);
return item ? convertApiToMedia(item) : null;
}
return null;
} catch (error) {
console.error('Error fetching media by ID:', error);
return null;
}
}
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
try {
const response = await fetch('/adult.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
return data.data.items
.filter(item => item.actors?.some(actor => actor.name.toLowerCase().includes(actorName.toLowerCase())))
.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching media by actor:', error);
return [];
}
}
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
try {
const response = await fetch('/adult.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
return data.data.items
.filter(item => {
try {
const metadata = JSON.parse(item.metadata);
return metadata.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase()));
} catch {
return false;
}
})
.map(convertApiToMedia);
}
return [];
} catch (error) { } catch (error) {
console.error('Error fetching media by tag:', error); console.error('Error fetching media by tag:', error);
return []; return [];
} }
} }
export async function fetchAllActors(): Promise<Array<{id: number, name: string, thumbnail_path: string | null}>> { export async function fetchMediaFromApi(apiUrl?: string) {
try { const { fetchAllMedia } = await import('./lib/api/mediaApi');
const response = await fetch('/adult.json'); return fetchAllMedia();
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
const actorMap = new Map();
data.data.items.forEach(item => {
item.actors?.forEach(actor => {
if (!actorMap.has(actor.id)) {
actorMap.set(actor.id, {
id: actor.id,
name: actor.name,
thumbnail_path: actor.thumbnail_path
});
}
});
});
return Array.from(actorMap.values());
}
return [];
} catch (error) {
console.error('Error fetching all actors:', error);
return [];
}
} }
export async function fetchAllTags(): Promise<string[]> { export async function fetchMediaFromLocalJson() {
try { const { fetchAllMedia } = await import('./lib/api/mediaApi');
const response = await fetch('/adult.json'); return fetchAllMedia();
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
const tagSet = new Set<string>();
data.data.items.forEach(item => {
try {
const metadata = JSON.parse(item.metadata);
metadata.tags?.forEach((tag: string) => tagSet.add(tag));
} catch {
// Ignore metadata parsing errors
}
});
return Array.from(tagSet).sort();
}
return [];
} catch (error) {
console.error('Error fetching all tags:', error);
return [];
}
} }

View File

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

View File

@@ -1,8 +1,9 @@
import { Media, MediaCategory } from '@/types'; import { Media, MediaCategory } from '@/types';
import MediaCard from './MediaCard'; import MediaCard from './MediaCard';
import MediaListItem from './MediaListItem'; import MediaListItem from './MediaListItem';
import { Filter, LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Plus } from 'lucide-react'; import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Loading from '@/components/ui/loading';
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { import {
DropdownMenu, DropdownMenu,
@@ -10,98 +11,67 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AnimatePresence } from 'motion/react'; import { AnimatePresence } from 'motion/react';
interface BrowseViewProps { interface BrowseViewProps {
mediaList: Media[]; mediaList: Media[];
onMediaClick: (media: Media) => void; onMediaClick: (media: Media) => void;
onAddMedia: (media: Media) => void;
activeCategory: MediaCategory; activeCategory: MediaCategory;
itemsPerPage?: number;
gridItemSize?: number;
onGridItemSizeChange?: (size: number) => void;
loading?: boolean;
} }
export default function BrowseView({ mediaList, onMediaClick, onAddMedia, activeCategory }: BrowseViewProps) { export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12, gridItemSize: initialGridItemSize = 5, onGridItemSizeChange, loading = false }: BrowseViewProps) {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(12); const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
const [sortBy, setSortBy] = useState<string>('default'); const [sortBy, setSortBy] = useState<string>('default');
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
// Add Media Dialog State // Sync itemsPerPage with prop when API settings are loaded
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [newMedia, setNewMedia] = useState({
title: '',
year: '',
poster: '',
category: activeCategory as MediaCategory,
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1'
});
// Update category and default aspect ratio when activeCategory changes
useEffect(() => { useEffect(() => {
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3'; if (initialItemsPerPage) {
if (activeCategory === 'Music') defaultAspect = '1/1'; setItemsPerPage(initialItemsPerPage);
if (activeCategory === 'Games' || activeCategory === 'Adult') defaultAspect = '16/9'; }
}, [initialItemsPerPage]);
setNewMedia(prev => ({ // Sync gridItemSize with prop when API settings are loaded
...prev, useEffect(() => {
category: activeCategory, if (initialGridItemSize !== undefined) {
aspectRatio: defaultAspect setGridItemSize(initialGridItemSize);
})); }
}, [activeCategory]); }, [initialGridItemSize]);
const handleAddSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!newMedia.title || !newMedia.poster) return;
onAddMedia({
id: Math.random().toString(36).substr(2, 9),
title: newMedia.title,
year: newMedia.year || new Date().getFullYear().toString(),
poster: newMedia.poster,
category: newMedia.category,
aspectRatio: newMedia.aspectRatio,
status: 'planned'
});
setNewMedia({
title: '',
year: '',
poster: '',
category: activeCategory,
aspectRatio: '2/3'
});
setIsAddDialogOpen(false);
};
// Filter states // Filter states
const [selectedType, setSelectedType] = useState<string | null>(null);
const [selectedGenre, setSelectedGenre] = useState<string | null>(null); const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [selectedStudio, setSelectedStudio] = useState<string | null>(null); const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
// Extract unique values for filters // Extract unique values for filters
const allTypes = useMemo(() => Array.from(new Set(mediaList.map(m => m.type).filter(Boolean))), [mediaList]);
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]); const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]); const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]);
const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
const filteredMedia = useMemo(() => { const filteredMedia = useMemo(() => {
return mediaList.filter(media => { return mediaList.filter(media => {
if (selectedType && media.type !== selectedType) return false;
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false; if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false; if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
if (selectedSource && media.source !== selectedSource) return false;
return true; return true;
}); });
}, [mediaList, selectedType, selectedGenre, selectedStudio]); }, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory, selectedSource]);
// Reset to first page when mediaList or filters change // Reset to first page when mediaList or filters change
useEffect(() => { useEffect(() => {
@@ -119,6 +89,23 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
return list; return list;
}, [filteredMedia, sortBy]); }, [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 totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
const paginatedMedia = useMemo(() => { const paginatedMedia = useMemo(() => {
@@ -137,33 +124,17 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
}; };
return ( return (
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto"> <div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
{/* Filters Bar */} {/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-8"> <div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* Type Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedType ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<Filter size={16} />
{selectedType || 'Media Type'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setSelectedType(null)}>All Types</DropdownMenuItem>
{allTypes.map(type => (
<DropdownMenuItem key={type} onClick={() => setSelectedType(type!)}>{type}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Genre Filter */} {/* Genre Filter */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}> <button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Star size={16} /> <Star size={16} />
{selectedGenre || 'Genres'} {selectedGenre || 'Genres'}
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto"> <DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem> <DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
@@ -176,9 +147,9 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
{/* Studio Filter */} {/* Studio Filter */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}> <button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
Studios Studios
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto"> <DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem> <DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
@@ -188,15 +159,90 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{(selectedType || selectedGenre || selectedStudio) && ( {/* Platform Filter - Only for Games */}
{activeCategory === 'Games' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Monitor size={16} />
{selectedPlatform || 'Platforms'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedPlatform(null)}>All Platforms</DropdownMenuItem>
{allPlatforms.sort().map(platform => (
<DropdownMenuItem key={platform} onClick={() => setSelectedPlatform(platform)}>{platform}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Developer Filter - Only for Games */}
{activeCategory === 'Games' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Users size={16} />
{selectedDeveloper || 'Developers'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedDeveloper(null)}>All Developers</DropdownMenuItem>
{allDevelopers.sort().map(developer => (
<DropdownMenuItem key={developer} onClick={() => setSelectedDeveloper(developer)}>{developer}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Category Filter - Only for Games */}
{activeCategory === 'Games' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<FolderTree size={16} />
{selectedCategory || 'Categories'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>All Categories</DropdownMenuItem>
{allCategories.sort().map(category => (
<DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Source Filter */}
{allSources.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Tag size={16} />
{selectedSource || 'Source'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedSource(null)}>All Sources</DropdownMenuItem>
{allSources.sort().map(source => (
<DropdownMenuItem key={source} onClick={() => setSelectedSource(source)}>{source}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory || selectedSource) && (
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
className="text-zinc-400 font-bold" className="text-muted-foreground font-bold hover:text-[#6d28d9] transition-colors"
onClick={() => { onClick={() => {
setSelectedType(null);
setSelectedGenre(null); setSelectedGenre(null);
setSelectedStudio(null); setSelectedStudio(null);
setSelectedPlatform(null);
setSelectedDeveloper(null);
setSelectedCategory(null);
setSelectedSource(null);
}} }}
> >
Clear Filters Clear Filters
@@ -204,99 +250,31 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
)} )}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}> {/* Grid item size slider */}
<DialogTrigger asChild> <div className="flex items-center gap-3 bg-muted/50 backdrop-blur-sm rounded-xl px-4 py-2.5 border border-border/50">
<Button className="bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black rounded-full px-6 h-11 shadow-lg shadow-[#6d28d9]/20 gap-2"> <span className="text-xs font-bold text-muted-foreground">Size</span>
<Plus size={20} /> <input
ADD NEW type="range"
</Button> min="1"
</DialogTrigger> max="10"
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl"> value={gridItemSize}
<form onSubmit={handleAddSubmit}> onChange={(e) => {
<DialogHeader> const newSize = Number(e.target.value);
<DialogTitle className="text-2xl font-black text-zinc-900">Add New Media</DialogTitle> setGridItemSize(newSize);
<DialogDescription className="text-zinc-500 font-medium"> onGridItemSizeChange?.(newSize);
Manually add a new item to your {activeCategory} library. }}
</DialogDescription> className="w-24 h-2 bg-background rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
</DialogHeader> />
<div className="grid gap-6 py-6"> <span className="text-xs font-bold text-[#6d28d9] w-5 text-center">{gridItemSize}</span>
<div className="grid gap-2"> </div>
<Label htmlFor="title" className="text-sm font-black text-zinc-700">Title</Label>
<Input
id="title"
value={newMedia.title}
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g. Mob Psycho 100"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="year" className="text-sm font-black text-zinc-700">Year</Label>
<Input
id="year"
value={newMedia.year}
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
placeholder="2024"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="category" className="text-sm font-black text-zinc-700">Category</Label>
<select
id="category"
value={newMedia.category}
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label>
<select
id="aspectRatio"
value={newMedia.aspectRatio}
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
<option value="2/3">2:3 (Standard Poster - Anime/Movies)</option>
<option value="16/9">16:9 (Wide Thumbnail - Games/Adult)</option>
<option value="1/1">1:1 (Square - Music)</option>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="poster" className="text-sm font-black text-zinc-700">Poster URL</Label>
<Input
id="poster"
value={newMedia.poster}
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
placeholder="https://example.com/poster.jpg"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
required
/>
</div>
</div>
<DialogFooter>
<Button type="submit" className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20">
SAVE TO LIBRARY
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="text-zinc-600 font-bold gap-2"> <button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 text-muted-foreground font-bold backdrop-blur-sm border-border/50">
<ArrowUpDown size={16} /> <ArrowUpDown size={16} />
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'} {sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem> <DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem>
@@ -305,13 +283,13 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 transition-all", "h-8 w-8 transition-all rounded-lg",
viewMode === 'grid' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400" viewMode === 'grid' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
)} )}
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
> >
@@ -321,8 +299,8 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 transition-all", "h-8 w-8 transition-all rounded-lg",
viewMode === 'list' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400" viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
)} )}
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
> >
@@ -333,10 +311,12 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
</div> </div>
{/* Content */} {/* Content */}
{mediaList.length === 0 ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400"> <Loading message="Loading media..." />
<div className="w-16 h-16 bg-zinc-100 rounded-full flex items-center justify-center mb-4"> ) : mediaList.length === 0 ? (
<Filter size={32} /> <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> </div>
<p className="text-lg font-bold">No results found</p> <p className="text-lg font-bold">No results found</p>
<p className="text-sm">Try adjusting your search or filters</p> <p className="text-sm">Try adjusting your search or filters</p>
@@ -344,7 +324,7 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
) : ( ) : (
<div className={cn( <div className={cn(
viewMode === 'grid' 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" : "flex flex-col gap-2"
)}> )}>
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
@@ -369,18 +349,18 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
{/* Pagination Controls */} {/* Pagination Controls */}
{mediaList.length > 0 && ( {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"> <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 <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => { onChange={(e) => {
setItemsPerPage(Number(e.target.value)); setItemsPerPage(Number(e.target.value));
setCurrentPage(1); 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> <option key={size} value={size}>{size}</option>
))} ))}
</select> </select>
@@ -392,7 +372,7 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
size="sm" size="sm"
onClick={handlePrevPage} onClick={handlePrevPage}
disabled={currentPage === 1} disabled={currentPage === 1}
className="gap-2 font-bold border-zinc-200" className="gap-2 font-bold border-border"
> >
<ChevronLeft size={16} /> <ChevronLeft size={16} />
Previous Previous
@@ -400,8 +380,8 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span> <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 text-muted-foreground font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span> <span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
</div> </div>
<Button <Button
@@ -409,7 +389,7 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
size="sm" size="sm"
onClick={handleNextPage} onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0} disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-zinc-200" className="gap-2 font-bold border-border"
> >
Next Next
<ChevronRight size={16} /> <ChevronRight size={16} />

View File

@@ -1,35 +1,54 @@
import { Staff, Media } from '@/types'; import { Staff, Media } from '@/types';
import { useNavigate } from 'react-router-dom';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User } from 'lucide-react'; import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye, ChevronDown, ListFilter } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useState } from 'react';
interface CastDetailViewProps { interface CastDetailViewProps {
person: Staff; person: Staff;
onBack: () => void;
onMediaClick: (mediaId: string) => void;
relatedMedia: Media[]; relatedMedia: Media[];
} }
export default function CastDetailView({ person, onBack, onMediaClick, relatedMedia }: CastDetailViewProps) { export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
const navigate = useNavigate();
const [sortBy, setSortBy] = useState<'year' | 'title' | 'role'>('role');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const handleMediaClick = (mediaId: string) => {
navigate(`/media/${mediaId}`);
};
const sortedFilmography = [...(person.filmography || [])].sort((a, b) => {
let comparison = 0;
if (sortBy === 'year') {
comparison = (a.year || 0) - (b.year || 0);
} else if (sortBy === 'title') {
comparison = (a.title || '').localeCompare(b.title || '');
} else if (sortBy === 'role') {
comparison = (a.role || '').localeCompare(b.role || '');
}
return sortOrder === 'asc' ? comparison : -comparison;
});
return ( return (
<div className="min-h-screen bg-white pb-20"> <div className="min-h-screen bg-background pb-20">
{/* Hero Section */} {/* Hero Section */}
<div className="relative h-[40vh] md:h-[50vh] overflow-hidden bg-zinc-900"> <div className="relative h-[50vh] md:h-[60vh] overflow-hidden bg-zinc-900">
<img <img
src={person.photo} src={person.photo}
alt={person.name} alt={person.name}
className="w-full h-full object-cover opacity-40 blur-xl scale-110" className="w-full h-full object-cover opacity-40 blur-xl scale-110"
referrerPolicy="no-referrer" 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="absolute inset-0 flex items-end px-6 pb-12">
<div className="max-w-[1200px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-8"> <div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-8">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="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 <img
src={person.photo} src={person.photo}
@@ -45,15 +64,20 @@ export default function CastDetailView({ person, onBack, onMediaClick, relatedMe
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
> >
<h1 className="text-4xl md:text-6xl font-black text-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} {person.name}
</h1> </h1>
<div className="flex flex-wrap justify-center md:justify-start gap-3"> <div className="flex flex-wrap justify-center md:justify-start gap-3">
{person.occupations?.map(occ => ( {person.occupations?.map(occ => (
<Badge key={occ} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] border-none font-bold px-4 py-1"> <Badge key={occ} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 font-bold px-4 py-1.5 backdrop-blur-sm">
{occ} {occ}
</Badge> </Badge>
))} ))}
{person.filmography && person.filmography.length > 0 && (
<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> </div>
</motion.div> </motion.div>
</div> </div>
@@ -63,140 +87,286 @@ export default function CastDetailView({ person, onBack, onMediaClick, relatedMe
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onBack} onClick={() => navigate(-1)}
className="absolute top-24 left-6 bg-white/20 hover:bg-white/40 text-white rounded-full backdrop-blur-md" className="absolute top-24 left-6 bg-white/30 hover:bg-white/50 text-white rounded-2xl backdrop-blur-md transition-all duration-300 hover:scale-110 border border-white/20"
> >
<ArrowLeft size={24} /> <ArrowLeft size={24} />
</Button> </Button>
</div> </div>
{/* Content Section */} {/* Content Section */}
<div className="max-w-[1200px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12"> <div className="max-w-[1920px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Sidebar Info */} {/* Sidebar Info */}
<div className="space-y-8"> <div className="space-y-8">
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6"> <div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
<h3 className="text-xl font-black text-zinc-900">Personal Info</h3> <h3 className="text-2xl font-black text-foreground">Personal Info</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-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} /> <Calendar size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Date</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Date</p>
<p className="font-bold text-zinc-700">{person.birthDate || 'Unknown'}</p> <p className="font-bold text-foreground">{person.birthDate || 'Unknown'}</p>
</div> </div>
</div> </div>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-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} /> <MapPin size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Place</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Place</p>
<p className="font-bold text-zinc-700">{person.birthPlace || 'Unknown'}</p> <p className="font-bold text-foreground">{person.birthPlace || 'Unknown'}</p>
</div> </div>
</div> </div>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-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} /> <Briefcase size={20} />
</div> </div>
<div> <div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Known For</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Known For</p>
<p className="font-bold text-zinc-700">{person.role}</p> <p className="font-bold text-foreground">{person.role}</p>
</div> </div>
</div> </div>
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<User size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Ethnicity</p>
<p className="font-bold text-foreground">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
</div>
</div>
)}
</div> </div>
</div> </div>
<div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
<h3 className="text-2xl font-black text-foreground">Measurements</h3>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Height</p>
<p className="font-bold text-foreground">{person.adult_specifics?.height || person.height} cm</p>
</div>
</div>
{(person.weight || person.adult_specifics?.weight) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Weight</p>
<p className="font-bold text-foreground">{person.adult_specifics?.weight || person.weight} kg</p>
</div>
</div>
)}
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Measurements</p>
<p className="font-bold text-foreground">
{person.adult_specifics?.measurements || (
<>
{person.bust_size && `${person.bust_size}`}
{person.cup_size && person.cup_size}
{person.bust_size || person.cup_size ? '-' : ''}
{person.waist_size && `${person.waist_size}`}
{person.waist_size ? '-' : ''}
{person.hip_size && `${person.hip_size}`}
</>
)}
</p>
</div>
</div>
)}
{(person.hair_color || person.adult_specifics?.hair_color) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Hair Color</p>
<p className="font-bold text-foreground">{person.adult_specifics?.hair_color || person.hair_color}</p>
</div>
</div>
)}
{(person.eye_color || person.adult_specifics?.eye_color) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Eye size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Eye Color</p>
<p className="font-bold text-foreground">{person.adult_specifics?.eye_color || person.eye_color}</p>
</div>
</div>
)}
{person.adult_specifics?.tattoos && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Tattoos</p>
<p className="font-bold text-foreground">{person.adult_specifics.tattoos}</p>
</div>
</div>
)}
{person.adult_specifics?.piercings && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Piercings</p>
<p className="font-bold text-foreground">{person.adult_specifics.piercings}</p>
</div>
</div>
)}
</div>
</div>
</div> </div>
{/* Main Bio & Roles */} {/* Main Bio & Roles */}
<div className="lg:col-span-2 space-y-12"> <div className="lg:col-span-2 space-y-12">
<section> {person.bio && (
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3"> <section>
Biography <h2 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3">
</h2> Biography
<p className="text-zinc-600 leading-relaxed text-lg"> </h2>
{person.bio || `${person.name} is a talented ${person.role} known for their work in various media productions. They have brought numerous characters to life with their unique performances.`} <p className="text-foreground leading-relaxed text-lg">
</p> {person.bio}
</section> </p>
</section>
)}
<section> {person.filmography && person.filmography.length > 0 && (
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3"> <section>
<User className="text-[#6d28d9]" /> <h2 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3">
Characters <User className="text-[#6d28d9]" />
</h2> Characters
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> </h2>
{relatedMedia.map(media => { <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
const character = media.staff?.find(s => s.id === person.id); {person.filmography.map(item => (
if (!character) return null;
return (
<div <div
key={`${media.id}-char`} 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 <img
src={character.characterImage} src={item.poster || person.photo}
alt={character.characterName} alt={item.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Character</p> <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Character</p>
<h4 className="font-black text-zinc-900 truncate">{character.characterName}</h4> <h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4>
<button <button
onClick={() => onMediaClick(media.id)} 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 {media.title} in {item.title}
</button> </button>
{item.category && (
<Badge variant="secondary" className="text-[10px] font-bold mt-2 bg-muted text-muted-foreground border-none">
{item.category}
</Badge>
)}
</div> </div>
</div> </div>
); ))}
})} </div>
</div> </section>
</section> )}
<section> {person.filmography && person.filmography.length > 0 && (
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3"> <section>
<Film className="text-[#6d28d9]" /> <div className="flex items-center justify-between mb-6">
Filmography <h2 className="text-3xl font-black text-foreground flex items-center gap-3">
</h2> <Film className="text-[#6d28d9]" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> Filmography
{relatedMedia.map(media => ( </h2>
<div <div className="flex items-center gap-2">
key={media.id} <Button
onClick={() => onMediaClick(media.id)} variant="outline"
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" size="sm"
> onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 shadow-sm"> className="rounded-xl border-border hover:border-[#6d28d9]/50 transition-all duration-300"
<img >
src={media.poster} <ListFilter size={16} />
alt={media.title} </Button>
className="w-full h-full object-cover" <select
referrerPolicy="no-referrer" value={sortBy}
/> onChange={(e) => setSortBy(e.target.value as 'year' | 'title' | 'role')}
</div> 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"
<div className="min-w-0"> >
<h4 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors"> <option value="year">Year</option>
{media.title} <option value="title">Title</option>
</h4> <option value="role">Role</option>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-1"> </select>
{media.year} </div>
</p> </div>
<div className="flex items-center gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-zinc-200"> {sortedFilmography.map(item => (
{person.role} <div
</Badge> key={item.id}
onClick={() => handleMediaClick(item.id.toString())}
className="group flex items-center gap-4 p-4 rounded-2xl bg-card border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300 cursor-pointer"
>
<div className="w-16 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border border-border/30">
<img
src={item.poster || person.photo}
alt={item.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<h4 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
{item.title}
</h4>
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1">
{item.year || 'Unknown'}
</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-border/50">
{item.role}
</Badge>
{item.category && (
<Badge variant="secondary" className="text-[10px] font-bold py-0 h-5 bg-muted text-muted-foreground border-none">
{item.category}
</Badge>
)}
</div>
</div> </div>
</div> </div>
</div> ))}
))} </div>
</div> </section>
</section> )}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,36 +1,172 @@
import { Staff } from '@/types'; import { Staff, MediaCategory } from '@/types';
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight } from 'lucide-react'; import { useNavigate } from 'react-router-dom';
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; 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 { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { fetchAllCast } from '@/api';
interface CastViewProps { interface CastViewProps {
staffList: Staff[];
onPersonClick: (person: Staff) => void; onPersonClick: (person: Staff) => void;
enabledCategories: MediaCategory[];
itemsPerPage?: number;
} }
export default function CastView({ staffList, onPersonClick }: CastViewProps) { export default function CastView({ onPersonClick, enabledCategories, itemsPerPage: initialItemsPerPage = 12 }: CastViewProps) {
const [searchQuery, setSearchQuery] = useState(''); const navigate = useNavigate();
const [sortBy, setSortBy] = useState<'name' | 'role'>('name'); const [staffList, setStaffList] = useState<Staff[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(() => {
return localStorage.getItem('castSearchQuery') || '';
});
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height' | 'roleCount'>(() => {
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height' | 'roleCount') || 'roleCount';
});
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
});
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
return localStorage.getItem('castFilterOccupation') || '';
});
const [filterMediaType, setFilterMediaType] = useState<string>(() => {
return localStorage.getItem('castFilterMediaType') || '';
});
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(12); const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
const [showFilters, setShowFilters] = useState(false);
// Sync itemsPerPage with prop when API settings are loaded
useEffect(() => {
if (initialItemsPerPage) {
setItemsPerPage(initialItemsPerPage);
}
}, [initialItemsPerPage]);
// Persist filters and sorts
useEffect(() => {
localStorage.setItem('castSearchQuery', searchQuery);
}, [searchQuery]);
useEffect(() => {
localStorage.setItem('castSortBy', sortBy);
}, [sortBy]);
useEffect(() => {
localStorage.setItem('castSortOrder', sortOrder);
}, [sortOrder]);
useEffect(() => {
localStorage.setItem('castFilterOccupation', filterOccupation);
}, [filterOccupation]);
useEffect(() => {
localStorage.setItem('castFilterMediaType', filterMediaType);
}, [filterMediaType]);
const handleResetFilters = () => {
setSearchQuery('');
setSortBy('roleCount');
setSortOrder('desc');
setFilterOccupation('');
setFilterMediaType('');
};
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
useEffect(() => {
const loadCast = async () => {
try {
const cast = await fetchAllCast();
setStaffList(cast);
} catch (error) {
console.error('Failed to load cast:', error);
} finally {
setLoading(false);
}
};
loadCast();
}, []);
const filteredStaff = useMemo(() => { const filteredStaff = useMemo(() => {
let list = staffList.filter(s => let list = staffList.filter(s => {
s.name.toLowerCase().includes(searchQuery.toLowerCase()) || // Hide actors without linked media
s.role.toLowerCase().includes(searchQuery.toLowerCase()) || if (!s.filmography || s.filmography.length === 0) {
s.mediaTitle?.toLowerCase().includes(searchQuery.toLowerCase()) return false;
); }
return list.sort((a, b) => a[sortBy].localeCompare(b[sortBy])); // Filter by enabled categories based on media_types
}, [staffList, searchQuery, sortBy]); if (s.media_types && s.media_types.length > 0) {
const hasEnabledMediaType = s.media_types.some(type => {
const category = type.charAt(0).toUpperCase() + type.slice(1);
return enabledCategories.includes(category as MediaCategory);
});
if (!hasEnabledMediaType) {
return false;
}
}
// Filter by occupation
if (filterOccupation && !s.occupations?.includes(filterOccupation)) {
return false;
}
// Filter by media type
if (filterMediaType && !s.media_types?.includes(filterMediaType)) {
return false;
}
return s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.role.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.mediaTitle?.toLowerCase().includes(searchQuery.toLowerCase());
});
// Sort
list.sort((a, b) => {
let comparison = 0;
if (sortBy === 'name' || sortBy === 'role') {
comparison = a[sortBy].localeCompare(b[sortBy]);
} else if (sortBy === 'birthDate') {
const dateA = a.birthDate ? new Date(a.birthDate).getTime() : 0;
const dateB = b.birthDate ? new Date(b.birthDate).getTime() : 0;
comparison = dateA - dateB;
} else if (sortBy === 'height') {
const heightA = a.height || 0;
const heightB = b.height || 0;
comparison = heightA - heightB;
} else if (sortBy === 'roleCount') {
const roleCountA = a.filmography?.length || 0;
const roleCountB = b.filmography?.length || 0;
comparison = roleCountA - roleCountB;
}
return sortOrder === 'desc' ? -comparison : comparison;
});
return list;
}, [staffList, searchQuery, sortBy, sortOrder, filterOccupation, filterMediaType, enabledCategories]);
// Get unique occupations and media types for filters
const uniqueOccupations = useMemo(() => {
const occupations = new Set<string>();
staffList.forEach(s => s.occupations?.forEach(o => occupations.add(o)));
return Array.from(occupations).sort();
}, [staffList]);
const uniqueMediaTypes = useMemo(() => {
const mediaTypes = new Set<string>();
staffList.forEach(s => s.media_types?.forEach(m => mediaTypes.add(m)));
return Array.from(mediaTypes).sort();
}, [staffList]);
// Reset to first page when filters or sorting change // Reset to first page when filters or sorting change
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [searchQuery, sortBy, itemsPerPage]); }, [searchQuery, sortBy, sortOrder, filterOccupation, filterMediaType, itemsPerPage]);
const totalPages = Math.ceil(filteredStaff.length / itemsPerPage); const totalPages = Math.ceil(filteredStaff.length / itemsPerPage);
@@ -50,54 +186,165 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
}; };
return ( return (
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto"> <div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
<div> <div>
<h1 className="text-4xl font-black text-zinc-900 mb-2">Cast & Staff</h1> <h1 className="text-5xl font-black text-foreground mb-3 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
<p className="text-zinc-500 font-medium">Discover the people behind your favorite media</p> Cast & Staff
</h1>
<p className="text-muted-foreground font-medium text-lg">Discover the people behind your favorite media</p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
<Input <Input
placeholder="Search cast..." placeholder="Search cast..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 w-full md:w-[300px] bg-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> </div>
<Button
variant={showFilters ? 'default' : 'outline'}
size="icon"
className={`rounded-xl h-11 w-11 transition-all duration-300 ${showFilters ? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white border-[#6d28d9]' : 'border-border hover:border-[#6d28d9]/50'}`}
onClick={() => setShowFilters(!showFilters)}
>
<Filter size={20} />
</Button>
<Button <Button
variant="outline" variant="outline"
size="icon" 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={() => setSortBy(prev => prev === 'name' ? 'role' : 'name')} onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
> >
<ArrowUpDown size={20} /> <ArrowUpDown size={20} />
</Button> </Button>
{hasActiveFilters && (
<Button
variant="ghost"
size="icon"
className="rounded-xl h-11 w-11 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-300"
onClick={handleResetFilters}
title="Reset filters"
>
<X size={20} />
</Button>
)}
</div> </div>
</div> </div>
{filteredStaff.length === 0 ? ( {showFilters && (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400"> <motion.div
<User size={48} className="mb-4 opacity-20" /> initial={{ opacity: 0, height: 0 }}
<p className="text-lg font-bold">No cast members found</p> animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 mb-6 border border-border/50"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="text-sm font-bold text-foreground mb-2 block">Sort By</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="name">Name</option>
<option value="role">Role</option>
<option value="birthDate">Birth Date</option>
<option value="height">Height</option>
<option value="roleCount">Role Count</option>
</select>
</div>
<div>
<label className="text-sm font-bold text-foreground mb-2 block">Occupation</label>
<select
value={filterOccupation}
onChange={(e) => setFilterOccupation(e.target.value)}
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="">All Occupations</option>
{uniqueOccupations.map(occ => (
<option key={occ} value={occ}>{occ}</option>
))}
</select>
</div>
<div>
<label className="text-sm font-bold text-foreground mb-2 block">Media Type</label>
<select
value={filterMediaType}
onChange={(e) => setFilterMediaType(e.target.value)}
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
>
<option value="">All Media Types</option>
{uniqueMediaTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
{searchQuery && (
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Search: {searchQuery}
<button onClick={() => setSearchQuery('')} className="hover:text-foreground">
<X size={12} />
</button>
</Badge>
)}
{filterOccupation && (
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Occupation: {filterOccupation}
<button onClick={() => setFilterOccupation('')} className="hover:text-foreground">
<X size={12} />
</button>
</Badge>
)}
{filterMediaType && (
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Media Type: {filterMediaType}
<button onClick={() => setFilterMediaType('')} className="hover:text-foreground">
<X size={12} />
</button>
</Badge>
)}
{(sortBy !== 'name' || sortOrder !== 'asc') && (
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
Sort: {sortBy} ({sortOrder})
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-foreground">
<X size={12} />
</button>
</Badge>
)}
</div>
</motion.div>
)}
{loading ? (
<Loading message="Loading cast..." />
) : filteredStaff.length === 0 ? (
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-sm border border-border/50">
<User size={40} />
</div>
<p className="text-xl font-bold">No cast members found</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{paginatedStaff.map((person) => ( {paginatedStaff.map((person) => (
<motion.div <motion.div
key={`${person.id}-${person.mediaId}`} key={person.id}
layout layout
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, scale: 0.9 }}
className="group bg-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)} onClick={() => onPersonClick(person)}
> >
<div className="flex items-center gap-4 mb-4"> <div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-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 <img
src={person.photo} src={person.photo}
alt={person.name} alt={person.name}
@@ -105,31 +352,38 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</div> </div>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<h3 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors"> <h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
{person.name} {person.name}
</h3> </h3>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider"> <p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{person.role} {person.role}
</p> </p>
</div> </div>
{person.filmography && person.filmography.length > 0 && (
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold text-[10px] px-2 py-0.5 shrink-0">
{person.filmography.length}
</Badge>
)}
</div> </div>
<div className="bg-zinc-50 rounded-xl p-3 flex items-center gap-3"> {person.filmography && person.filmography.length > 0 && (
<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">
<img <div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background border border-border/30">
src={person.characterImage} <img
alt={person.characterName} src={person.filmography[0].poster || person.photo}
className="w-full h-full object-contain" alt={person.filmography[0].title}
referrerPolicy="no-referrer" className="w-full h-full object-cover"
/> referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest leading-none mb-1">Latest Role</p>
<p className="text-xs font-bold text-foreground truncate">{person.filmography[0].title}</p>
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">{person.filmography[0].role}</p>
</div>
</div> </div>
<div className="min-w-0"> )}
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest leading-none mb-1">Character</p>
<p className="text-xs font-bold text-zinc-700 truncate">{person.characterName}</p>
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">in {person.mediaTitle}</p>
</div>
</div>
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
@@ -138,17 +392,17 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
{/* Pagination Controls */} {/* Pagination Controls */}
{filteredStaff.length > 0 && ( {filteredStaff.length > 0 && (
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-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"> <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 <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => { onChange={(e) => {
setItemsPerPage(Number(e.target.value)); 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> <option key={size} value={size}>{size}</option>
))} ))}
</select> </select>
@@ -160,7 +414,7 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
size="sm" size="sm"
onClick={handlePrevPage} onClick={handlePrevPage}
disabled={currentPage === 1} 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} /> <ChevronLeft size={16} />
Previous Previous
@@ -168,8 +422,8 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span> <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 text-muted-foreground font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span> <span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
</div> </div>
<Button <Button
@@ -177,7 +431,7 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
size="sm" size="sm"
onClick={handleNextPage} onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0} 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 Next
<ChevronRight size={16} /> <ChevronRight size={16} />

View File

@@ -0,0 +1,279 @@
import { Media, MediaCategory } from '@/types';
import MediaCard from './MediaCard';
import { Film, Tv, Music, Book, Gamepad2, Users, Star, TrendingUp, Clock, Hash, Play, Award } from 'lucide-react';
import { useMemo } from 'react';
import { motion } from 'motion/react';
import Loading from '@/components/ui/loading';
interface DashboardViewProps {
mediaList: Media[];
onMediaClick: (media: Media) => void;
loading?: boolean;
}
export default function DashboardView({ mediaList, onMediaClick, loading = false }: DashboardViewProps) {
// Calculate statistics
const stats = useMemo(() => {
const totalMedia = mediaList.length;
const categories = mediaList.reduce((acc, media) => {
acc[media.category] = (acc[media.category] || 0) + 1;
return acc;
}, {} as Record<MediaCategory, number>);
const totalRating = mediaList.reduce((sum, media) => sum + (media.rating || 0), 0);
const avgRating = totalRating > 0 ? (totalRating / mediaList.filter(m => m.rating).length).toFixed(1) : '0.0';
const totalPlaytime = mediaList.reduce((sum, media) => sum + (media.playtime || 0), 0);
const totalPlayCount = mediaList.reduce((sum, media) => sum + (media.playCount || 0), 0);
return {
totalMedia,
categories,
avgRating,
totalPlaytime,
totalPlayCount
};
}, [mediaList]);
// Get recently added media (sorted by some indicator - using index as proxy)
const recentMedia = useMemo(() => {
return [...mediaList].slice(0, 8);
}, [mediaList]);
// Get top rated media
const topRatedMedia = useMemo(() => {
return [...mediaList]
.filter(m => m.rating && m.rating > 0)
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
.slice(0, 8);
}, [mediaList]);
// Get most played media
const mostPlayedMedia = useMemo(() => {
return [...mediaList]
.filter(m => m.playCount && m.playCount > 0)
.sort((a, b) => (b.playCount || 0) - (a.playCount || 0))
.slice(0, 8);
}, [mediaList]);
// Category icons mapping
const categoryIcons: Record<MediaCategory, any> = {
'Anime': Tv,
'Movies': Film,
'TV Series': Tv,
'Music': Music,
'Books': Book,
'Games': Gamepad2,
'Consoles': Gamepad2,
'Adult': Users
};
// Category colors
const categoryColors: Record<MediaCategory, string> = {
'Anime': 'bg-purple-500/10 text-purple-500 border-purple-500/20',
'Movies': 'bg-blue-500/10 text-blue-500 border-blue-500/20',
'TV Series': 'bg-green-500/10 text-green-500 border-green-500/20',
'Music': 'bg-pink-500/10 text-pink-500 border-pink-500/20',
'Books': 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
'Games': 'bg-red-500/10 text-red-500 border-red-500/20',
'Consoles': 'bg-orange-500/10 text-orange-500 border-orange-500/20',
'Adult': 'bg-gray-500/10 text-gray-500 border-gray-500/20'
};
const formatPlaytime = (minutes: number) => {
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
if (loading) {
return <Loading message="Loading dashboard..." />;
}
return (
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
{/* Header */}
<div className="mb-10">
<h1 className="text-5xl font-black text-foreground mb-3 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
Dashboard
</h1>
<p className="text-muted-foreground font-medium text-lg">Overview of your media collection</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-[#6d28d9]/10 to-[#8b5cf6]/5 border border-[#6d28d9]/20 hover:border-[#6d28d9]/40 transition-all duration-300 hover:shadow-lg hover:shadow-[#6d28d9]/10"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-[#6d28d9]/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="relative">
<div className="flex items-center justify-between mb-4">
<Hash className="w-10 h-10 text-[#6d28d9]" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
</div>
<div className="text-4xl font-black text-foreground">{stats.totalMedia}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Media Items</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-yellow-500/10 to-amber-500/5 border border-yellow-500/20 hover:border-yellow-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-yellow-500/10"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-yellow-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="relative">
<div className="flex items-center justify-between mb-4">
<Star className="w-10 h-10 text-yellow-500" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Average</span>
</div>
<div className="text-4xl font-black text-foreground">{stats.avgRating}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Rating</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-green-500/10 to-emerald-500/5 border border-green-500/20 hover:border-green-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-green-500/10"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-green-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="relative">
<div className="flex items-center justify-between mb-4">
<Play className="w-10 h-10 text-green-500" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
</div>
<div className="text-4xl font-black text-foreground">{stats.totalPlayCount}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Play Count</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-blue-500/10 to-cyan-500/5 border border-blue-500/20 hover:border-blue-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/10"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="relative">
<div className="flex items-center justify-between mb-4">
<Clock className="w-10 h-10 text-blue-500" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
</div>
<div className="text-4xl font-black text-foreground">{formatPlaytime(stats.totalPlaytime)}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Playtime</div>
</div>
</motion.div>
</div>
{/* Category Breakdown */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="relative overflow-hidden rounded-2xl p-8 bg-gradient-to-br from-muted/50 to-muted/30 border border-border mb-10"
>
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
<TrendingUp className="w-6 h-6 text-[#6d28d9]" />
Category Breakdown
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-4">
{(Object.keys(stats.categories) as MediaCategory[]).map((category) => {
const Icon = categoryIcons[category];
const count = stats.categories[category] || 0;
const percentage = stats.totalMedia > 0 ? ((count / stats.totalMedia) * 100).toFixed(1) : '0';
return (
<div
key={category}
className={`rounded-xl p-5 border backdrop-blur-sm transition-all duration-300 hover:scale-105 hover:shadow-lg ${categoryColors[category]} flex flex-col items-center justify-center gap-2`}
>
<Icon className="w-7 h-7" />
<div className="text-xs font-bold uppercase tracking-wider">{category}</div>
<div className="text-3xl font-black">{count}</div>
<div className="text-xs font-medium opacity-75">{percentage}%</div>
</div>
);
})}
</div>
</motion.div>
{/* Recent Media */}
{recentMedia.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="mb-10"
>
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
<Clock className="w-6 h-6 text-[#6d28d9]" />
Recent Additions
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
{recentMedia.map((media) => (
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
))}
</div>
</motion.div>
)}
{/* Top Rated Media */}
{topRatedMedia.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
className="mb-10"
>
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
<Award className="w-6 h-6 text-[#6d28d9]" />
Top Rated
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
{topRatedMedia.map((media) => (
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
))}
</div>
</motion.div>
)}
{/* Most Played Media */}
{mostPlayedMedia.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
className="mb-10"
>
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
<Play className="w-6 h-6 text-[#6d28d9]" />
Most Played
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
{mostPlayedMedia.map((media) => (
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
))}
</div>
</motion.div>
)}
{/* Empty State */}
{mediaList.length === 0 && (
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-sm border border-border/50">
<Hash size={40} />
</div>
<p className="text-xl font-bold">No media found</p>
<p className="text-sm">Start by adding media to your collection</p>
</div>
)}
</div>
);
}

View File

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

View File

@@ -1,12 +1,11 @@
import { Search, User, X } from 'lucide-react'; import { Search, User, X, Plus, Download, Settings, Menu } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Link, NavLink, useLocation } from 'react-router-dom';
import { MediaCategory } from '@/types'; import { MediaCategory } from '@/types';
import LibrarySettings from './LibrarySettings'; import { useTheme } from '@/contexts/ThemeContext';
interface HeaderProps { interface HeaderProps {
onBrowse: () => void;
onCast: () => void;
onSearch: (query: string) => void; onSearch: (query: string) => void;
activeCategory: MediaCategory; activeCategory: MediaCategory;
onCategoryChange: (category: MediaCategory) => void; onCategoryChange: (category: MediaCategory) => void;
@@ -16,8 +15,6 @@ interface HeaderProps {
} }
export default function Header({ export default function Header({
onBrowse,
onCast,
onSearch, onSearch,
activeCategory, activeCategory,
onCategoryChange, onCategoryChange,
@@ -27,6 +24,31 @@ export default function Header({
}: HeaderProps) { }: HeaderProps) {
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); 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 handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value; const query = e.target.value;
@@ -45,67 +67,156 @@ export default function Header({
return ( return (
<header <header
className={cn( className={cn(
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-300", "fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-500",
transparent ? "bg-transparent" : "bg-[#6d28d9]" transparent && !scrolled
? "bg-transparent"
: transparent && scrolled
? "backdrop-blur-xl bg-background/70 border-b border-border/30"
: "backdrop-blur-xl bg-gradient-to-r from-[#6d28d9]/90 via-[#8b5cf6]/90 to-[#6d28d9]/90 border-b border-white/10"
)} )}
> >
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div <Link
className="text-2xl font-black text-white cursor-pointer flex items-center gap-1" to="/"
onClick={onBrowse} 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={cn(
<div className="w-3 h-3 bg-[#6d28d9] rounded-full" /> "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> </div>
kyoo <span className="bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
</div> omnyx
<nav className="hidden md:flex items-center gap-6"> </span>
</Link>
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className={cn(
"md:hidden p-2 rounded-lg transition-all duration-300 hover:bg-white/10",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Menu size={20} />
</button>
<nav className="hidden md:flex items-center gap-1">
{enabledCategories.map(cat => ( {enabledCategories.map(cat => (
<button <NavLink
key={cat} key={cat}
onClick={() => onCategoryChange(cat)} to={`/${categoryPaths[cat]}`}
className={cn( className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider", "text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg relative",
activeCategory === cat ? "text-white" : "text-white/60 hover:text-white" (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} {cat}
</button> </NavLink>
))} ))}
<div className="w-px h-4 bg-white/20 mx-2" /> <div className={cn(
<button "w-px h-6 mx-2",
onClick={onCast} (transparent && !scrolled) || !transparent ? "bg-white/20" : "bg-border"
className="text-sm font-bold text-white/60 hover:text-white transition-colors uppercase tracking-wider" )} />
<NavLink
to="/cast"
className={({ isActive }) => cn(
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg",
(transparent && !scrolled) || !transparent
? isActive ? "text-white bg-white/10" : "text-white/70 hover:text-white hover:bg-white/5"
: isActive ? "text-foreground bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
> >
CAST CAST
</button> </NavLink>
</nav> </nav>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-2">
<div className={cn( <div className={cn(
"flex items-center transition-all duration-300 overflow-hidden", "flex items-center transition-all duration-300 overflow-hidden rounded-2xl",
isSearchOpen ? "w-48 md:w-64 bg-white/10 rounded-full px-3 py-1" : "w-0" isSearchOpen ? "w-48 md:w-72 px-4 py-2.5" : "w-0",
(transparent && !scrolled) || !transparent
? "bg-white/10 backdrop-blur-md border border-white/20"
: "bg-muted/50 backdrop-blur-md border border-border"
)}> )}>
<input <input
type="text" type="text"
placeholder="Search..." placeholder="Search..."
value={searchQuery} value={searchQuery}
onChange={handleSearchChange} 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} autoFocus={isSearchOpen}
/> />
</div> </div>
<button <button
onClick={toggleSearch} 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> </button>
<LibrarySettings <Link
enabledCategories={enabledCategories} to="/add"
onToggleCategory={onToggleCategory} className={cn(
/> "p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
<button className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20"> (transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Plus size={18} />
</Link>
<Link
to="/import"
className={cn(
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Download size={18} />
</Link>
<Link
to="/settings"
className={cn(
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Settings size={18} />
</Link>
<button className={cn(
"w-9 h-9 rounded-xl overflow-hidden border-2 transition-all duration-300 hover:scale-110 hover:shadow-lg",
(transparent && !scrolled) || !transparent
? "border-white/30 hover:border-white/50"
: "border-border hover:border-[#6d28d9]/50"
)}>
<img <img
src="https://picsum.photos/seed/user/100/100" src="https://picsum.photos/seed/user/100/100"
alt="User" alt="User"
@@ -114,6 +225,38 @@ export default function Header({
/> />
</button> </button>
</div> </div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden absolute top-full left-0 right-0 bg-background border-b border-border shadow-lg">
<nav className="flex flex-col p-4 gap-2">
{enabledCategories.map(cat => (
<NavLink
key={cat}
to={`/${categoryPaths[cat]}`}
onClick={() => setIsMobileMenuOpen(false)}
className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
isActive ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{cat}
</NavLink>
))}
<div className="w-full h-px bg-border my-2" />
<NavLink
to="/cast"
onClick={() => setIsMobileMenuOpen(false)}
className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
isActive ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
CAST
</NavLink>
</nav>
</div>
)}
</header> </header>
); );
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -44,11 +44,11 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
className="group flex items-center gap-6 p-4 rounded-xl hover:bg-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)} onClick={() => onClick(media)}
> >
<div className={cn( <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 aspectRatioClass
)}> )}>
<img <img
@@ -57,6 +57,7 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
className="w-full h-full object-cover" className="w-full h-full object-cover"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
{media.status && ( {media.status && (
<div className={cn( <div className={cn(
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm", "absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
@@ -67,32 +68,32 @@ export default function MediaListItem({ media, onClick }: MediaListItemProps) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-black text-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} {media.title}
</h3> </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>
<div className="flex items-center gap-4 mb-3"> <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" /> <Star size={14} className="text-yellow-500" fill="currentColor" />
{media.rating || 'N/A'} {media.rating || 'N/A'}
</div> </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'} {media.genres?.slice(0, 3).join(' • ') || 'Anime'}
</div> </div>
</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."} {media.description || "No description available for this title."}
</p> </p>
</div> </div>
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
<Button size="icon" variant="ghost" className="rounded-full text-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" /> <Play size={18} fill="currentColor" />
</Button> </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} /> <Bookmark size={18} />
</Button> </Button>
</div> </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,14 @@ export const MOCK_MEDIA: Media[] = [
studios: ['Example Studio'], studios: ['Example Studio'],
} }
]; ];
export const DETAIL_MEDIA: Media = {
id: '',
title: '',
year: '',
poster: '',
category: 'Movies'
}
/*
export const DETAIL_MEDIA: Media = { export const DETAIL_MEDIA: Media = {
id: 'mob-psycho', id: 'mob-psycho',
title: 'Mob Psycho 100', title: 'Mob Psycho 100',
@@ -220,3 +227,4 @@ export const DETAIL_MEDIA: Media = {
}, },
] ]
}; };
*/

View File

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

View File

@@ -0,0 +1,453 @@
/**
* Tests for Jellyfin Importer
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { importFromJellyfin, fetchJellyfinLibraries, JellyfinConfig, JellyfinImportOptions, ImportProgress } from '../jellyfinImporter';
// Mock global fetch
global.fetch = vi.fn();
describe('jellyfinImporter', () => {
const mockConfig: JellyfinConfig = {
url: 'http://localhost:8096',
apiKey: 'test-api-key'
};
const mockOptions: JellyfinImportOptions = {
importMovies: true,
importSeries: true,
importMusic: false,
importCast: false,
updateExisting: false
};
const mockLogCallback = vi.fn();
const mockProgressCallback = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetch).mockClear();
});
describe('fetchJellyfinLibraries', () => {
it('should successfully fetch libraries from Jellyfin', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{ Id: 'lib-1', Name: 'Movies', Type: 'CollectionFolder', CollectionType: 'movies' },
{ Id: 'lib-2', Name: 'TV Shows', Type: 'CollectionFolder', CollectionType: 'tvshows' }
],
TotalRecordCount: 2
})
} as Response);
const libraries = await fetchJellyfinLibraries(mockConfig);
expect(libraries).toHaveLength(2);
expect(libraries[0].Name).toBe('Movies');
expect(libraries[1].Name).toBe('TV Shows');
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
await expect(fetchJellyfinLibraries(mockConfig)).rejects.toThrow('Connection failed');
});
it('should handle API response errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized'
} as Response);
await expect(fetchJellyfinLibraries(mockConfig)).rejects.toThrow('Failed to fetch libraries from Jellyfin: Unauthorized');
});
it('should handle empty library list', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ Items: [], TotalRecordCount: 0 })
} as Response);
const libraries = await fetchJellyfinLibraries(mockConfig);
expect(libraries).toHaveLength(0);
});
});
describe('importFromJellyfin', () => {
it('should successfully import movies from Jellyfin', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ([{ Id: 'user-1' }])
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{
Id: 'movie-1',
Name: 'Test Movie',
Type: 'Movie',
ProductionYear: 2024,
CommunityRating: 8.5,
Overview: 'A test movie',
Genres: ['Action'],
Studios: [{ Name: 'Test Studio', Id: 'studio-1' }],
People: [
{ Name: 'Actor 1', Type: 'Actor' },
{ Name: 'Director 1', Type: 'Director' }
],
ImageTags: { Primary: 'tag-1' }
}
],
TotalRecordCount: 1
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromJellyfin(
mockConfig,
mockOptions,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.moviesImported).toBe(1);
expect(result.errors).toHaveLength(0);
expect(mockLogCallback).toHaveBeenCalledWith('Starting Jellyfin import...');
});
it('should successfully import series from Jellyfin', async () => {
const seriesOptions: JellyfinImportOptions = {
...mockOptions,
importMovies: false,
importSeries: true
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ([{ Id: 'user-1' }])
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{
Id: 'series-1',
Name: 'Test Series',
Type: 'Series',
ProductionYear: 2024,
CommunityRating: 9.0,
Overview: 'A test series',
Genres: ['Drama'],
Studios: [{ Name: 'Test Studio', Id: 'studio-1' }],
People: [
{ Name: 'Actor 1', Type: 'Actor' }
],
ImageTags: { Primary: 'tag-1' }
}
],
TotalRecordCount: 1
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: []
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromJellyfin(
mockConfig,
seriesOptions,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.seriesImported).toBe(1);
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await importFromJellyfin(
mockConfig,
mockOptions,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Connection failed');
});
it('should skip existing items when updateExisting is false', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'media-1', title: 'Test Movie' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ([{ Id: 'user-1' }])
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{
Id: 'movie-1',
Name: 'Test Movie',
Type: 'Movie'
}
],
TotalRecordCount: 1
})
} as Response);
const result = await importFromJellyfin(
mockConfig,
mockOptions,
mockLogCallback,
mockProgressCallback
);
expect(result.moviesImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped movie: Test Movie (already exists, updateExisting is false)');
});
it('should update existing items when updateExisting is true', async () => {
const updateOptions: JellyfinImportOptions = {
...mockOptions,
updateExisting: true
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'media-1', title: 'Test Movie' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ([{ Id: 'user-1' }])
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{
Id: 'movie-1',
Name: 'Test Movie',
Type: 'Movie'
}
],
TotalRecordCount: 1
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromJellyfin(
mockConfig,
updateOptions,
mockLogCallback,
mockProgressCallback
);
expect(result.moviesImported).toBe(1);
});
it('should respect library mappings and skip libraries marked as skip', async () => {
const optionsWithMapping: JellyfinImportOptions = {
...mockOptions,
libraryMappings: [
{ libraryName: 'Movies', category: 'skip' }
]
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ([{ Id: 'user-1' }])
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{
Id: 'movie-1',
Name: 'Test Movie',
Type: 'Movie',
ParentId: 'lib-1'
}
],
TotalRecordCount: 1
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
Items: [
{ Id: 'lib-1', Name: 'Movies', Type: 'CollectionFolder', CollectionType: 'movies' }
]
})
} as Response);
const result = await importFromJellyfin(
mockConfig,
optionsWithMapping,
mockLogCallback,
mockProgressCallback
);
expect(result.moviesImported).toBe(0);
});
});
describe('JellyfinConfig', () => {
it('should accept valid configuration', () => {
const config: JellyfinConfig = {
url: 'http://localhost:8096',
apiKey: 'test-api-key'
};
expect(config.url).toBe('http://localhost:8096');
expect(config.apiKey).toBe('test-api-key');
});
});
describe('JellyfinImportOptions', () => {
it('should accept valid options', () => {
const options: JellyfinImportOptions = {
importMovies: true,
importSeries: true,
importMusic: false,
importCast: false,
limit: 100,
updateExisting: false
};
expect(options.importMovies).toBe(true);
expect(options.importSeries).toBe(true);
expect(options.importMusic).toBe(false);
expect(options.importCast).toBe(false);
expect(options.limit).toBe(100);
expect(options.updateExisting).toBe(false);
});
it('should accept library mappings', () => {
const options: JellyfinImportOptions = {
libraryMappings: [
{ libraryName: 'Movies', category: 'Movies' },
{ libraryName: 'TV Shows', category: 'TV Series' },
{ libraryName: 'Anime', category: 'Anime' },
{ libraryName: 'Music', category: 'Music' },
{ libraryName: 'Unwanted', category: 'skip' }
]
};
expect(options.libraryMappings).toHaveLength(5);
expect(options.libraryMappings![4].category).toBe('skip');
});
});
describe('ImportProgress', () => {
it('should have correct structure', () => {
const progress: ImportProgress = {
current: 5,
total: 10,
stage: 'importing',
message: 'Importing...',
moviesImported: 3,
seriesImported: 2,
musicImported: 0,
castImported: 5,
errors: []
};
expect(progress.current).toBe(5);
expect(progress.total).toBe(10);
expect(progress.stage).toBe('importing');
expect(progress.moviesImported).toBe(3);
expect(progress.seriesImported).toBe(2);
expect(progress.musicImported).toBe(0);
expect(progress.castImported).toBe(5);
expect(progress.errors).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,364 @@
/**
* Tests for Playnite Importer
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { importFromPlaynite, PlayniteConfig, ImportProgress } from '../playniteImporter';
// Mock global fetch
global.fetch = vi.fn();
describe('playniteImporter', () => {
const mockConfig: PlayniteConfig = {
ip: '192.168.1.100',
apiToken: 'test-token',
port: 19821,
updateExisting: false
};
const mockLogCallback = vi.fn();
const mockProgressCallback = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetch).mockClear();
});
describe('importFromPlaynite', () => {
it('should successfully import games from Playnite', async () => {
// Mock existing media check
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
// Mock games list fetch
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 1,
offset: 0,
limit: 5000,
games: [
{
id: 'game-1',
name: 'Test Game',
description: 'A test game',
genres: ['Action'],
developers: ['Test Dev'],
publishers: ['Test Pub'],
releaseDate: '2024-01-01'
}
]
})
} as Response);
// Mock game detail fetch
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'game-1',
name: 'Test Game',
description: 'A test game',
genres: ['Action'],
developers: ['Test Dev'],
publishers: ['Test Pub'],
releaseDate: '2024-01-01'
})
} as Response);
// Mock media creation
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.gamesImported).toBe(1);
expect(result.errors).toHaveLength(0);
expect(mockLogCallback).toHaveBeenCalledWith('Starting Playnite import...');
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Connection failed');
});
it('should handle API response errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized'
} as Response);
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Failed to connect to Playnite API: Unauthorized');
});
it('should skip existing games when updateExisting is false', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'media-1', title: 'Test Game' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 1,
offset: 0,
limit: 5000,
games: [
{
id: 'game-1',
name: 'Test Game',
description: 'A test game'
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'game-1',
name: 'Test Game',
description: 'A test game'
})
} as Response);
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.gamesImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped game: Test Game (already exists, updateExisting is false)');
});
it('should update existing games when updateExisting is true', async () => {
const configWithUpdate: PlayniteConfig = {
...mockConfig,
updateExisting: true
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'media-1', title: 'Test Game' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 1,
offset: 0,
limit: 5000,
games: [
{
id: 'game-1',
name: 'Test Game',
description: 'A test game'
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'game-1',
name: 'Test Game',
description: 'A test game'
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromPlaynite(
configWithUpdate,
mockLogCallback,
mockProgressCallback
);
expect(result.gamesImported).toBe(1);
});
it('should convert ratings from 0-100 scale to 0-5 scale', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 1,
offset: 0,
limit: 5000,
games: [
{
id: 'game-1',
name: 'Test Game',
userScore: 80,
communityScore: 90,
criticScore: 85
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'game-1',
name: 'Test Game',
userScore: 80,
communityScore: 90,
criticScore: 85
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.gamesImported).toBe(1);
});
it('should convert playtime from seconds to minutes', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 1,
offset: 0,
limit: 5000,
games: [
{
id: 'game-1',
name: 'Test Game',
playtime: 3600 // 1 hour in seconds
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'game-1',
name: 'Test Game',
playtime: 3600
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromPlaynite(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.gamesImported).toBe(1);
});
});
describe('PlayniteConfig', () => {
it('should accept valid configuration', () => {
const config: PlayniteConfig = {
ip: '192.168.1.100',
apiToken: 'test-token'
};
expect(config.ip).toBe('192.168.1.100');
expect(config.apiToken).toBe('test-token');
expect(config.port).toBeUndefined();
expect(config.updateExisting).toBeUndefined();
});
it('should accept configuration with optional fields', () => {
const config: PlayniteConfig = {
ip: '192.168.1.100',
apiToken: 'test-token',
port: 19821,
updateExisting: true
};
expect(config.port).toBe(19821);
expect(config.updateExisting).toBe(true);
});
});
describe('ImportProgress', () => {
it('should have correct structure', () => {
const progress: ImportProgress = {
current: 5,
total: 10,
stage: 'importing',
message: 'Importing...',
gamesImported: 5,
errors: []
};
expect(progress.current).toBe(5);
expect(progress.total).toBe(10);
expect(progress.stage).toBe('importing');
expect(progress.gamesImported).toBe(5);
expect(progress.errors).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,431 @@
/**
* Tests for StashAPP Importer
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { importFromStashAPP, updateActorsFromStashAPP, StashAPPConfig, ImportProgress } from '../stashappImporter';
// Mock global fetch
global.fetch = vi.fn();
describe('stashappImporter', () => {
const mockConfig: StashAPPConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key',
blacklist: ['/AI/', 'temp'],
updateExisting: false
};
const mockLogCallback = vi.fn();
const mockProgressCallback = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetch).mockClear();
});
describe('importFromStashAPP', () => {
it('should successfully import scenes and performers from StashAPP', async () => {
// Mock existing media and cast check
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
// Mock scenes fetch
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findScenes: {
scenes: [
{
id: 'scene-1',
title: 'Test Scene',
details: 'A test scene',
date: '2024-01-01',
rating100: 80,
paths: {
screenshot: 'http://example.com/screenshot.jpg'
},
files: [
{
size: 1000000,
duration: 1800,
video_codec: 'h264',
audio_codec: 'aac',
width: 1920,
height: 1080,
path: '/videos/test.mp4'
}
],
performers: []
}
],
count: 1
}
}
})
} as Response);
// Mock media creation
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.videosImported).toBe(1);
expect(result.errors).toHaveLength(0);
expect(mockLogCallback).toHaveBeenCalledWith('Starting StashAPP import...');
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Connection failed');
});
it('should handle API response errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized'
} as Response);
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Failed to connect to StashAPP: Unauthorized');
});
it('should skip blacklisted scenes', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findScenes: {
scenes: [
{
id: 'scene-1',
title: 'Test Scene',
paths: { screenshot: 'http://example.com/screenshot.jpg' },
files: [
{
path: '/videos/AI/test.mp4',
size: 1000000,
duration: 1800
}
],
performers: []
}
],
count: 1
}
}
})
} as Response);
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped blacklisted scene: Test Scene');
});
it('should convert rating from 0-100 scale to 0-5 scale', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findScenes: {
scenes: [
{
id: 'scene-1',
title: 'Test Scene',
rating100: 80,
paths: { screenshot: 'http://example.com/screenshot.jpg' },
files: [{ path: '/videos/test.mp4', size: 1000000, duration: 1800 }],
performers: []
}
],
count: 1
}
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(1);
});
it('should determine aspect ratio from file dimensions', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findScenes: {
scenes: [
{
id: 'scene-1',
title: 'Test Scene',
paths: { screenshot: 'http://example.com/screenshot.jpg' },
files: [
{
path: '/videos/test.mp4',
size: 1000000,
duration: 1800,
width: 1920,
height: 1080
}
],
performers: []
}
],
count: 1
}
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(1);
});
});
describe('updateActorsFromStashAPP', () => {
it('should successfully update actors from StashAPP', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findPerformers: {
performers: [
{
id: 'performer-1',
name: 'Test Performer',
image_path: 'http://example.com/photo.jpg',
details: 'A test performer',
birthdate: '1990-01-01',
country: 'USA'
}
],
count: 1
}
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'cast-1' })
} as Response);
const result = await updateActorsFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.actorsImported).toBe(1);
expect(result.errors).toHaveLength(0);
expect(mockLogCallback).toHaveBeenCalledWith('Starting StashAPP actor update...');
});
it('should update existing actors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'cast-1', name: 'Test Performer', photo: 'old-photo.jpg' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
findPerformers: {
performers: [
{
id: 'performer-1',
name: 'Test Performer',
image_path: 'http://example.com/new-photo.jpg',
details: 'Updated bio'
}
],
count: 1
}
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'cast-1' })
} as Response);
const result = await updateActorsFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.actorsImported).toBe(1);
expect(mockLogCallback).toHaveBeenCalledWith('✓ Updated actor: Test Performer');
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await updateActorsFromStashAPP(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Connection failed');
});
});
describe('StashAPPConfig', () => {
it('should accept valid configuration', () => {
const config: StashAPPConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key'
};
expect(config.url).toBe('http://localhost:9999');
expect(config.apiKey).toBe('test-api-key');
expect(config.blacklist).toBeUndefined();
expect(config.updateExisting).toBeUndefined();
});
it('should accept configuration with optional fields', () => {
const config: StashAPPConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key',
blacklist: ['/AI/', 'temp'],
updateExisting: true
};
expect(config.blacklist).toEqual(['/AI/', 'temp']);
expect(config.updateExisting).toBe(true);
});
});
describe('ImportProgress', () => {
it('should have correct structure', () => {
const progress: ImportProgress = {
current: 5,
total: 10,
stage: 'importing',
message: 'Importing...',
videosImported: 5,
actorsImported: 3,
errors: []
};
expect(progress.current).toBe(5);
expect(progress.total).toBe(10);
expect(progress.stage).toBe('importing');
expect(progress.videosImported).toBe(5);
expect(progress.actorsImported).toBe(3);
expect(progress.errors).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,524 @@
/**
* Tests for XBVR Importer
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { importFromXBVR, XBVRConfig, ImportProgress } from '../xbvrImporter';
// Mock global fetch
global.fetch = vi.fn();
describe('xbvrImporter', () => {
const mockConfig: XBVRConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key',
updateExisting: false
};
const mockLogCallback = vi.fn();
const mockProgressCallback = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetch).mockClear();
});
describe('importFromXBVR', () => {
it('should successfully import videos and actors from XBVR', async () => {
// Mock existing media and cast check
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
// Mock scene list fetch
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: 'Test Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
},
{
name: 'Favorites',
list: []
}
]
})
} as Response);
// Mock video detail fetch
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: 'Test Video',
description: 'A test VR video',
date: 1704067200, // 2024-01-01
thumbnailUrl: 'http://example.com/thumb.jpg',
rating_avg: 8.5,
screenType: '180',
stereoMode: 'sbs',
videoLength: 1800,
paysite: { name: 'Test Studio' },
actors: [
{ id: 1, name: 'Actor 1' },
{ id: 2, name: 'Actor 2' }
],
categories: [
{ tag: { name: 'VR' } },
{ tag: { name: '180°' } }
]
})
} as Response);
// Mock actor creation
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'cast-1' })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'cast-2' })
} as Response);
// Mock media creation
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('complete');
expect(result.videosImported).toBe(1);
expect(result.actorsImported).toBe(2);
expect(result.errors).toHaveLength(0);
expect(mockLogCallback).toHaveBeenCalledWith('Starting DeoVR import...');
});
it('should handle connection errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Connection failed');
});
it('should handle API response errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized'
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.stage).toBe('error');
expect(result.errors).toContain('Failed to connect to DeoVR API: Unauthorized');
});
it('should skip videos starting with aka:', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: 'aka: Test Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: 'aka: Test Video',
date: 1704067200,
videoLength: 1800,
actors: [],
categories: []
})
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped \'aka:\' video: aka: Test Video');
});
it('should skip actors containing aka:', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: 'Test Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: 'Test Video',
date: 1704067200,
videoLength: 1800,
actors: [
{ id: 1, name: 'Actor 1' },
{ id: 2, name: 'aka: Actor 2' }
],
categories: []
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'cast-1' })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.actorsImported).toBe(1);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped \'aka:\' actor: aka: Actor 2');
});
it('should skip existing videos when updateExisting is false', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
items: [
{ id: 'media-1', title: 'Test Video' }
]
}
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: 'Test Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: 'Test Video',
date: 1704067200,
videoLength: 1800,
actors: [],
categories: []
})
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped duplicate: Test Video (updateExisting is false)');
});
it('should determine aspect ratio based on screenType and stereoMode', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: '360 Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: '360 Video',
date: 1704067200,
videoLength: 1800,
screenType: '360',
stereoMode: 'sbs',
actors: [],
categories: []
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(1);
});
it('should convert Unix timestamp to date', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Recent',
list: [
{
title: 'Test Video',
videoLength: 1800,
thumbnailUrl: 'http://example.com/thumb.jpg',
video_url: 'http://example.com/api/video/1'
}
]
}
]
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 1,
title: 'Test Video',
date: 1704067200, // 2024-01-01
videoLength: 1800,
actors: [],
categories: []
})
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'media-1' })
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(1);
});
it('should handle missing Recent scene group', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { items: [] } })
} as Response);
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
scenes: [
{
name: 'Favorites',
list: []
}
]
})
} as Response);
const result = await importFromXBVR(
mockConfig,
mockLogCallback,
mockProgressCallback
);
expect(result.videosImported).toBe(0);
expect(result.actorsImported).toBe(0);
expect(mockLogCallback).toHaveBeenCalledWith('Found 0 videos in \'Recent\' scene group');
});
});
describe('XBVRConfig', () => {
it('should accept valid configuration', () => {
const config: XBVRConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key'
};
expect(config.url).toBe('http://localhost:9999');
expect(config.apiKey).toBe('test-api-key');
expect(config.updateExisting).toBeUndefined();
});
it('should accept configuration with optional fields', () => {
const config: XBVRConfig = {
url: 'http://localhost:9999',
apiKey: 'test-api-key',
updateExisting: true
};
expect(config.updateExisting).toBe(true);
});
});
describe('ImportProgress', () => {
it('should have correct structure', () => {
const progress: ImportProgress = {
current: 5,
total: 10,
stage: 'importing',
message: 'Importing...',
videosImported: 5,
actorsImported: 3,
errors: []
};
expect(progress.current).toBe(5);
expect(progress.total).toBe(10);
expect(progress.stage).toBe('importing');
expect(progress.videosImported).toBe(5);
expect(progress.actorsImported).toBe(3);
expect(progress.errors).toHaveLength(0);
});
});
});

163
src/lib/api/castApi.ts Normal file
View File

@@ -0,0 +1,163 @@
import { Staff, Media } from '../../types';
import { ApiResponse, PaginatedResponse, ApiCastItem, CreateCastInput, UpdateCastInput } from './types';
import { convertApiCastToStaff, convertApiToMedia } from './converters';
const BASE_URL = import.meta.env.VITE_API_URL;
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<Staff[]> {
try {
const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<ApiCastItem>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiCastToStaff);
}
return [];
} catch (error) {
console.error('Error fetching cast from API:', error);
return [];
}
}
export async function fetchCastById(id: number | string): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error fetching cast by ID:', error);
return null;
}
}
export async function fetchCastMedia(castId: number | string): Promise<Media[]> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${castId}/media`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<any>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching cast media:', error);
return [];
}
}
export async function createCast(cast: CreateCastInput): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cast),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error creating cast:', error);
return null;
}
}
export async function updateCast(id: number | string, cast: UpdateCastInput): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cast),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error updating cast:', error);
return null;
}
}
export async function deleteCast(id: number | string): Promise<boolean> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<{ message: string }> = await response.json();
return data.success;
} catch (error) {
console.error('Error deleting cast:', error);
return false;
}
}
// Legacy functions for compatibility
export async function fetchAllActors(): Promise<Array<{id: number, name: string, photo: string | null}>> {
try {
const media = await (await import('./mediaApi')).fetchAllMedia(1, 1000);
const actorMap = new Map<number, {id: number, name: string, photo: string | null}>();
media.forEach(item => {
item.staff?.forEach(staffMember => {
const id = parseInt(staffMember.id);
if (!actorMap.has(id)) {
actorMap.set(id, {
id: id,
name: staffMember.name,
photo: staffMember.photo
});
}
});
});
return Array.from(actorMap.values());
} catch (error) {
console.error('Error fetching all actors:', error);
return [];
}
}
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
try {
const media = await (await import('./mediaApi')).fetchAllMedia(1, 1000);
return media.filter(item =>
item.staff?.some(staffMember =>
staffMember.name.toLowerCase().includes(actorName.toLowerCase())
)
);
} catch (error) {
console.error('Error fetching media by actor:', error);
return [];
}
}

201
src/lib/api/converters.ts Normal file
View File

@@ -0,0 +1,201 @@
import { Media, Staff, UserSettings, MediaCategory } from '../../types';
import { ApiMediaItem, ApiStaff, ApiCastItem, ApiSettingsItem, CreateSettingsInput } from './types';
const BASE_URL = import.meta.env.VITE_API_URL;
function normalizeUrl(url: string | null): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
return `${BASE_URL}/${cleanPath}`;
}
export function convertApiCastToStaff(apiItem: ApiCastItem): Staff {
return {
id: apiItem.id.toString(),
name: apiItem.name,
cleanname: apiItem.cleanname,
role: apiItem.occupations?.[0] || 'Actor',
photo: normalizeUrl(apiItem.photo) || `https://picsum.photos/seed/cast-${apiItem.id}/200/200`,
bio: apiItem.bio || undefined,
birthDate: apiItem.birthDate || undefined,
birthPlace: apiItem.birthPlace || undefined,
occupations: apiItem.occupations || ['Actor'],
createdAt: apiItem.createdAt,
updatedAt: apiItem.updatedAt,
bust_size: apiItem.bust_size,
cup_size: apiItem.cup_size,
waist_size: apiItem.waist_size,
hip_size: apiItem.hip_size,
height: apiItem.height,
weight: apiItem.weight,
hair_color: apiItem.hair_color,
eye_color: apiItem.eye_color,
ethnicity: apiItem.ethnicity,
filmography: apiItem.filmography?.map(item => ({
id: item.id,
title: item.title,
year: item.year,
poster: normalizeUrl(item.poster) || `https://picsum.photos/seed/${item.id}/400/600`,
category: item.category,
type: item.type,
role: item.role,
characterName: item.characterName
})),
media_types: apiItem.media_types,
adult_specifics: apiItem.adult_specifics
};
}
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
const staff: Staff[] = (apiItem.staff || []).map((staffMember) => ({
id: staffMember.id.toString(),
name: staffMember.name,
role: staffMember.role,
photo: normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
characterName: staffMember.characterName || staffMember.name,
characterImage: normalizeUrl(staffMember.characterImage) || normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
}));
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
if (apiItem.aspectRatio) {
const ratio = apiItem.aspectRatio.toLowerCase();
if (ratio.includes('16:9') || ratio.includes('16/9') || ratio.includes('1.78') || ratio.includes('2.39')) {
aspectRatio = '16/9';
} else if (ratio.includes('1:1') || ratio.includes('1/1') || ratio.includes('1.00')) {
aspectRatio = '1/1';
} else if (ratio.includes('2/3')) {
aspectRatio = '2/3';
}
}
let mediaType: 'TV' | 'Movie' | 'OVA' | 'ONA' | 'Album' | 'Single' | 'Hardcover' | 'E-book' | 'Console' | 'Game' = 'Movie';
const apiType = apiItem.type?.toLowerCase();
if (apiType === 'tv' || apiType === 'episode') {
mediaType = 'TV';
} else if (apiType === 'album' || apiType === 'single') {
mediaType = apiType === 'album' ? 'Album' : 'Single';
} else if (apiType === 'game' || apiType === 'console') {
mediaType = apiType === 'game' ? 'Game' : 'Console';
} else if (apiType === 'ova') {
mediaType = 'OVA';
} else if (apiType === 'ona') {
mediaType = 'ONA';
} else if (apiType === 'hardcover' || apiType === 'e-book') {
mediaType = apiType === 'hardcover' ? 'Hardcover' : 'E-book';
}
let mediaCategory: 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games' = 'Movies';
const apiCategory = apiItem.category?.toLowerCase();
if (apiCategory === 'anime') {
mediaCategory = 'Anime';
} else if (apiCategory === 'movie' || apiCategory === 'movies') {
mediaCategory = 'Movies';
} else if (apiCategory === 'tv' || apiCategory === 'series' || apiCategory === 'tv series' || apiType === 'tv' || apiType === 'episode') {
mediaCategory = 'TV Series';
} else if (apiCategory === 'music' || apiType === 'album' || apiType === 'single') {
mediaCategory = 'Music';
} else if (apiCategory === 'book' || apiCategory === 'books' || apiType === 'hardcover' || apiType === 'e-book') {
mediaCategory = 'Books';
} else if (apiCategory === 'adult') {
mediaCategory = 'Adult';
} else if (apiCategory === 'console' || apiCategory === 'consoles' || apiType === 'console') {
mediaCategory = 'Consoles';
} else if (apiCategory === 'game' || apiCategory === 'games' || apiType === 'game') {
mediaCategory = 'Games';
} else {
console.warn('Unknown category:', apiItem.category, 'defaulting to Movies');
mediaCategory = 'Movies';
}
let mediaStatus: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold' = 'completed';
const apiStatus = apiItem.status?.toLowerCase();
if (apiStatus === 'ongoing' || apiStatus === 'watching') {
mediaStatus = 'watching';
} else if (apiStatus === 'upcoming' || apiStatus === 'planned') {
mediaStatus = 'planned';
} else if (apiStatus === 'dropped') {
mediaStatus = 'dropped';
} else if (apiStatus === 'reading') {
mediaStatus = 'reading';
} else if (apiStatus === 'listening') {
mediaStatus = 'listening';
} else if (apiStatus === 'playing') {
mediaStatus = 'playing';
} else if (apiStatus === 'on-hold') {
mediaStatus = 'on-hold';
}
return {
id: apiItem.id.toString(),
title: apiItem.title,
year: apiItem.year?.toString() || 'Unknown',
poster: normalizeUrl(apiItem.poster) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
category: mediaCategory,
banner: normalizeUrl(apiItem.banner) || undefined,
description: apiItem.description || undefined,
rating: apiItem.rating || undefined,
genres: apiItem.genres || [],
tags: apiItem.tags || [],
studios: apiItem.studios,
type: mediaType,
source: apiItem.source || undefined,
status: mediaStatus,
staff: staff.length > 0 ? staff : undefined,
aspectRatio: aspectRatio,
categories: apiItem.categories,
platforms: apiItem.platforms,
developers: apiItem.developers,
completionStatus: apiItem.completionStatus,
playCount: apiItem.playCount,
lastActivity: apiItem.lastActivity,
playtime: apiItem.playtime,
episodes: apiItem.episodes,
tracks: apiItem.tracks
};
}
export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
return {
id: apiItem.id,
enabledCategories: apiItem.enabled_categories as MediaCategory[],
itemsPerPage: apiItem.items_per_page || 20,
gridItemSize: apiItem.grid_item_size || 5,
defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid',
showAdultContent: apiItem.show_adult_content || false,
autoPlayTrailers: apiItem.auto_play_trailers || false,
language: apiItem.language || 'en',
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
jellyfinLibraryMappings: apiItem.jellyfin_library_mappings,
// Page Settings
pageTitle: apiItem.page_title,
favicon: apiItem.favicon,
customColors: apiItem.custom_colors ? JSON.parse(apiItem.custom_colors) : undefined,
createdAt: apiItem.created_at,
updatedAt: apiItem.updated_at,
};
}
export function convertSettingsToApi(settings: UserSettings): CreateSettingsInput {
return {
enabled_categories: settings.enabledCategories,
items_per_page: settings.itemsPerPage,
grid_item_size: settings.gridItemSize,
default_view: settings.defaultView,
show_adult_content: settings.showAdultContent,
auto_play_trailers: settings.autoPlayTrailers,
language: settings.language,
theme: settings.theme,
jellyfin_library_mappings: settings.jellyfinLibraryMappings,
// Page Settings
page_title: settings.pageTitle,
favicon: settings.favicon,
custom_colors: settings.customColors ? JSON.stringify(settings.customColors) : undefined,
};
}

105
src/lib/api/mediaApi.ts Normal file
View File

@@ -0,0 +1,105 @@
import { Media } from '../../types';
import { ApiResponse, PaginatedResponse, ApiMediaItem, CreateMediaInput, UpdateMediaInput } from './types';
import { convertApiToMedia } from './converters';
const BASE_URL = import.meta.env.VITE_API_URL;
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
try {
const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching media from API:', error);
return [];
}
}
export async function fetchMediaById(id: number | string): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error fetching media by ID:', error);
return null;
}
}
export async function createMedia(media: CreateMediaInput): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(media),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error creating media:', error);
return null;
}
}
export async function updateMedia(id: number | string, media: UpdateMediaInput): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(media),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error updating media:', error);
return null;
}
}
export async function deleteMedia(id: number | string): Promise<boolean> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<{ message: string }> = await response.json();
return data.success;
} catch (error) {
console.error('Error deleting media:', error);
return false;
}
}

View File

@@ -0,0 +1,83 @@
import { UserSettings } from '../../types';
import { ApiResponse, ApiSettingsItem, CreateSettingsInput, UpdateSettingsInput } from './types';
import { convertApiToSettings, convertSettingsToApi } from './converters';
const BASE_URL = import.meta.env.VITE_API_URL;
export async function fetchSettings(): Promise<UserSettings | null> {
try {
const response = await fetch(`${BASE_URL}/api/settings`);
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiSettingsItem> = await response.json();
if (data.success && data.data) {
return convertApiToSettings(data.data);
}
return null;
} catch (error) {
console.error('Error fetching settings:', error);
return null;
}
}
export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
try {
const apiSettings = convertSettingsToApi(settings);
const response = await fetch(`${BASE_URL}/api/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiSettings),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Create settings error response:', errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiSettingsItem> = await response.json();
if (data.success && data.data) {
return convertApiToSettings(data.data);
}
return null;
} catch (error) {
console.error('Error creating settings:', error);
return null;
}
}
export async function updateSettings(settings: UserSettings): Promise<UserSettings | null> {
try {
const apiSettings = convertSettingsToApi(settings);
const response = await fetch(`${BASE_URL}/api/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiSettings),
});
if (!response.ok) {
if (response.status === 404) {
return createSettings(settings);
}
const errorText = await response.text();
console.error('Update settings error response:', errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiSettingsItem> = await response.json();
if (data.success && data.data) {
return convertApiToSettings(data.data);
}
return null;
} catch (error) {
console.error('Error updating settings:', error);
return null;
}
}

223
src/lib/api/types.ts Normal file
View File

@@ -0,0 +1,223 @@
// API Response Types
export interface ApiResponse<T> {
success: boolean;
data: T;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
totalPages?: number;
}
// Media Types
export interface ApiEpisode {
id: number;
media_id: number;
season: number;
episode_number: number;
title: string;
description: string;
air_date: string;
duration: number;
thumbnail: string;
}
export interface ApiTrack {
id: number;
media_id: number;
track_number: number;
title: string;
duration: number | null;
artist: string;
}
export interface ApiMediaItem {
id: number;
title: string;
year: number;
poster: string | null;
banner: string | null;
description: string | null;
rating: number | null;
category: string | null;
type: string;
status: string;
aspectRatio: string | null;
runtime: number | null;
director: string | null;
writer: string | null;
releaseDate: string | null;
source?: string | null;
createdAt: string;
updatedAt: string;
genres?: string[];
tags?: string[];
studios?: string[];
staff?: ApiStaff[];
categories?: string[];
platforms?: string[];
developers?: string[];
completionStatus?: string;
playCount?: number;
lastActivity?: string | null;
playtime?: number;
episodes?: ApiEpisode[];
tracks?: ApiTrack[];
}
export interface ApiStaff {
id: number;
name: string;
photo: string | null;
bio: string | null;
birthDate: string | null;
birthPlace: string | null;
role: string;
characterName: string | null;
characterImage: string | null;
occupations?: string[];
}
export interface CreateMediaInput {
title: string;
year: number;
poster?: string | null;
banner?: string | null;
description?: string | null;
rating?: number | null;
category?: string | null;
type?: string;
status?: string;
aspectRatio?: string | null;
runtime?: number | null;
director?: string | null;
writer?: string | null;
releaseDate?: string | null;
source?: string | null;
genres?: string[];
tags?: string[];
studios?: string[];
staff?: CreateStaffInput[];
}
export interface UpdateMediaInput extends Partial<CreateMediaInput> {}
export interface CreateStaffInput {
name: string;
photo?: string | null;
bio?: string | null;
birthDate?: string | null;
birthPlace?: string | null;
role: string;
characterName?: string | null;
characterImage?: string | null;
occupations?: string[];
}
// Cast Types
export interface ApiCastItem {
id: number;
name: string;
cleanname?: string;
photo: string | null;
bio: string | null;
birthDate: string | null;
birthPlace: string | null;
createdAt: string;
updatedAt: string;
occupations?: string[];
filmography?: ApiCastMediaItem[];
media_types?: string[];
bust_size?: number | null;
cup_size?: string | null;
waist_size?: number | null;
hip_size?: number | null;
height?: number | null;
weight?: number | null;
hair_color?: string | null;
eye_color?: string | null;
ethnicity?: string | null;
adult_specifics?: {
id: number;
cast_id: number;
bust_size?: number | null;
cup_size?: string | null;
waist_size?: number | null;
hip_size?: number | null;
height?: number | null;
weight?: number | null;
hair_color?: string | null;
eye_color?: string | null;
ethnicity?: string | null;
tattoos?: string | null;
piercings?: string | null;
measurements?: string | null;
shoe_size?: number | null;
};
}
export interface ApiCastMediaItem {
id: number;
title: string;
year: number;
poster: string | null;
category: string | null;
type: string;
role: string;
characterName?: string | null;
}
export interface CreateCastInput {
name: string;
photo?: string | null;
bio?: string | null;
birthDate?: string | null;
birthPlace?: string | null;
occupations?: string[];
}
export interface UpdateCastInput extends Partial<CreateCastInput> {}
// Settings Types
export interface ApiSettingsItem {
id?: number;
enabled_categories: string[];
items_per_page: number;
grid_item_size?: number;
default_view: string;
show_adult_content: boolean;
auto_play_trailers: boolean;
language: string;
theme: string;
jellyfin_library_mappings?: string;
// Page Settings
page_title?: string;
favicon?: string;
custom_colors?: string; // JSON string of CustomColors
created_at?: string;
updated_at?: string;
}
export interface CreateSettingsInput {
enabled_categories: string[];
items_per_page?: number;
grid_item_size?: number;
default_view?: string;
show_adult_content?: boolean;
auto_play_trailers?: boolean;
language?: string;
theme?: string;
jellyfin_library_mappings?: string;
// Page Settings
page_title?: string;
favicon?: string;
custom_colors?: string;
}
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}

1456
src/lib/jellyfinImporter.ts Normal file

File diff suppressed because it is too large Load Diff

515
src/lib/playniteImporter.ts Normal file
View File

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

884
src/lib/stashappImporter.ts Normal file
View File

@@ -0,0 +1,884 @@
/**
* StashAPP Importer Module
*
* This module provides functionality to import adult video content and performers from a StashAPP instance
* into the Omnyx media database. It fetches scene and performer data via GraphQL, converts it to the Omnyx
* media format, and handles both new imports and updates to existing entries.
*
* @module stashappImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to a StashAPP instance
*/
export interface StashAPPConfig {
/** URL of the StashAPP server */
url: string;
/** API key for authentication (optional) */
apiKey?: string;
/** List of path patterns to blacklist during import */
blacklist?: string[];
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean;
}
/**
* Progress tracking for the import operation
*/
export interface ImportProgress {
/** Current number of items processed */
current: number;
/** Total number of items to process */
total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string;
/** Number of videos successfully imported */
videosImported: number;
/** Number of actors successfully imported */
actorsImported: number;
/** Array of error messages encountered during import */
errors: string[];
}
/**
* Scene data structure as returned by the StashAPP GraphQL API
*/
export interface StashAPPScene {
/** Unique identifier for the scene */
id: string;
/** Scene title */
title: string;
/** Scene description/details */
details: string;
/** Scene URL */
url: string;
/** Release date in ISO format */
date: string;
/** Rating on a 0-100 scale */
rating100: number;
/** Whether the scene is organized */
organized: boolean;
/** O-counter value */
o_counter: number;
/** Creation timestamp */
created_at: string;
/** Last update timestamp */
updated_at: string;
/** File paths for various media assets */
paths: {
screenshot: string;
preview: string;
stream: string;
webp: string;
vtt: string;
sprite: string;
funscript: string;
caption: string;
};
/** Array of file information */
files: Array<{
size: number;
duration: number;
video_codec: string;
audio_codec: string;
width: number;
height: number;
path: string;
}>;
/** Array of performers in the scene */
performers: Array<{
id: string;
name: string;
disambiguation: string;
url: string;
gender: string;
birthdate: string;
ethnicity: string;
country: string;
eye_color: string;
height_cm: number;
measurements: string;
fake_tits: boolean;
career_length: string;
tattoos: string;
piercings: string;
alias_list: string[];
favorite: boolean;
ignore_auto_tag: boolean;
details: string;
death_date: string;
hair_color: string;
weight: number;
image_path: string;
scene_count: number;
}>;
}
export interface StashAPPScenePerformer {
id: string;
name: string;
disambiguation: string;
url: string;
gender: string;
birthdate: string;
ethnicity: string;
country: string;
eye_color: string;
height_cm: number;
measurements: string;
fake_tits: boolean;
career_length: string;
tattoos: string;
piercings: string;
alias_list: string[];
favorite: boolean;
ignore_auto_tag: boolean;
created_at?: string;
updated_at?: string;
details: string;
death_date: string;
hair_color: string;
weight: number;
image_path: string;
scene_count: number;
}
export interface StashAPPPerformer {
id: string;
name: string;
disambiguation: string;
url: string;
gender: string;
birthdate: string;
ethnicity: string;
country: string;
eye_color: string;
height_cm: number;
measurements: string;
fake_tits: boolean;
career_length: string;
tattoos: string;
piercings: string;
alias_list: string[];
favorite: boolean;
ignore_auto_tag: boolean;
created_at: string;
updated_at: string;
details: string;
death_date: string;
hair_color: string;
weight: number;
image_path: string;
scene_count: number;
}
export interface StashAPPScenesResponse {
data: {
findScenes: {
scenes: StashAPPScene[];
count: number;
};
};
}
export interface StashAPPPerformersResponse {
data: {
findPerformers: {
performers: StashAPPPerformer[];
count: number;
};
};
}
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/**
* Checks if a file path matches any blacklist pattern
* @param filePath - The file path to check
* @param blacklist - Array of blacklist patterns
* @returns True if the path is blacklisted, false otherwise
*/
function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
if (!blacklist || blacklist.length === 0) {
return false;
}
return blacklist.some(pattern => filePath.includes(pattern));
}
/**
* Updates or creates actor entries from StashAPP performers
*
* This function fetches all performers from StashAPP and updates or creates
* corresponding actor entries in the Omnyx database.
*
* @param config - Configuration for connecting to StashAPP
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*/
export async function updateActorsFromStashAPP(
config: StashAPPConfig,
logCallback: LogCallback,
progressCallback: ProgressCallback
): Promise<ImportProgress> {
const progress: ImportProgress = {
current: 0,
total: 0,
stage: 'fetching',
message: 'Connecting to StashAPP...',
videosImported: 0,
actorsImported: 0,
errors: []
};
try {
logCallback('Starting StashAPP actor update...');
// Fetch existing cast from Omnyx API
logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
const existingCastData = await existingCastResponse.json();
const existingActors = new Map<string, Staff>(
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
// Fetch all performers from StashAPP
logCallback(`Fetching performers from StashAPP...`);
progressCallback({ message: 'Fetching performers from StashAPP...' });
const graphqlQuery = {
query: `
query FindPerformers($filter: FindFilterType) {
findPerformers(filter: $filter) {
count
performers {
id
name
disambiguation
url
gender
birthdate
ethnicity
country
eye_color
height_cm
measurements
fake_tits
career_length
tattoos
piercings
alias_list
favorite
ignore_auto_tag
created_at
updated_at
details
death_date
hair_color
weight
image_path
scene_count
}
}
}
`,
variables: {
filter: {
per_page: 1000
}
}
};
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (config.apiKey) {
headers['ApiKey'] = config.apiKey;
}
const performersResponse = await fetch(`${config.url}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify(graphqlQuery)
});
if (!performersResponse.ok) {
throw new Error(`Failed to connect to StashAPP: ${performersResponse.statusText}`);
}
const performersData: StashAPPPerformersResponse = await performersResponse.json();
const performers = performersData.data?.findPerformers?.performers || [];
logCallback(`Found ${performers.length} performers in StashAPP`);
progressCallback({
total: performers.length,
stage: 'importing',
message: 'Updating actors...'
});
let actorsUpdated = 0;
let actorsCreated = 0;
const actorErrors: string[] = [];
for (let i = 0; i < performers.length; i++) {
const performer = performers[i];
const existingActor: Staff | undefined = existingActors.get(performer.name);
try {
if (existingActor) {
// Update existing actor
const updateData: Partial<Staff> = {
name: performer.name,
};
// Update photo if available and different
if (performer.image_path && performer.image_path !== existingActor.photo) {
updateData.photo = performer.image_path;
}
// Update bio with details if available
if (performer.details) {
updateData.bio = performer.details;
} else if (performer.career_length) {
updateData.bio = performer.career_length;
}
// Update birth date if available
if (performer.birthdate) {
updateData.birthDate = performer.birthdate;
}
// Update birth place if available
if (performer.country) {
updateData.birthPlace = performer.country;
}
const response = await fetch(`${BASE_URL}/api/cast/${existingActor.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
});
if (response.ok) {
actorsUpdated++;
logCallback(`✓ Updated actor: ${performer.name}`);
} else {
const error = await response.text();
actorErrors.push(`Failed to update actor ${performer.name}: ${error}`);
logCallback(`✗ Failed to update actor: ${performer.name}`);
}
} else {
// Create new actor
const response = await fetch(`${BASE_URL}/api/cast/adult`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: performer.name,
photo: performer.image_path || null,
bio: performer.details || performer.career_length || null,
birthDate: performer.birthdate || null,
birthPlace: performer.country || null,
occupations: ['Actor'],
adult_specifics: {
height: performer.height_cm ? performer.height_cm.toString() : null,
weight: performer.weight ? performer.weight.toString() : null,
hair_color: performer.hair_color || null,
eye_color: performer.eye_color || null,
ethnicity: performer.ethnicity || null,
tattoos: performer.tattoos || null,
piercings: performer.piercings || null,
measurements: performer.measurements || null
}
})
});
if (response.ok) {
actorsCreated++;
logCallback(`✓ Created new Adult actor: ${performer.name}`);
} else {
const error = await response.text();
actorErrors.push(`Failed to create actor ${performer.name}: ${error}`);
logCallback(`✗ Failed to create actor: ${performer.name}`);
}
}
} catch (error) {
actorErrors.push(`Error processing actor ${performer.name}: ${error}`);
logCallback(`✗ Error processing actor: ${performer.name}`);
}
progressCallback({
current: i + 1,
actorsImported: actorsCreated,
errors: actorErrors
});
}
logCallback(`Updated ${actorsUpdated} existing actors, created ${actorsCreated} new actors`);
// Complete
progress.stage = 'complete';
progress.message = 'Actor update complete!';
progress.current = performers.length;
progress.total = performers.length;
progress.actorsImported = actorsCreated;
progress.errors = actorErrors;
logCallback('Actor update completed successfully!');
return progress;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
progress.stage = 'error';
progress.message = `Actor update failed: ${errorMessage}`;
progress.errors = [...progress.errors, errorMessage];
logCallback(`✗ Actor update failed: ${errorMessage}`);
return progress;
}
}
/**
* Imports scenes and performers from a StashAPP instance into the Omnyx media database
*
* This function performs the following steps:
* 1. Fetches existing media and cast from Omnyx to check for duplicates
* 2. Fetches all scenes from StashAPP via GraphQL
* 3. Extracts unique performers from all scenes
* 4. Imports or updates performers first
* 5. Imports or updates scenes with their associated performers
*
* @param config - Configuration for connecting to StashAPP
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromStashAPP(
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
* ```
*/
export async function importFromStashAPP(
config: StashAPPConfig,
logCallback: LogCallback,
progressCallback: ProgressCallback
): Promise<ImportProgress> {
const progress: ImportProgress = {
current: 0,
total: 0,
stage: 'fetching',
message: 'Connecting to StashAPP API...',
videosImported: 0,
actorsImported: 0,
errors: []
};
try {
logCallback('Starting StashAPP import...');
// Step 0: Fetch existing media and cast to check for duplicates
logCallback('Fetching existing media from Omnyx API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
const existingMediaData = await existingMediaResponse.json();
const existingTitles = new Set(
existingMediaData.data?.items?.map((m: Media) => m.title) || []
);
logCallback(`Found ${existingTitles.size} existing videos in database`);
logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
const existingCastData = await existingCastResponse.json();
const existingActors = new Map<string, Staff>(
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
// Step 1: Fetch scenes from StashAPP
logCallback(`Fetching scenes from StashAPP...`);
progressCallback({ message: 'Fetching scenes from StashAPP...' });
const graphqlQuery = {
query: `
query FindScenes($filter: FindFilterType) {
findScenes(filter: $filter) {
scenes {
id
title
details
url
date
rating100
organized
o_counter
created_at
updated_at
paths {
screenshot
preview
stream
webp
vtt
sprite
funscript
caption
}
files {
size
duration
video_codec
audio_codec
width
height
path
}
performers {
id
name
disambiguation
url
gender
birthdate
ethnicity
country
eye_color
height_cm
measurements
fake_tits
career_length
tattoos
piercings
alias_list
favorite
ignore_auto_tag
created_at
updated_at
details
death_date
hair_color
weight
image_path
scene_count
}
}
count
}
}
`,
variables: {
filter: {
per_page: 20000,
sort: "date",
direction: "DESC"
}
}
};
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (config.apiKey) {
headers['ApiKey'] = config.apiKey;
}
const scenesResponse = await fetch(`${config.url}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify(graphqlQuery)
});
if (!scenesResponse.ok) {
throw new Error(`Failed to connect to StashAPP: ${scenesResponse.statusText}`);
}
const scenesData: StashAPPScenesResponse = await scenesResponse.json();
const scenes = scenesData.data?.findScenes?.scenes || [];
logCallback(`Found ${scenes.length} scenes in StashAPP`);
// Step 2: Extract unique performers
const performerSet = new Map<string, StashAPPScenePerformer>();
scenes.forEach(scene => {
scene.performers.forEach(performer => {
if (!performerSet.has(performer.id)) {
performerSet.set(performer.id, performer);
}
});
});
const uniquePerformers = Array.from(performerSet.values());
logCallback(`Found ${uniquePerformers.length} unique performers across all scenes`);
// Step 3: Import performers first
progressCallback({
total: uniquePerformers.length + scenes.length,
current: 0,
message: 'Importing performers...'
});
let performersImported = 0;
const performerErrors: string[] = [];
for (let i = 0; i < uniquePerformers.length; i++) {
const performer = uniquePerformers[i];
const existingActor: Staff | undefined = existingActors.get(performer.name);
try {
if (existingActor) {
// Update existing actor
const updateData: Partial<Staff> = {
name: performer.name,
};
// Update photo if available and different
if (performer.image_path && performer.image_path !== existingActor.photo) {
updateData.photo = performer.image_path;
}
// Update bio with details if available
if (performer.details) {
updateData.bio = performer.details;
} else if (performer.career_length) {
updateData.bio = performer.career_length;
}
// Update birth date if available
if (performer.birthdate) {
updateData.birthDate = performer.birthdate;
}
// Update birth place if available
if (performer.country) {
updateData.birthPlace = performer.country;
}
const response = await fetch(`${BASE_URL}/api/cast/${existingActor.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
});
if (response.ok) {
performersImported++;
logCallback(`✓ Updated performer: ${performer.name}`);
} else {
const error = await response.text();
performerErrors.push(`Failed to update performer ${performer.name}: ${error}`);
logCallback(`✗ Failed to update performer: ${performer.name}`);
}
} else {
// Create new actor
const response = await fetch(`${BASE_URL}/api/cast/adult`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: performer.name,
photo: performer.image_path || null,
bio: performer.details || performer.career_length || null,
birthDate: performer.birthdate || null,
birthPlace: performer.country || null,
occupations: ['Actor'],
adult_specifics: {
height: performer.height_cm ? performer.height_cm.toString() : null,
weight: performer.weight ? performer.weight.toString() : null,
hair_color: performer.hair_color || null,
eye_color: performer.eye_color || null,
ethnicity: performer.ethnicity || null,
tattoos: performer.tattoos || null,
piercings: performer.piercings || null,
measurements: performer.measurements || null
}
})
});
if (response.ok) {
performersImported++;
logCallback(`✓ Imported performer: ${performer.name}`);
} else {
const error = await response.text();
performerErrors.push(`Failed to import performer ${performer.name}: ${error}`);
logCallback(`✗ Failed to import performer: ${performer.name}`);
}
}
} catch (error) {
performerErrors.push(`Error processing performer ${performer.name}: ${error}`);
logCallback(`✗ Error processing performer: ${performer.name}`);
}
progressCallback({
current: i + 1,
actorsImported: performersImported,
errors: performerErrors
});
}
logCallback(`Processed ${performersImported}/${uniquePerformers.length} performers (imported or updated)`);
// Step 4: Import scenes
progressCallback({
current: uniquePerformers.length,
message: 'Importing scenes...'
});
let scenesImported = 0;
const sceneErrors: string[] = [];
for (let i = 0; i < scenes.length; i++) {
const scene = scenes[i];
// Check if scene is blacklisted
if (config.blacklist && config.blacklist.length > 0) {
const isBlacklisted = scene.files && scene.files.some(file =>
isPathBlacklisted(file.path, config.blacklist!)
);
if (isBlacklisted) {
logCallback(`⊘ Skipped blacklisted scene: ${scene.title}`);
progressCallback({
current: uniquePerformers.length + i + 1
});
continue;
}
}
// Check for duplicate
if (existingTitles.has(scene.title)) {
if (!config.updateExisting) {
logCallback(`⊘ Skipped duplicate: ${scene.title} (updateExisting is false)`);
progressCallback({
current: uniquePerformers.length + i + 1
});
continue;
}
logCallback(`→ Updating existing: ${scene.title}`);
}
try {
// Extract performers as staff
const staff = scene.performers && Array.isArray(scene.performers)
? scene.performers.map(p => ({
name: p.name,
role: 'Actor',
photo: p.image_path || null,
characterName: p.name,
characterImage: p.image_path || null
}))
: [];
// Parse date
const year = scene.date ? new Date(scene.date).getFullYear() : new Date().getFullYear();
const releaseDate = scene.date || null;
// Determine aspect ratio from file dimensions
let aspectRatio: '2/3' | '16/9' | '1/1' = '16/9';
if (scene.files && scene.files.length > 0) {
const file = scene.files[0];
if (file.width && file.height) {
const ratio = file.width / file.height;
if (ratio > 1.6) {
aspectRatio = '16/9';
} else if (ratio < 1.4 && ratio > 0.8) {
aspectRatio = '1/1';
} else if (ratio < 0.8) {
aspectRatio = '2/3';
}
}
}
// Get duration from files
const runtime = scene.files && scene.files.length > 0 ? scene.files[0].duration : null;
// Convert rating100 to 5-star scale
const rating = scene.rating100 ? scene.rating100 / 20 : null;
const mediaData = {
title: scene.title,
year: year.toString(),
poster: scene.paths?.screenshot || null,
banner: null,
description: scene.details || null,
rating: rating,
category: 'Adult',
type: 'Movie',
status: 'completed',
aspectRatio: aspectRatio,
runtime: runtime,
director: null,
writer: null,
releaseDate: releaseDate,
source: SOURCE_CATEGORY_MAPPING['stashapp']?.includes('Adult') ? 'stashapp' : null,
genres: [],
tags: [],
studios: [],
staff: staff
};
const response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaData)
});
if (response.ok) {
scenesImported++;
logCallback(`✓ Imported scene: ${scene.title}`);
} else {
const error = await response.text();
sceneErrors.push(`Failed to import scene ${scene.title}: ${error}`);
logCallback(`✗ Failed to import scene: ${scene.title}`);
}
} catch (error) {
sceneErrors.push(`Error importing scene ${scene.title}: ${error}`);
logCallback(`✗ Error importing scene: ${scene.title}`);
}
progressCallback({
current: uniquePerformers.length + i + 1,
videosImported: scenesImported,
errors: [...performerErrors, ...sceneErrors]
});
}
logCallback(`Imported ${scenesImported}/${scenes.length} scenes`);
// Complete
progress.stage = 'complete';
progress.message = 'Import complete!';
progress.current = uniquePerformers.length + scenes.length;
progress.total = uniquePerformers.length + scenes.length;
progress.videosImported = scenesImported;
progress.actorsImported = performersImported;
progress.errors = [...performerErrors, ...sceneErrors];
logCallback('Import completed successfully!');
return progress;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
progress.stage = 'error';
progress.message = `Import failed: ${errorMessage}`;
progress.errors = [...progress.errors, errorMessage];
logCallback(`✗ Import failed: ${errorMessage}`);
return progress;
}
}

464
src/lib/xbvrImporter.ts Normal file
View File

@@ -0,0 +1,464 @@
/**
* XBVR Importer Module
*
* This module provides functionality to import VR adult video content from an XBVR instance into the Omnyx media database.
* It fetches scene data from the DeoVR API endpoint, extracts actors and video details, and handles both new imports
* and updates to existing entries. The module specifically filters for content in the 'Recent' scene group.
*
* @module xbvrImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to an XBVR instance
*/
export interface XBVRConfig {
/** URL of the XBVR server */
url: string;
/** API key for authentication (optional) */
apiKey?: string;
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean;
}
/**
* Progress tracking for the import operation
*/
export interface ImportProgress {
/** Current number of items processed */
current: number;
/** Total number of items to process */
total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string;
/** Number of videos successfully imported */
videosImported: number;
/** Number of actors successfully imported */
actorsImported: number;
/** Array of error messages encountered during import */
errors: string[];
}
/**
* Basic video information from the DeoVR scene list
*/
export interface XBVRVideo {
/** Video title */
title: string;
/** Video length in seconds */
videoLength: number;
/** URL to the video thumbnail */
thumbnailUrl: string;
/** URL to fetch detailed video information */
video_url: string;
}
/**
* Detailed video information as returned by the XBVR API
*/
export interface XBVRVideoDetail {
/** Unique video identifier */
id: number;
/** Video title */
title: string;
/** Video description */
description: string;
/** Release date as Unix timestamp */
date: number;
/** URL to the video thumbnail */
thumbnailUrl: string;
/** Average rating */
rating_avg: number;
/** Screen type (e.g., '180', '360', 'dome') */
screenType: string;
/** Stereo mode (e.g., 'sbs', 'tb') */
stereoMode: string;
/** Video length in seconds */
videoLength: number;
/** Pay site information */
paysite?: {
name: string;
};
/** Array of actors in the video */
actors: Array<{
id: number;
name: string;
}>;
/** Array of category tags */
categories: Array<{
tag: {
name: string;
};
}>;
}
/**
* Scene list structure as returned by the DeoVR API
*/
export interface XBVRSceneList {
/** Array of scene groups */
scenes: Array<{
/** Name of the scene group (e.g., 'Recent', 'Favorites') */
name: string;
/** List of videos in this group */
list: XBVRVideo[];
}>;
}
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/**
* Imports VR adult videos and actors from an XBVR instance into the Omnyx media database
*
* This function performs the following steps:
* 1. Fetches existing media and cast from Omnyx to check for duplicates
* 2. Fetches the scene list from the DeoVR API endpoint
* 3. Extracts videos from the 'Recent' scene group
* 4. Fetches detailed information for each video
* 5. Imports or updates actors first
* 6. Imports or updates videos with their associated actors
*
* Videos and actors containing 'aka:' in their name are automatically skipped.
*
* @param config - Configuration for connecting to XBVR
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromXBVR(
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
* ```
*/
export async function importFromXBVR(
config: XBVRConfig,
logCallback: LogCallback,
progressCallback: ProgressCallback
): Promise<ImportProgress> {
const progress: ImportProgress = {
current: 0,
total: 0,
stage: 'fetching',
message: 'Connecting to DeoVR API...',
videosImported: 0,
actorsImported: 0,
errors: []
};
try {
logCallback('Starting DeoVR import...');
// Step 0: Fetch existing media and cast to check for duplicates
logCallback('Fetching existing media from Omnyx API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
const existingMediaData = await existingMediaResponse.json();
const existingTitles = new Set(
existingMediaData.data?.items?.map((m: Media) => m.title) || []
);
logCallback(`Found ${existingTitles.size} existing videos in database`);
logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
const existingCastData = await existingCastResponse.json();
const existingActors = new Map(
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
// Step 1: Fetch scene list from DeoVR API
logCallback(`Fetching scene list from ${config.url}/deovr...`);
progressCallback({ message: 'Fetching scene list from DeoVR...' });
const scenesListResponse = await fetch(`${config.url}/deovr`);
if (!scenesListResponse.ok) {
throw new Error(`Failed to connect to DeoVR API: ${scenesListResponse.statusText}`);
}
const scenesListData: XBVRSceneList = await scenesListResponse.json();
logCallback('Received scene list structure');
// Extract only videos from the 'Recent' scene group
const allVideos: XBVRVideo[] = [];
if (scenesListData.scenes && Array.isArray(scenesListData.scenes)) {
const recentGroup = scenesListData.scenes.find((group) => group.name === 'Recent');
if (recentGroup && recentGroup.list && Array.isArray(recentGroup.list)) {
allVideos.push(...recentGroup.list);
}
}
logCallback(`Found ${allVideos.length} videos in 'Recent' scene group`);
// Step 2: Fetch details for each video
progressCallback({
total: allVideos.length,
stage: 'importing',
message: 'Fetching video details...'
});
const videoDetails: XBVRVideoDetail[] = [];
const actorSet = new Map<number, any>();
for (let i = 0; i < allVideos.length; i++) {
const video = allVideos[i];
try {
logCallback(`Fetching details for video: ${video.title} (${i + 1}/${allVideos.length})`);
const detailResponse = await fetch(video.video_url);
if (!detailResponse.ok) {
throw new Error(`Failed to fetch details: ${detailResponse.statusText}`);
}
const detailData: XBVRVideoDetail = await detailResponse.json();
videoDetails.push(detailData);
// Extract actors from video details
if (detailData.actors && Array.isArray(detailData.actors)) {
detailData.actors.forEach((actor) => {
// Skip actors containing 'aka:' anywhere in the name
if (actor.name.toLowerCase().includes('aka:')) {
return;
}
// Deduplicate by actor ID
if (!actorSet.has(actor.id)) {
actorSet.set(actor.id, actor);
}
});
}
logCallback(`✓ Fetched details for: ${video.title}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logCallback(`✗ Failed to fetch details for ${video.title}: ${errorMessage}`);
progress.errors.push(`Failed to fetch details for ${video.title}: ${errorMessage}`);
progressCallback({ errors: progress.errors });
}
progressCallback({
current: i + 1,
message: `Fetching video details... ${Math.round(((i + 1) / allVideos.length) * 100)}%`
});
}
const uniqueActors = Array.from(actorSet.values());
logCallback(`Found ${uniqueActors.length} unique actors across all videos`);
// Step 3: Import actors first
progressCallback({
total: uniqueActors.length + videoDetails.length,
current: 0,
message: 'Importing actors...'
});
let actorsImported = 0;
const actorErrors: string[] = [];
for (let i = 0; i < uniqueActors.length; i++) {
const actor = uniqueActors[i];
// Skip actors containing 'aka:' anywhere in the name
if (actor.name.toLowerCase().includes('aka:')) {
logCallback(`⊘ Skipped 'aka:' actor: ${actor.name}`);
progressCallback({ current: i + 1 });
continue;
}
const existingActor = existingActors.get(actor.name);
try {
if (existingActor) {
// Update existing actor - XBVR doesn't have photos, so just ensure it exists
logCallback(`⊘ Actor already exists: ${actor.name}`);
} else {
// Create new actor
const response = await fetch(`${BASE_URL}/api/cast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: actor.name,
photo: null,
bio: null,
birthDate: null,
birthPlace: null,
occupations: ['Actor']
})
});
if (response.ok) {
actorsImported++;
logCallback(`✓ Imported actor: ${actor.name}`);
} else {
const error = await response.text();
actorErrors.push(`Failed to import actor ${actor.name}: ${error}`);
logCallback(`✗ Failed to import actor: ${actor.name}`);
}
}
} catch (error) {
actorErrors.push(`Error importing actor ${actor.name}: ${error}`);
logCallback(`✗ Error importing actor: ${actor.name}`);
}
progressCallback({
current: i + 1,
actorsImported,
errors: actorErrors
});
}
logCallback(`Imported ${actorsImported}/${uniqueActors.length} actors`);
// Step 4: Import videos
progressCallback({
current: uniqueActors.length,
message: 'Importing videos...'
});
let videosImported = 0;
const videoErrors: string[] = [];
for (let i = 0; i < videoDetails.length; i++) {
const video = videoDetails[i];
// Skip videos starting with 'aka:'
if (video.title.toLowerCase().startsWith('aka:')) {
logCallback(`⊘ Skipped 'aka:' video: ${video.title}`);
progressCallback({
current: uniqueActors.length + i + 1
});
continue;
}
// Check for duplicate
if (existingTitles.has(video.title)) {
if (!config.updateExisting) {
logCallback(`⊘ Skipped duplicate: ${video.title} (updateExisting is false)`);
progressCallback({
current: uniqueActors.length + i + 1
});
continue;
}
logCallback(`→ Updating existing: ${video.title}`);
}
try {
// Extract categories/tags
const categories = video.categories && Array.isArray(video.categories)
? video.categories.map((c) => c.tag?.name).filter(Boolean)
: [];
// Extract actors
const staff = video.actors && Array.isArray(video.actors)
? video.actors.map((a) => ({
name: a.name,
role: 'Actor',
photo: null,
characterName: a.name,
characterImage: null
}))
: [];
// Convert Unix timestamp to date
const releaseDate = video.date ? new Date(video.date * 1000).toISOString().split('T')[0] : null;
const year = video.date ? new Date(video.date * 1000).getFullYear() : new Date().getFullYear();
// Determine aspect ratio based on DeoVR screenType and stereoMode
let aspectRatio: '2/3' | '16/9' | '1/1' = '16/9';
if (video.screenType === '360' || video.screenType === '360180') {
aspectRatio = '1/1'; // VR360 videos are typically square for SBS
} else if (video.screenType === '180' || video.screenType === 'dome') {
aspectRatio = '16/9'; // VR180 videos are typically 16:9 for SBS
} else if (video.stereoMode === 'tb' && (video.screenType === '360' || video.screenType === '180')) {
aspectRatio = '1/1'; // Top-bottom format is taller
}
const mediaData = {
title: video.title,
year: year,
poster: video.thumbnailUrl || null,
banner: null,
description: video.description || null,
rating: video.rating_avg || null,
category: 'Adult',
type: 'Movie',
status: 'completed',
aspectRatio: aspectRatio,
runtime: video.videoLength || null,
director: null,
writer: null,
releaseDate: releaseDate,
source: SOURCE_CATEGORY_MAPPING['xbvr']?.includes('Adult') ? 'xbvr' : null,
genres: categories,
tags: categories,
studios: video.paysite?.name ? [video.paysite.name] : [],
staff: staff
};
const response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaData)
});
if (response.ok) {
videosImported++;
logCallback(`✓ Imported video: ${video.title}`);
} else {
const error = await response.text();
videoErrors.push(`Failed to import video ${video.title}: ${error}`);
logCallback(`✗ Failed to import video: ${video.title}`);
}
} catch (error) {
videoErrors.push(`Error importing video ${video.title}: ${error}`);
logCallback(`✗ Error importing video: ${video.title}`);
}
progressCallback({
current: uniqueActors.length + i + 1,
videosImported,
errors: [...actorErrors, ...videoErrors]
});
}
logCallback(`Imported ${videosImported}/${videoDetails.length} videos`);
// Complete
progress.stage = 'complete';
progress.message = 'Import complete!';
progress.current = uniqueActors.length + videoDetails.length;
progress.total = uniqueActors.length + videoDetails.length;
progress.videosImported = videosImported;
progress.actorsImported = actorsImported;
progress.errors = [...actorErrors, ...videoErrors];
logCallback('Import completed successfully!');
return progress;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
progress.stage = 'error';
progress.message = `Import failed: ${errorMessage}`;
progress.errors = [...progress.errors, errorMessage];
logCallback(`✗ Import failed: ${errorMessage}`);
return progress;
}
}

70
src/store/appStore.ts Normal file
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

@@ -1,4 +1,4 @@
export type MediaCategory = 'Anime' | 'Movies' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games'; export type MediaCategory = 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games';
export interface Media { export interface Media {
id: string; id: string;
@@ -16,30 +16,133 @@ export interface Media {
studios?: string[]; studios?: string[];
status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold'; status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold';
episodes?: Episode[]; episodes?: Episode[];
tracks?: Track[];
staff?: Staff[]; staff?: Staff[];
categories?: string[];
platforms?: string[];
developers?: string[];
completionStatus?: string;
source?: string;
playCount?: number;
lastActivity?: string | null;
playtime?: number;
} }
export interface Episode { export interface Episode {
id: string; id: number;
number: number; media_id: number;
season: number;
episode_number: number;
title: string; title: string;
date: string;
duration: string;
description: string; description: string;
air_date: string;
duration: number;
thumbnail: string; thumbnail: string;
} }
export interface Track {
id: number;
media_id: number;
track_number: number;
title: string;
duration: number | null;
artist: string;
}
export interface Staff { export interface Staff {
id: string; id: string;
name: string; name: string;
cleanname?: string;
role: string; role: string;
photo: string; photo: string;
characterName: string; characterName?: string;
characterImage: string; characterImage?: string;
mediaId?: string; mediaId?: string;
mediaTitle?: string; mediaTitle?: string;
bio?: string; bio?: string;
birthDate?: string; birthDate?: string;
birthPlace?: string; birthPlace?: string;
occupations?: string[]; occupations?: string[];
createdAt?: string;
updatedAt?: string;
bust_size?: number | null;
cup_size?: string | null;
waist_size?: number | null;
hip_size?: number | null;
height?: number | null;
weight?: number | null;
hair_color?: string | null;
eye_color?: string | null;
ethnicity?: string | null;
filmography?: CastMediaItem[];
media_types?: string[];
adult_specifics?: AdultSpecifics;
} }
export interface CastMediaItem {
id: number;
title: string;
year: number;
poster: string | null;
category: string | null;
type: string;
role: string;
characterName?: string | null;
}
export interface AdultSpecifics {
id: number;
cast_id: number;
bust_size?: number | null;
cup_size?: string | null;
waist_size?: number | null;
hip_size?: number | null;
height?: number | null;
weight?: number | null;
hair_color?: string | null;
eye_color?: string | null;
ethnicity?: string | null;
tattoos?: string | null;
piercings?: string | null;
measurements?: string | null;
shoe_size?: number | null;
}
export interface UserSettings {
id?: number;
enabledCategories: MediaCategory[];
itemsPerPage: number;
gridItemSize: number; // 1-10 scale
defaultView: 'grid' | 'list';
showAdultContent: boolean;
autoPlayTrailers: boolean;
language: string;
theme: 'light' | 'dark' | 'system';
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[]
// Page Settings
pageTitle?: string; // Custom page title
favicon?: string; // Base64 encoded favicon/image
customColors?: CustomColors; // Custom color scheme
createdAt?: string;
updatedAt?: string;
}
export interface CustomColors {
primary?: string; // Primary accent color (hex)
secondary?: string; // Secondary accent color (hex)
background?: string; // Background color (hex)
surface?: string; // Surface/card color (hex)
text?: string; // Text color (hex)
muted?: string; // Muted text color (hex)
border?: string; // Border color (hex)
}
// Source to Category mapping - ensures sources are only used with appropriate categories
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
'xbvr': ['Adult'],
'stashapp': ['Adult'],
'playnite': ['Games'],
'manual': ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Adult', 'Consoles', 'Games'],
};

14
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_XBVR_URL?: string;
readonly VITE_STASHAPP_URL?: string;
readonly VITE_STASHAPP_API_KEY?: string;
readonly VITE_PLAYNITE_IP?: string;
readonly VITE_PLAYNITE_PORT?: string;
readonly VITE_PLAYNITE_API_TOKEN?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

26
typedoc.json Normal file
View File

@@ -0,0 +1,26 @@
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": [
"./src/lib/playniteImporter.ts",
"./src/lib/stashappImporter.ts",
"./src/lib/jellyfinImporter.ts",
"./src/lib/xbvrImporter.ts"
],
"out": "docs",
"name": "Omnyx Importer Documentation",
"theme": "default",
"excludePrivate": true,
"excludeProtected": false,
"excludeInternal": true,
"hideGenerator": true,
"sort": ["source-order"],
"categorizeByGroup": true,
"defaultCategory": "Other",
"categoryOrder": [
"Configuration",
"Types",
"Functions",
"Other"
],
"readme": "README.md"
}

View File

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