27 Commits

Author SHA1 Message Date
Lars Behrends d61472f069 ui2 2026-05-23 00:54:01 +02:00
Lars Behrends 83617f75e4 theme context 2026-05-22 11:02:32 +02:00
Lars Behrends 901b342871 test 2026-05-22 09:57:56 +02:00
Lars Behrends b0cb8ca0a2 Introduce pagination component and sticky views
Add a reusable pagination UI (src/components/ui/pagination.tsx) and integrate it into BrowseView and CastView. Replace previous simple prev/next handlers with handlePageChange and a getPaginationItems helper (ellipsis support), move filters/controls into sticky headers, make main content scrollable (browse-scroll-container / cast-scroll-container), and add sticky pagination bars. Also: fix footer to be fixed at bottom in App.tsx, increase bottom padding in DashboardView and DetailView, simplify MediaTable markup to render Table directly, and add /.windsurf to .gitignore. These changes improve UX for large result sets and keep controls accessible while scrolling.
2026-04-26 15:43:41 +02:00
Lars Behrends 4605b251be Refactor Settings UI into tabs and add slider
Rework SettingsView into a tabbed, card-based layout with improved controls and UX. Adds save status indicators/animations, a back navigation button (useNavigate), badges, separators, and consistent Button/Input/Card/Tabs components. Refactors CATEGORY_ICONS to use React.ElementType and updates icon imports (BookOpen, ImageIcon, etc.). Introduces a new Slider component (src/components/ui/slider.tsx) and replaces the old range input for grid item size with the new Slider. Also consolidates custom color inputs, favicon upload UI, language selection, and other display/content controls into structured cards for clarity.
2026-04-26 02:22:09 +02:00
Lars Behrends 073c8a6c5d Integrate shadcn UI & add UI primitives
Integrates the shadcn/ui design system across the app and adds a collection of reusable UI primitives and layout components. Adds new UI atoms/molecules (avatar, card, collapsible, progress, select, sheet, sidebar, skeleton, table, tabs, toggles, tooltip), app sidebar, media filters, MediaTable, and a mobile hook; updates many views/components to use the new UI. Updates AGENTS.md with styling, layout, accessibility and design standards (Tailwind/shadcn guidance) and adds a registry entry to components.json. Also updates dependencies/lockfile to align shadcn and related packages.
2026-04-26 02:18:01 +02:00
Lars Behrends 9a72ba3064 Refactor detail tabs; add series & Playnite options
Split DetailView into focused tab components (Overview, Cast, Seasons, Tracks, Series) and moved related UI/logic into src/components/details/tabs/*. DetailView now composes these tabs and accepts allMedia for series lookups; MediaDetailRoute forwards allMedia.

Support for series was added across the stack: API types and converters now include series, Media type gained series and cleanname fields, and BrowseView now lists/filters by series (label updated to 'Series' and dropdown default changed to '--- Alle ---').

Playnite importer: introduced PlayniteImportOptions (limit, nameFilter), added UI inputs to ImporterView, increased existing media fetch limit, added name filtering, import limiting, deduplication and improved cleanname-based matching/logging. Adjusted progress/total handling to account for deduped items.
2026-04-25 23:54:18 +02:00
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
78 changed files with 15781 additions and 2710 deletions
+4 -2
View File
@@ -3,8 +3,10 @@
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# Backend API URL
VITE_API_URL="http://192.168.1.102:6400"
# Backend API URL (Omnyx Backend)
# Default: http://localhost:3001 for local dev
# Change this if backend runs on different host/port
VITE_API_URL="http://localhost:3001"
# Importer Configurations
# XBVR Importer
+2
View File
@@ -6,3 +6,5 @@ coverage/
*.log
.env*
!.env.example
/docs
/.windsurf
+198
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.
+404
View File
@@ -0,0 +1,404 @@
---
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 4 with shadcn/ui component library
- **Testing Framework**: Vitest + React Testing Library
- **Code Quality**: ESLint + Prettier + Husky
- **UI Components**: Complete shadcn/ui component set (New York style) with Lucide icons
## 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,
},
});
```
## Styling
1. Use the shadcn/ui library unless the user specifies otherwise.
2. Avoid using indigo or blue colors unless specified in the user's request.
3. MUST generate responsive designs.
4. The Code Project is rendered on top of a white background. If a different background color is needed, use a wrapper element with a background color Tailwind class.
---
## UI/UX Design Standards
### Visual Design
- **Color System**: Use Tailwind CSS built-in variables (`bg-primary`, `text-primary-foreground`, `bg-background`).
- **Color Restriction**: NO indigo or blue colors unless explicitly requested.
- **Theme Support**: Implement light/dark mode with `next-themes`.
- **Typography**: Consistent hierarchy with proper font weights and sizes.
### Responsive Design (MANDATORY)
- **Mobile-First**: Design for mobile, then enhance for desktop.
- **Breakpoints**: Use Tailwind responsive prefixes (`sm:`, `md:`, `lg:`, `xl:`).
- **Touch-Friendly**: Minimum 44px touch targets for interactive elements.
### Layout (MANDATORY)
- **Sticky Footer Required**: If a `footer` exists, it MUST stick to the bottom of the viewport when content is shorter than one screen height (no floating/empty gap below).
- **Natural Push on Overflow**: When content exceeds the viewport height, the footer MUST be pushed down naturally (never overlay or cover content).
- **Recommended Implementation (Tailwind)**: Use a root wrapper with `min-h-screen flex flex-col`, and apply `mt-auto` to the `footer`.
- **Mobile Safe Area**: On devices with safe areas (e.g., iOS), the footer MUST respect bottom safe area insets when applicable.
### Accessibility (MANDATORY)
- **Semantic HTML**: Use `main`, `header`, `nav`, `section`, `article`.
- **ARIA Support**: Proper roles, labels, and descriptions.
- **Screen Readers**: Use `sr-only` class for screen reader content.
- **Alt Text**: Descriptive alt text for all images.
- **Keyboard Navigation**: Ensure all elements are keyboard accessible.
### Interactive Elements
- **Loading States**: Show spinners/skeletons during async operations.
- **Error Handling**: Clear, actionable error messages.
- **Feedback**: Toast notifications for user actions.
- **Animations**: Subtle Framer Motion transitions (hover, focus, page transitions).
- **Hover Effects**: Interactive feedback on all clickable elements.
## 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
## Task Management
### Todo-Listen System
Alle AIs MÜSSEN Todo-Listen für komplexe Aufgaben verwenden:
- **Erstellung**: Bei mehreren Schritten oder komplexen Aufgaben eine Todo-Liste erstellen
- **Aktualisierung**: Fortschritt regelmäßig aktualisieren (in_progress, completed)
- **Priorisierung**: Aufgaben mit high/medium/low priorisieren
- **Dokumentation**: Wichtige Entscheidungen in der Todo festhalten
Beispiel Workflow:
1. Todo-Liste am Anfang erstellen mit allen geplanten Schritten
2. Aktuellen Schritt als `in_progress` markieren
3. Erledigte Schritte als `completed` markieren
4. Bei neuen Erkenntnissen die Liste aktualisieren
## 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)
+51
View File
@@ -0,0 +1,51 @@
# Multi-stage build for Omnyx Frontend
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Stage 2: Production
FROM nginx:alpine AS production
# Copy built files to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
RUN echo 'server { \
listen 3000; \
server_name localhost; \
location / { \
root /usr/share/nginx/html; \
index index.html; \
try_files $uri $uri/ /index.html; \
} \
location /api { \
proxy_pass http://backend:3001; \
proxy_http_version 1.1; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
proxy_set_header X-Forwarded-Proto $scheme; \
} \
}' > /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 3000
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+6 -2
View File
@@ -1,6 +1,10 @@
# Kyoo - Media Discovery Platform
![Omnyx Logo](img/logo.png)
A modern web application for browsing, managing, and discovering media across multiple categories. Kyoo provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR.
# Omnyx - Media Discovery Platform
![Omnyx Banner](img/banner.png)
A modern web application for browsing, managing, and discovering media across multiple categories. Omnyx provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR.
## Features
+3 -1
View File
@@ -21,5 +21,7 @@
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
"registries": {
"@acme": "https://acme.com/r/{name}.json"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
<title>Omnyx - Media Discovery</title>
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "Kyoo - Media Discovery",
"name": "Omnyx - Media Discovery",
"description": "A polished media discovery and tracking application inspired by modern anime platforms.",
"requestFramePermissions": []
}
+1404 -7
View File
File diff suppressed because it is too large Load Diff
+14 -4
View File
@@ -8,7 +8,12 @@
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
"lint": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"docs": "typedoc",
"docs:serve": "typedoc && npx serve docs"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -25,18 +30,23 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.14.0",
"shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vite": "^6.2.0"
"vite": "^6.2.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"@vitest/ui": "^4.1.4",
"autoprefixer": "^10.4.21",
"jsdom": "^29.0.2",
"shadcn": "^4.5.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typedoc": "^0.28.19",
"typescript": "~5.8.2",
"vite": "^6.2.0"
"vite": "^6.2.0",
"vitest": "^4.1.4"
}
}
+375 -229
View File
@@ -6,36 +6,72 @@
import { useState, useMemo, useEffect } from 'react';
import { LayoutGroup } from 'motion/react';
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
import Header from './components/Header';
import AppSidebar from './components/sidebar/AppSidebar';
import { SidebarProvider } from '@/components/ui/sidebar';
import BrowseView from './components/BrowseView';
import DashboardView from './components/DashboardView';
import DetailView from './components/DetailView';
import CastView from './components/CastView';
import CastDetailView from './components/CastDetailView';
import AddMediaView from './components/AddMediaView';
import ImporterView from './components/ImporterView';
import SettingsView from './components/SettingsView';
import Loading from './components/ui/loading';
import MediaDetailRoute from './components/routes/MediaDetailRoute';
import CastDetailRoute from './components/routes/CastDetailRoute';
import CategoryBrowseRoute from './components/routes/CategoryBrowseRoute';
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory, UserSettings } from './types';
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
import { Search, Plus, LayoutGrid, List, Filter } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { CATEGORY_PATHS, PATH_TO_CATEGORY, DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from './constants';
import { useAppStore } from './store/appStore';
function AppContent() {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [activeCategory, setActiveCategory] = useState<MediaCategory>(
(searchParams.get('category') as MediaCategory) || 'Anime'
);
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
const [settings, setSettings] = useState<UserSettings | null>(null);
const [customMedia, setCustomMedia] = useState<Media[]>([]);
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
// Load media from API on component mount (only when not on cast routes)
const [apiMedia, setApiMedia] = useState<Media[]>([]);
const { setTheme } = useTheme();
// Zustand store
const {
apiMedia,
customMedia,
adultMedia,
mediaLoading,
selectedMedia,
selectedPerson,
activeCategory,
enabledCategories,
searchQuery,
settings,
setApiMedia,
setCustomMedia,
setAdultMedia,
setMediaLoading,
setSelectedMedia,
setSelectedPerson,
setActiveCategory,
setEnabledCategories,
setSearchQuery,
setSettings,
} = useAppStore();
// Set category from URL path on mount or location change
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean);
if (pathParts.length === 1 && PATH_TO_CATEGORY[pathParts[0]]) {
const category = PATH_TO_CATEGORY[pathParts[0]];
if (enabledCategories.includes(category)) {
setActiveCategory(category);
}
}
}, [location.pathname, enabledCategories, setActiveCategory]);
useEffect(() => {
const loadSettingsFromApi = async () => {
try {
@@ -43,14 +79,48 @@ function AppContent() {
if (loadedSettings) {
setSettings(loadedSettings);
setEnabledCategories(loadedSettings.enabledCategories);
// Sync theme with theme context
setTheme(loadedSettings.theme);
// Set custom page title
if (loadedSettings.pageTitle) {
document.title = loadedSettings.pageTitle;
}
// Set custom favicon
if (loadedSettings.favicon) {
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!faviconLink) {
faviconLink = document.createElement('link');
faviconLink.rel = 'icon';
document.head.appendChild(faviconLink);
}
faviconLink.href = loadedSettings.favicon;
}
}
} catch (error) {
console.error('Failed to load settings from API:', error);
}
};
loadSettingsFromApi();
}, []);
}, [setTheme]);
// Apply custom colors when settings change
useEffect(() => {
if (settings?.customColors) {
const root = document.documentElement;
const colors = settings.customColors;
if (colors.primary) root.style.setProperty('--color-primary', colors.primary);
if (colors.secondary) root.style.setProperty('--color-secondary', colors.secondary);
if (colors.background) root.style.setProperty('--color-background', colors.background);
if (colors.surface) root.style.setProperty('--color-surface', colors.surface);
if (colors.text) root.style.setProperty('--color-text', colors.text);
if (colors.muted) root.style.setProperty('--color-muted', colors.muted);
if (colors.border) root.style.setProperty('--color-border', colors.border);
}
}, [settings?.customColors]);
const reloadSettings = async () => {
try {
@@ -58,6 +128,24 @@ function AppContent() {
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);
@@ -66,11 +154,14 @@ function AppContent() {
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);
}
};
@@ -81,46 +172,35 @@ function AppContent() {
}, [location.pathname]);
const toggleCategory = async (category: MediaCategory) => {
setEnabledCategories(prev => {
const isEnabling = !prev.includes(category);
const newList = isEnabling
? [...prev, category]
: prev.filter(c => c !== category);
// 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);
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);
}
// Save to API
const baseSettings = settings || {
enabledCategories: prev,
itemsPerPage: 20,
defaultView: 'grid',
showAdultContent: false,
autoPlayTrailers: false,
language: 'en',
theme: 'system',
};
const updatedSettings: UserSettings = {
...baseSettings,
enabledCategories: newList,
};
updateSettings(updatedSettings).then(saved => {
if (saved) {
setSettings(saved);
}
});
return newList;
});
};
const handleCategoryChange = (category: MediaCategory) => {
setActiveCategory(category);
setSearchParams({ category });
navigate('/');
navigate(`/${CATEGORY_PATHS[category]}`);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
@@ -134,7 +214,8 @@ function AppContent() {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const allMedia = useMemo(() => {
// All media from enabled categories (for cross-category search)
const allEnabledMedia = useMemo(() => {
// Use API data if available, otherwise fall back to mock data
let list: Media[] = [];
@@ -152,9 +233,14 @@ function AppContent() {
list.push(DETAIL_MEDIA);
}
// Filter by enabled categories only (all enabled categories, not just active)
return list.filter(m => enabledCategories.includes(m.category));
}, [enabledCategories, customMedia, apiMedia]);
const allMedia = useMemo(() => {
// Filter by active category AND ensure it's enabled
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
}, [activeCategory, enabledCategories, customMedia, apiMedia]);
return allEnabledMedia.filter(m => m.category === activeCategory);
}, [activeCategory, allEnabledMedia]);
const handleAddMedia = async () => {
// Reload all media from API to get the newly added item
@@ -166,39 +252,70 @@ function AppContent() {
}
};
const handleGridItemSizeChange = async (size: number) => {
const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories };
const updatedSettings: UserSettings = {
...baseSettings,
gridItemSize: size,
};
updateSettings(updatedSettings).then(saved => {
if (saved) {
setSettings(saved);
}
});
};
const allStaff = useMemo(() => {
const staff: Staff[] = [];
// Use API data if available, otherwise fall back to mock data
let baseList: Media[] = [];
const staffIds = new Set<string>(); // Track unique staff to avoid duplicates
if (apiMedia.length > 0) {
// API has data, use it
baseList = [...apiMedia];
} else {
// API is empty, use mock data as fallback
baseList = [...MOCK_MEDIA];
}
// Add custom media and detail media
baseList = [...baseList, ...customMedia];
if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) {
baseList.push(DETAIL_MEDIA);
}
const enabledMedia = baseList.filter(m => enabledCategories.includes(m.category));
enabledMedia.forEach(media => {
// Use allEnabledMedia which already has enabled categories filtered
allEnabledMedia.forEach(media => {
media.staff?.forEach(s => {
staff.push({
...s,
mediaId: media.id,
mediaTitle: media.title
});
// Avoid duplicate staff entries
if (!staffIds.has(s.id)) {
staffIds.add(s.id);
staff.push({
...s,
mediaId: media.id,
mediaTitle: media.title
});
}
});
});
return staff;
}, [enabledCategories, customMedia, apiMedia]);
}, [allEnabledMedia]);
// Search across all enabled media (all categories)
const searchResultsMedia = useMemo(() => {
if (!searchQuery.trim()) return [];
const query = searchQuery.toLowerCase();
return allEnabledMedia.filter(media =>
media.title.toLowerCase().includes(query) ||
media.year.toLowerCase().includes(query) ||
media.genres?.some(g => g.toLowerCase().includes(query)) ||
media.studios?.some(s => s.toLowerCase().includes(query)) ||
media.description?.toLowerCase().includes(query) ||
media.tags?.some(t => t.toLowerCase().includes(query)) ||
media.developers?.some(d => d.toLowerCase().includes(query)) ||
media.platforms?.some(p => p.toLowerCase().includes(query))
);
}, [allEnabledMedia, searchQuery]);
// Search cast members
const searchResultsCast = useMemo(() => {
if (!searchQuery.trim()) return [];
const query = searchQuery.toLowerCase();
return allStaff.filter(staff =>
staff.name.toLowerCase().includes(query) ||
staff.role.toLowerCase().includes(query) ||
staff.bio?.toLowerCase().includes(query) ||
staff.occupations?.some(o => o.toLowerCase().includes(query)) ||
staff.characterName?.toLowerCase().includes(query)
);
}, [allStaff, searchQuery]);
// Legacy filteredMedia for backward compatibility (searches within current category)
const filteredMedia = useMemo(() => {
if (!searchQuery.trim()) return allMedia;
const query = searchQuery.toLowerCase();
@@ -266,169 +383,198 @@ function AppContent() {
params.delete('search');
}
setSearchParams(params);
navigate('/');
navigate('/browse');
};
return (
<div className="min-h-screen bg-white font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
<Header
onSearch={handleSearch}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
enabledCategories={enabledCategories}
onToggleCategory={toggleCategory}
transparent={location.pathname.startsWith('/media/') || location.pathname.startsWith('/cast/')}
/>
<main>
<LayoutGroup>
<Routes>
<Route path="/" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory={activeCategory}
itemsPerPage={settings?.itemsPerPage}
/>
} />
<Route path="/media/:id" element={
<MediaDetailRoute
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
allMedia={allMedia}
onPersonClick={handlePersonClick}
/>
} />
<Route path="/cast" element={
<CastView
onPersonClick={handlePersonClick}
enabledCategories={enabledCategories}
itemsPerPage={settings?.itemsPerPage}
/>
} />
<Route path="/cast/:id" element={
<CastDetailRoute
selectedPerson={selectedPerson}
setSelectedPerson={setSelectedPerson}
/>
} />
<Route path="/add" element={
<AddMediaView
activeCategory={activeCategory}
onAddComplete={handleAddMedia}
/>
} />
<Route path="/import" element={
<ImporterView />
} />
<Route path="/settings" element={
<SettingsView onSettingsSaved={reloadSettings} />
} />
</Routes>
</LayoutGroup>
</main>
// Calculate media counts for sidebar (all categories)
const mediaCounts = useMemo(() => {
const counts: Record<string, number> = {};
// Count all enabled categories using allEnabledMedia
enabledCategories.forEach(cat => {
counts[cat] = allEnabledMedia.filter(m => m.category === cat).length;
});
// Add favorites count
counts['favorites'] = allEnabledMedia.filter(m => m.rating && m.rating >= 8).length;
// Add total count
counts['all'] = allEnabledMedia.length;
return counts;
}, [allEnabledMedia, enabledCategories]);
{/* Footer */}
<footer className="py-12 px-6 border-t border-zinc-100 bg-zinc-50">
<div className="max-w-[1600px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2 text-xl font-black text-zinc-400">
<div className="w-5 h-5 bg-zinc-300 rounded-full" />
kyoo
// Calculate active filter based on current URL
const activeFilter = useMemo(() => {
const path = location.pathname;
// Map routes to filter IDs
const routeMap: Record<string, string> = {
'/anime': 'anime',
'/movies': 'movies',
'/tv-series': 'tv-series',
'/music': 'music',
'/books': 'books',
'/adult': 'adult',
'/consoles': 'consoles',
'/games': 'games',
};
if (routeMap[path]) return routeMap[path];
if (searchParams.get('favorites') === 'true') return 'favorites';
return undefined;
}, [location.pathname, searchParams]);
return (
<div className="min-h-screen bg-background font-sans selection:bg-[#e8466c]/20 selection:text-[#e8466c] flex">
<SidebarProvider defaultOpen={true}>
<AppSidebar
enabledCategories={enabledCategories}
onToggleCategory={toggleCategory}
pageTitle={settings?.pageTitle || 'MediaVault'}
mediaCounts={mediaCounts}
activeFilter={activeFilter}
/>
<main className="flex-1 flex flex-col relative">
{/* Header with Search and Add Media */}
<header className="sticky top-0 z-30 bg-background/80 backdrop-blur-xl border-b border-border px-6 py-4">
<div className="flex items-center justify-between gap-4 max-w-[1920px] mx-auto">
{/* Search Bar */}
<div className="flex-1 max-w-xl">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
type="text"
placeholder="Search library..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-muted/30 border-border rounded-lg text-foreground placeholder:text-muted-foreground focus:border-[#e8466c]/50 focus:ring-[#e8466c]/20"
/>
</div>
</div>
{/* View Toggle and Add Button */}
<div className="flex items-center gap-3">
<div className="flex items-center bg-muted/30 rounded-lg p-1 border border-border">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded bg-accent text-accent-foreground"
>
<LayoutGrid className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
>
<List className="w-4 h-4" />
</Button>
</div>
<Button
onClick={handleAddMediaView}
className="bg-[#e8466c] hover:bg-[#d13d60] text-white font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<Plus className="w-4 h-4" />
Add Media
</Button>
</div>
</div>
<div className="flex items-center gap-8 text-sm font-bold text-zinc-400">
<a href="#" className="hover:text-[#6d28d9] transition-colors">Terms</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Privacy</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Contact</a>
</div>
<p className="text-xs font-medium text-zinc-400">
© 2026 Kyoo Media Discovery. All rights reserved.
</p>
</header>
<div className="flex-1">
<LayoutGroup>
<Routes>
<Route path="/" element={
<DashboardView
mediaList={apiMedia.length > 0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))}
onMediaClick={handleMediaClick}
loading={mediaLoading}
/>
} />
<Route path="/browse" element={
<BrowseView
mediaList={searchQuery.trim() ? searchResultsMedia : allMedia}
onMediaClick={handleMediaClick}
activeCategory={activeCategory}
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
searchResultsCast={searchQuery.trim() ? searchResultsCast : []}
onCastClick={handlePersonClick}
searchQuery={searchQuery}
/>
} />
<Route path="/:category" element={
<CategoryBrowseRoute
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/>
} />
<Route path="/media/:id" element={
<MediaDetailRoute
allMedia={allMedia}
onPersonClick={handlePersonClick}
/>
} />
<Route path="/cast" element={
<CastView
onPersonClick={handlePersonClick}
enabledCategories={enabledCategories}
itemsPerPage={settings?.itemsPerPage}
/>
} />
<Route path="/cast/:id" element={
<CastDetailRoute />
} />
<Route path="/add" element={
<AddMediaView
activeCategory={activeCategory}
enabledCategories={enabledCategories}
onAddComplete={handleAddMedia}
/>
} />
<Route path="/import" element={
<ImporterView />
} />
<Route path="/settings" element={
<SettingsView onSettingsSaved={reloadSettings} />
} />
</Routes>
</LayoutGroup>
</div>
</footer>
{/* Footer */}
<footer className="mt-auto py-3 px-6 border-t border-border bg-background">
<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-sm font-medium text-muted-foreground">
<span>{mediaCounts.all} total</span>
<span className="text-border-foreground"></span>
<span className="text-blue-400">{mediaCounts.movies} Movies</span>
<span className="text-green-400">{mediaCounts.series} Series</span>
<span className="text-purple-400">{mediaCounts.games} Games</span>
<span className="text-red-400">{mediaCounts.adult} Adult</span>
<span className="text-border-foreground"></span>
<span className="text-[#e8466c]">{mediaCounts.favorites} Favorites</span>
</div>
<p className="text-xs text-muted-foreground">
© 2026 MediaVault v1.0.0
</p>
</div>
</footer>
</main>
</SidebarProvider>
</div>
);
}
// Helper component for media detail route
function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
useEffect(() => {
const loadMedia = async () => {
if (id) {
// First check if media is in allMedia
const media = allMedia.find(m => m.id === id);
if (media) {
setSelectedMedia(media);
} else {
// If not found, fetch from API
try {
const fetchedMedia = await fetchMediaById(id);
if (fetchedMedia) {
setSelectedMedia(fetchedMedia);
} else {
navigate('/');
}
} catch (error) {
console.error('Failed to fetch media:', error);
navigate('/');
}
}
}
};
loadMedia();
}, [id, allMedia]);
if (!selectedMedia) return null;
return (
<DetailView
media={selectedMedia}
onPersonClick={onPersonClick}
/>
);
}
// Helper component for cast detail route
function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
useEffect(() => {
const loadCast = async () => {
if (id) {
try {
const castData = await fetchCastById(id);
if (castData) {
const person = convertApiCastToStaff(castData);
setSelectedPerson(person);
} else {
navigate('/cast');
}
} catch (error) {
console.error('Failed to load cast:', error);
navigate('/cast');
}
}
};
loadCast();
}, [id]);
if (!selectedPerson) return null;
return (
<CastDetailView
person={selectedPerson}
relatedMedia={[]}
/>
);
}
export default function App() {
return (
<BrowserRouter>
<AppContent />
<ThemeProvider>
<AppContent />
</ThemeProvider>
</BrowserRouter>
);
}
+14 -733
View File
@@ -1,577 +1,14 @@
import { Media, Staff, UserSettings, MediaCategory } from './types';
// Re-export all API functions for backward compatibility
export * from './lib/api/mediaApi';
export * from './lib/api/castApi';
export * from './lib/api/settingsApi';
export * from './lib/api/converters';
export * from './lib/api/types';
const BASE_URL = import.meta.env.VITE_API_URL;
function normalizeUrl(url: string | null): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Remove leading slash if present and add base URL
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
return `${BASE_URL}/${cleanPath}`;
}
// API Response Types
export interface ApiResponse<T> {
success: boolean;
data: T;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
totalPages?: number;
}
// Media Types
export interface ApiMediaItem {
id: number;
title: string;
year: number;
poster: string | null;
banner: string | null;
description: string | null;
rating: number | null;
category: string | null;
type: string;
status: string;
aspectRatio: string | null;
runtime: number | null;
director: string | null;
writer: string | null;
releaseDate: string | null;
createdAt: string;
updatedAt: string;
genres?: string[];
tags?: string[];
studios?: string[];
staff?: ApiStaff[];
categories?: string[];
platforms?: string[];
developers?: string[];
completionStatus?: string;
source?: string;
playCount?: number;
lastActivity?: string | null;
playtime?: number;
}
export interface ApiStaff {
id: number;
name: string;
photo: string | null;
bio: string | null;
birthDate: string | null;
birthPlace: string | null;
role: string;
characterName: string | null;
characterImage: string | null;
occupations?: string[];
}
export interface CreateMediaInput {
title: string;
year: number;
poster?: string | null;
banner?: string | null;
description?: string | null;
rating?: number | null;
category?: string | null;
type?: string;
status?: string;
aspectRatio?: string | null;
runtime?: number | null;
director?: string | null;
writer?: string | null;
releaseDate?: string | null;
genres?: string[];
tags?: string[];
studios?: string[];
staff?: CreateStaffInput[];
}
export interface UpdateMediaInput extends Partial<CreateMediaInput> {}
export interface CreateStaffInput {
name: string;
photo?: string | null;
bio?: string | null;
birthDate?: string | null;
birthPlace?: string | null;
role: string;
characterName?: string | null;
characterImage?: string | null;
occupations?: string[];
}
// Cast Types
export interface ApiCastItem {
id: number;
name: string;
cleanname?: string;
photo: string | null;
bio: string | null;
birthDate: string | null;
birthPlace: string | null;
createdAt: string;
updatedAt: string;
occupations?: string[];
filmography?: ApiCastMediaItem[];
media_types?: string[];
bust_size?: number | null;
cup_size?: string | null;
waist_size?: number | null;
hip_size?: number | null;
height?: number | null;
weight?: number | null;
hair_color?: string | null;
eye_color?: string | null;
ethnicity?: string | null;
adult_specifics?: {
id: number;
cast_id: number;
bust_size?: number | null;
cup_size?: string | null;
waist_size?: number | null;
hip_size?: number | null;
height?: number | null;
weight?: number | null;
hair_color?: string | null;
eye_color?: string | null;
ethnicity?: string | null;
tattoos?: string | null;
piercings?: string | null;
measurements?: string | null;
shoe_size?: number | null;
};
}
export interface ApiCastMediaItem {
id: number;
title: string;
year: number;
poster: string | null;
category: string | null;
type: string;
role: string;
characterName?: string | null;
}
export interface CreateCastInput {
name: string;
photo?: string | null;
bio?: string | null;
birthDate?: string | null;
birthPlace?: string | null;
occupations?: string[];
}
export interface UpdateCastInput extends Partial<CreateCastInput> {}
export function convertApiCastToStaff(apiItem: ApiCastItem): Staff {
return {
id: apiItem.id.toString(),
name: apiItem.name,
cleanname: apiItem.cleanname,
role: apiItem.occupations?.[0] || 'Actor',
photo: normalizeUrl(apiItem.photo) || `https://picsum.photos/seed/cast-${apiItem.id}/200/200`,
bio: apiItem.bio || undefined,
birthDate: apiItem.birthDate || undefined,
birthPlace: apiItem.birthPlace || undefined,
occupations: apiItem.occupations || ['Actor'],
createdAt: apiItem.createdAt,
updatedAt: apiItem.updatedAt,
bust_size: apiItem.bust_size,
cup_size: apiItem.cup_size,
waist_size: apiItem.waist_size,
hip_size: apiItem.hip_size,
height: apiItem.height,
weight: apiItem.weight,
hair_color: apiItem.hair_color,
eye_color: apiItem.eye_color,
ethnicity: apiItem.ethnicity,
filmography: apiItem.filmography?.map(item => ({
id: item.id,
title: item.title,
year: item.year,
poster: normalizeUrl(item.poster) || `https://picsum.photos/seed/${item.id}/400/600`,
category: item.category,
type: item.type,
role: item.role,
characterName: item.characterName
})),
media_types: apiItem.media_types,
adult_specifics: apiItem.adult_specifics
};
}
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
// Convert staff from API to Media staff format
const staff: Staff[] = (apiItem.staff || []).map((staffMember) => ({
id: staffMember.id.toString(),
name: staffMember.name,
role: staffMember.role,
photo: normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
characterName: staffMember.characterName || staffMember.name,
characterImage: normalizeUrl(staffMember.characterImage) || normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
}));
// Determine aspect ratio from API format
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
if (apiItem.aspectRatio) {
const ratio = apiItem.aspectRatio.toLowerCase();
if (ratio.includes('16:9') || ratio.includes('16/9') || ratio.includes('1.78') || ratio.includes('2.39')) {
aspectRatio = '16/9';
} else if (ratio.includes('1:1') || ratio.includes('1/1') || ratio.includes('1.00')) {
aspectRatio = '1/1';
} else if (ratio.includes('2/3')) {
aspectRatio = '2/3';
}
}
// Map API type to Media type allowed values
let mediaType: 'TV' | 'Movie' | 'OVA' | 'ONA' | 'Album' | 'Single' | 'Hardcover' | 'E-book' | 'Console' | 'Game' = 'Movie';
const apiType = apiItem.type?.toLowerCase();
if (apiType === 'tv' || apiType === 'episode') {
mediaType = 'TV';
} else if (apiType === 'album' || apiType === 'single') {
mediaType = apiType === 'album' ? 'Album' : 'Single';
} else if (apiType === 'game' || apiType === 'console') {
mediaType = apiType === 'game' ? 'Game' : 'Console';
} else if (apiType === 'ova') {
mediaType = 'OVA';
} else if (apiType === 'ona') {
mediaType = 'ONA';
} else if (apiType === 'hardcover' || apiType === 'e-book') {
mediaType = apiType === 'hardcover' ? 'Hardcover' : 'E-book';
}
// Map API category to MediaCategory
let mediaCategory: 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games' = 'Movies';
const apiCategory = apiItem.category?.toLowerCase();
if (apiCategory === 'anime') {
mediaCategory = 'Anime';
} else if (apiCategory === 'movie' || apiCategory === 'movies') {
mediaCategory = 'Movies';
} else if (apiCategory === 'tv' || apiCategory === 'series' || apiCategory === 'tv series' || apiType === 'tv' || apiType === 'episode') {
mediaCategory = 'TV Series';
} else if (apiCategory === 'music' || apiType === 'album' || apiType === 'single') {
mediaCategory = 'Music';
} else if (apiCategory === 'book' || apiCategory === 'books' || apiType === 'hardcover' || apiType === 'e-book') {
mediaCategory = 'Books';
} else if (apiCategory === 'adult') {
mediaCategory = 'Adult';
} else if (apiCategory === 'console' || apiCategory === 'consoles' || apiType === 'console') {
mediaCategory = 'Consoles';
} else if (apiCategory === 'game' || apiCategory === 'games' || apiType === 'game') {
mediaCategory = 'Games';
} else {
// If category doesn't match any known category, use the original value capitalized
// This handles cases where the API returns unexpected category values
console.warn('Unknown category:', apiItem.category, 'defaulting to Movies');
mediaCategory = 'Movies';
}
// Map API status to Media status allowed values
let mediaStatus: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold' = 'completed';
const apiStatus = apiItem.status?.toLowerCase();
if (apiStatus === 'ongoing' || apiStatus === 'watching') {
mediaStatus = 'watching';
} else if (apiStatus === 'upcoming' || apiStatus === 'planned') {
mediaStatus = 'planned';
} else if (apiStatus === 'dropped') {
mediaStatus = 'dropped';
} else if (apiStatus === 'reading') {
mediaStatus = 'reading';
} else if (apiStatus === 'listening') {
mediaStatus = 'listening';
} else if (apiStatus === 'playing') {
mediaStatus = 'playing';
} else if (apiStatus === 'on-hold') {
mediaStatus = 'on-hold';
}
return {
id: apiItem.id.toString(),
title: apiItem.title,
year: apiItem.year?.toString() || 'Unknown',
poster: normalizeUrl(apiItem.poster) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
category: mediaCategory,
banner: normalizeUrl(apiItem.banner) || undefined,
description: apiItem.description || undefined,
rating: apiItem.rating || undefined,
genres: apiItem.genres || [],
tags: apiItem.tags || [],
studios: apiItem.studios,
type: mediaType,
status: mediaStatus,
staff: staff.length > 0 ? staff : undefined,
aspectRatio: aspectRatio,
categories: apiItem.categories,
platforms: apiItem.platforms,
developers: apiItem.developers,
completionStatus: apiItem.completionStatus,
source: apiItem.source,
playCount: apiItem.playCount,
lastActivity: apiItem.lastActivity,
playtime: apiItem.playtime
};
}
// Media API Functions
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
try {
const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching media from API:', error);
return [];
}
}
export async function fetchMediaById(id: number | string): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error fetching media by ID:', error);
return null;
}
}
export async function createMedia(media: CreateMediaInput): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(media),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error creating media:', error);
return null;
}
}
export async function updateMedia(id: number | string, media: UpdateMediaInput): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(media),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error updating media:', error);
return null;
}
}
export async function deleteMedia(id: number | string): Promise<boolean> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<{ message: string }> = await response.json();
return data.success;
} catch (error) {
console.error('Error deleting media:', error);
return false;
}
}
// Cast API Functions
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<Staff[]> {
try {
const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<ApiCastItem>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiCastToStaff);
}
return [];
} catch (error) {
console.error('Error fetching cast from API:', error);
return [];
}
}
export async function fetchCastById(id: number | string): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error fetching cast by ID:', error);
return null;
}
}
export async function fetchCastMedia(castId: number | string): Promise<Media[]> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${castId}/media`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching cast media:', error);
return [];
}
}
export async function createCast(cast: CreateCastInput): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cast),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error creating cast:', error);
return null;
}
}
export async function updateCast(id: number | string, cast: UpdateCastInput): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cast),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error updating cast:', error);
return null;
}
}
export async function deleteCast(id: number | string): Promise<boolean> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<{ message: string }> = await response.json();
return data.success;
} catch (error) {
console.error('Error deleting cast:', error);
return false;
}
}
// Legacy function for compatibility - fetches all unique staff members from media
export async function fetchAllActors(): Promise<Array<{id: number, name: string, photo: string | null}>> {
try {
const media = await fetchAllMedia(1, 1000);
const actorMap = new Map<number, {id: number, name: string, photo: string | null}>();
media.forEach(item => {
item.staff?.forEach(staffMember => {
const id = parseInt(staffMember.id);
if (!actorMap.has(id)) {
actorMap.set(id, {
id: id,
name: staffMember.name,
photo: staffMember.photo
});
}
});
});
return Array.from(actorMap.values());
} catch (error) {
console.error('Error fetching all actors:', error);
return [];
}
}
// Legacy function for compatibility - fetches all unique tags from media
// Legacy functions for compatibility
export async function fetchAllTags(): Promise<string[]> {
try {
const { fetchAllMedia } = await import('./lib/api/mediaApi');
const media = await fetchAllMedia(1, 1000);
const tagSet = new Set<string>();
@@ -587,24 +24,9 @@ export async function fetchAllTags(): Promise<string[]> {
}
}
// Legacy function for compatibility - fetches media by actor name
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
try {
const media = await fetchAllMedia(1, 1000);
return media.filter(item =>
item.staff?.some(staffMember =>
staffMember.name.toLowerCase().includes(actorName.toLowerCase())
)
);
} catch (error) {
console.error('Error fetching media by actor:', error);
return [];
}
}
// Legacy function for compatibility - fetches media by tag
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
export async function fetchMediaByTag(tag: string) {
try {
const { fetchAllMedia } = await import('./lib/api/mediaApi');
const media = await fetchAllMedia(1, 1000);
return media.filter(item =>
item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) ||
@@ -616,153 +38,12 @@ export async function fetchMediaByTag(tag: string): Promise<Media[]> {
}
}
// Convenience function - fetch media from API (legacy compatibility)
export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> {
export async function fetchMediaFromApi(apiUrl?: string) {
const { fetchAllMedia } = await import('./lib/api/mediaApi');
return fetchAllMedia();
}
// Convenience function - fetch media from local JSON (legacy compatibility)
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
export async function fetchMediaFromLocalJson() {
const { fetchAllMedia } = await import('./lib/api/mediaApi');
return fetchAllMedia();
}
// Settings API Types
export interface ApiSettingsItem {
id?: number;
enabled_categories: string[];
items_per_page: number;
default_view: string;
show_adult_content: boolean;
auto_play_trailers: boolean;
language: string;
theme: string;
created_at?: string;
updated_at?: string;
}
export interface CreateSettingsInput {
enabled_categories: string[];
items_per_page?: number;
default_view?: string;
show_adult_content?: boolean;
auto_play_trailers?: boolean;
language?: string;
theme?: string;
}
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
return {
id: apiItem.id,
enabledCategories: apiItem.enabled_categories as MediaCategory[],
itemsPerPage: apiItem.items_per_page || 20,
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',
createdAt: apiItem.created_at,
updatedAt: apiItem.updated_at,
};
}
export function convertSettingsToApi(settings: UserSettings): CreateSettingsInput {
return {
enabled_categories: settings.enabledCategories,
items_per_page: settings.itemsPerPage,
default_view: settings.defaultView,
show_adult_content: settings.showAdultContent,
auto_play_trailers: settings.autoPlayTrailers,
language: settings.language,
theme: settings.theme,
};
}
// Settings API Functions
export async function fetchSettings(): Promise<UserSettings | null> {
try {
const response = await fetch(`${BASE_URL}/api/settings`);
if (!response.ok) {
// If settings don't exist (404), return null to use defaults
if (response.status === 404) {
return null;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiSettingsItem> = await response.json();
if (data.success && data.data) {
return convertApiToSettings(data.data);
}
return null;
} catch (error) {
console.error('Error fetching settings:', error);
return null;
}
}
export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
try {
const apiSettings = convertSettingsToApi(settings);
console.log('Creating settings:', apiSettings);
const response = await fetch(`${BASE_URL}/api/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiSettings),
});
console.log('Create settings response status:', response.status);
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();
console.log('Create settings response:', data);
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);
console.log('Updating settings:', apiSettings);
const response = await fetch(`${BASE_URL}/api/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiSettings),
});
console.log('Update settings response status:', response.status);
if (!response.ok) {
// If settings don't exist (404), try creating them instead
if (response.status === 404) {
console.log('Settings not found, attempting to create...');
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();
console.log('Update settings response:', data);
if (data.success && data.data) {
return convertApiToSettings(data.data);
}
return null;
} catch (error) {
console.error('Error updating settings:', error);
return null;
}
}
+431 -201
View File
@@ -5,15 +5,16 @@ import { Label } from '@/components/ui/label';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { createMedia, type CreateMediaInput } from '@/api';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Film, Calendar, Star, User, BookOpen, Music as MusicIcon, Gamepad2, Monitor, Hash, Tag, Users, FileText, Globe, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
interface AddMediaViewProps {
activeCategory: MediaCategory;
enabledCategories: MediaCategory[];
onAddComplete: () => void;
}
export default function AddMediaView({ activeCategory, onAddComplete }: AddMediaViewProps) {
export default function AddMediaView({ activeCategory, enabledCategories, onAddComplete }: AddMediaViewProps) {
const navigate = useNavigate();
const [newMedia, setNewMedia] = useState({
title: '',
@@ -30,6 +31,7 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
director: '',
writer: '',
releaseDate: '',
source: '' as string,
genres: '' as string,
tags: '' as string,
studios: '' as string
@@ -37,6 +39,29 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const [staff, setStaff] = useState<Array<{ name: string; role: string; characterName?: string; photo?: string }>>([]);
const addStaffMember = () => {
const nameInput = document.getElementById('staffName') as HTMLInputElement;
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
const characterInput = document.getElementById('staffCharacter') as HTMLInputElement;
const photoInput = document.getElementById('staffPhoto') as HTMLInputElement;
if (nameInput?.value && roleInput?.value) {
setStaff(prev => [...prev, {
name: nameInput.value,
role: roleInput.value,
characterName: characterInput?.value || undefined,
photo: photoInput?.value || undefined
}]);
// Clear the form
if (nameInput) nameInput.value = '';
if (roleInput) roleInput.value = '';
if (characterInput) characterInput.value = '';
if (photoInput) photoInput.value = '';
}
};
// Update category, default aspect ratio, and default type when activeCategory changes
useEffect(() => {
@@ -105,9 +130,16 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
director: newMedia.director || null,
writer: newMedia.writer || null,
releaseDate: newMedia.releaseDate || null,
source: newMedia.source || null,
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : []
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : [],
staff: staff.length > 0 ? staff.map(s => ({
name: s.name,
role: s.role,
characterName: s.characterName || null,
photo: s.photo || null
})) : undefined
};
try {
@@ -133,10 +165,12 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
director: '',
writer: '',
releaseDate: '',
source: '',
genres: '',
tags: '',
studios: ''
});
setStaff([]);
}
} catch (error) {
setSubmitStatus('error');
@@ -146,82 +180,112 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
}
};
const getCategoryIcon = (category: MediaCategory) => {
const icons: Record<MediaCategory, any> = {
'Anime': <Film size={18} />,
'Movies': <Film size={18} />,
'TV Series': <Film size={18} />,
'Music': <MusicIcon size={18} />,
'Books': <BookOpen size={18} />,
'Games': <Gamepad2 size={18} />,
'Consoles': <Monitor size={18} />,
'Adult': <Star size={18} />
};
return icons[category] || <Film size={18} />;
};
return (
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
<div className="pt-24 pb-12 px-6">
<Button
variant="ghost"
onClick={() => navigate('/')}
className="mb-6 gap-2 text-zinc-600 hover:text-zinc-900"
className="mb-6 gap-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-xl transition-all duration-300"
>
<ArrowLeft size={20} />
Back to Browse
</Button>
<div className="bg-white rounded-3xl shadow-xl p-8">
<h1 className="text-3xl font-black text-zinc-900 mb-2">Add New Media</h1>
<p className="text-zinc-500 font-medium mb-8">
Add a new item to your {activeCategory} library.
</p>
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center shadow-lg shadow-[#e8466c]/30">
{getCategoryIcon(activeCategory)}
</div>
<div>
<h1 className="text-4xl font-black text-foreground mb-1">Add New Media</h1>
<p className="text-muted-foreground font-medium text-lg">
Add a new item to your {activeCategory} library.
</p>
</div>
</div>
{submitStatus === 'success' && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
<p className="text-green-800 font-bold"> Successfully added to library!</p>
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl backdrop-blur-sm">
<p className="text-green-500 font-bold"> Successfully added to library!</p>
</div>
)}
{submitStatus === 'error' && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
<p className="text-red-800 font-bold"> Error: {errorMessage}</p>
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl backdrop-blur-sm">
<p className="text-red-500 font-bold"> Error: {errorMessage}</p>
</div>
)}
<form onSubmit={handleAddSubmit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="title" className="text-sm font-black text-zinc-700">Title</Label>
<Input
id="title"
value={newMedia.title}
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g. Mob Psycho 100"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="year" className="text-sm font-black text-zinc-700">Year</Label>
<Input
id="year"
value={newMedia.year}
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
placeholder="2024"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
<form onSubmit={handleAddSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Info Card */}
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
<FileText size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
</div>
<div className="grid gap-2">
<Label htmlFor="category" className="text-sm font-black text-zinc-700">Category</Label>
<select
id="category"
value={newMedia.category}
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="type" className="text-sm font-black text-zinc-700">Type</Label>
<select
id="type"
value={newMedia.type}
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{newMedia.category === 'Music' ? (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="title" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Title</Label>
<Input
id="title"
value={newMedia.title}
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g. Mob Psycho 100"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/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-[#e8466c]/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-[#e8466c]/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-[#e8466c]/50 outline-none"
>
{newMedia.category === 'Music' ? (
<>
<option value="Album">Album</option>
<option value="Single">Single</option>
@@ -250,171 +314,337 @@ export default function AddMediaView({ activeCategory, onAddComplete }: AddMedia
<option value="Movie">Movie</option>
</>
)}
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="status" className="text-sm font-black text-zinc-700">Status</Label>
<select
id="status"
value={newMedia.status}
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
<option value="Released">Released</option>
<option value="Ongoing">Ongoing</option>
<option value="Upcoming">Upcoming</option>
<option value="Completed">Completed</option>
<option value="Watching">Watching</option>
<option value="Reading">Reading</option>
<option value="Listening">Listening</option>
<option value="Playing">Playing</option>
<option value="Dropped">Dropped</option>
<option value="On Hold">On Hold</option>
</select>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="status" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Status</Label>
<select
id="status"
value={newMedia.status}
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/50 outline-none"
>
<option value="Released">Released</option>
<option value="Ongoing">Ongoing</option>
<option value="Upcoming">Upcoming</option>
<option value="Completed">Completed</option>
<option value="Watching">Watching</option>
<option value="Reading">Reading</option>
<option value="Listening">Listening</option>
<option value="Playing">Playing</option>
<option value="Dropped">Dropped</option>
<option value="On Hold">On Hold</option>
</select>
</div>
</div>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label>
<select
id="aspectRatio"
value={newMedia.aspectRatio}
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
<option value="2/3">2:3 (Standard Poster)</option>
<option value="16/9">16:9 (Wide Thumbnail)</option>
<option value="1/1">1:1 (Square)</option>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="poster" className="text-sm font-black text-zinc-700">Poster URL</Label>
<Input
id="poster"
value={newMedia.poster}
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
placeholder="https://example.com/poster.jpg"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="banner" className="text-sm font-black text-zinc-700">Banner URL (Optional)</Label>
<Input
id="banner"
value={newMedia.banner}
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
placeholder="https://example.com/banner.jpg"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description" className="text-sm font-black text-zinc-700">Description (Optional)</Label>
<textarea
id="description"
value={newMedia.description}
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
placeholder="Brief description..."
className="bg-zinc-50 border-zinc-100 rounded-xl p-3 h-20 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none resize-none"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="rating" className="text-sm font-black text-zinc-700">Rating (Optional)</Label>
<Input
id="rating"
type="number"
step="0.1"
min="0"
max="10"
value={newMedia.rating}
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
placeholder="8.5"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
<>
{/* Media Info Card */}
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] 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-[#e8466c]/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-[#e8466c]/50"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="runtime" className="text-sm font-black text-zinc-700">Runtime (min)</Label>
<Label htmlFor="aspectRatio" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Aspect Ratio</Label>
<select
id="aspectRatio"
value={newMedia.aspectRatio}
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/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-[#e8466c]/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-[#e8466c]/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-[#e8466c] shadow-sm">
<Clock size={16} />
</div>
<h3 className="text-lg font-black text-foreground">Production Details</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="runtime" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Runtime (minutes)</Label>
<Input
id="runtime"
type="number"
value={newMedia.runtime}
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
placeholder="120"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="releaseDate" className="text-sm font-black text-zinc-700">Release Date</Label>
<Label htmlFor="releaseDate" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Release Date</Label>
<Input
id="releaseDate"
type="date"
value={newMedia.releaseDate}
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/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-[#e8466c]/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-[#e8466c]/50"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="director" className="text-sm font-black text-zinc-700">Director</Label>
<Input
id="director"
value={newMedia.director}
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
placeholder="Director name"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="writer" className="text-sm font-black text-zinc-700">Writer</Label>
<Input
id="writer"
value={newMedia.writer}
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
placeholder="Writer name"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
</>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="genres" className="text-sm font-black text-zinc-700">Genres (comma-separated)</Label>
<Input
id="genres"
value={newMedia.genres}
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
placeholder="Action, Drama, Sci-Fi"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
{/* Classification Card */}
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] 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-[#e8466c]/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-[#e8466c]/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-[#e8466c]/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-[#e8466c]/50"
/>
</div>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="tags" className="text-sm font-black text-zinc-700">Tags (comma-separated)</Label>
<Input
id="tags"
value={newMedia.tags}
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
placeholder="Classic, Best-selling"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
{/* Cast/Staff Card */}
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50 lg:col-span-2">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] 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-[#e8466c]/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-[#e8466c]/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-[#e8466c]/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-[#e8466c]/50"
/>
</div>
<Button
type="button"
onClick={addStaffMember}
variant="outline"
className="w-full border-border/50 text-sm font-bold hover:border-[#e8466c]/50 hover:bg-[#e8466c]/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-[#e8466c] to-[#f47298] hover:from-[#d13d60] hover:to-[#c5304e] text-white font-black h-12 rounded-xl shadow-lg shadow-[#e8466c]/30 transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
</Button>
</div>
<div className="grid gap-2">
<Label htmlFor="studios" className="text-sm font-black text-zinc-700">Studios (comma-separated)</Label>
<Input
id="studios"
value={newMedia.studios}
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
placeholder="Studio A, Studio B"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<Button
type="submit"
disabled={isSubmitting}
className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
</Button>
</form>
</div>
</div>
+397 -250
View File
@@ -1,44 +1,74 @@
import { Media, MediaCategory } from '@/types';
import { Media, MediaCategory, Staff } from '@/types';
import MediaCard from './MediaCard';
import MediaListItem from './MediaListItem';
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree } from 'lucide-react';
import MediaTable from './MediaTable';
import MediaFilters from './filters/MediaFilters';
import { LayoutGrid, List, User, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import Loading from '@/components/ui/loading';
import React, { useState, useMemo, useEffect } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { AnimatePresence } from 'motion/react';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
interface BrowseViewProps {
mediaList: Media[];
onMediaClick: (media: Media) => void;
activeCategory: MediaCategory;
itemsPerPage?: number;
gridItemSize?: number;
onGridItemSizeChange?: (size: number) => void;
loading?: boolean;
searchResultsCast?: Staff[];
onCastClick?: (person: Staff) => void;
searchQuery?: string;
}
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12 }: BrowseViewProps) {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
export default function BrowseView({
mediaList,
onMediaClick,
activeCategory,
itemsPerPage: initialItemsPerPage = 12,
gridItemSize: initialGridItemSize = 5,
onGridItemSizeChange,
loading = false,
searchResultsCast = [],
onCastClick,
searchQuery = ''
}: BrowseViewProps) {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
const [sortBy, setSortBy] = useState<string>('default');
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
// Sync itemsPerPage with prop when API settings are loaded
useEffect(() => {
if (initialItemsPerPage) {
setItemsPerPage(initialItemsPerPage);
}
}, [initialItemsPerPage]);
// Sync gridItemSize with prop when API settings are loaded
useEffect(() => {
if (initialGridItemSize !== undefined) {
setGridItemSize(initialGridItemSize);
}
}, [initialGridItemSize]);
// Filter states
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
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);
// Extract unique values for filters
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
const filteredMedia = useMemo(() => {
return mediaList.filter(media => {
@@ -46,276 +76,393 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
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 (selectedCategory && !media.series?.includes(selectedCategory)) return false;
if (selectedSource && media.source !== selectedSource) return false;
return true;
});
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]);
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory, selectedSource]);
// Reset to first page when mediaList or filters change
useEffect(() => {
setCurrentPage(1);
}, [filteredMedia, sortBy]);
}, [filteredMedia]);
const sortedMedia = useMemo(() => {
const list = [...filteredMedia];
if (sortBy === 'title-asc') {
return list.sort((a, b) => a.title.localeCompare(b.title));
}
if (sortBy === 'title-desc') {
return list.sort((a, b) => b.title.localeCompare(a.title));
}
return list;
}, [filteredMedia, sortBy]);
const gridColsClass = useMemo(() => {
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(filteredMedia.length / itemsPerPage);
const paginatedMedia = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return sortedMedia.slice(startIndex, startIndex + itemsPerPage);
}, [sortedMedia, currentPage, itemsPerPage]);
return filteredMedia.slice(startIndex, startIndex + itemsPerPage);
}, [filteredMedia, currentPage, itemsPerPage]);
const handlePrevPage = () => {
setCurrentPage((prev) => Math.max(prev - 1, 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
const handleClearAll = () => {
setSelectedGenre(null);
setSelectedStudio(null);
setSelectedPlatform(null);
setSelectedDeveloper(null);
setSelectedCategory(null);
setSelectedSource(null);
};
const handleNextPage = () => {
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
window.scrollTo({ top: 0, behavior: 'smooth' });
const handlePageChange = (page: number) => {
setCurrentPage(page);
const scrollContainer = document.getElementById('browse-scroll-container');
if (scrollContainer) {
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
}
};
// Generate pagination items with ellipsis
const getPaginationItems = () => {
const items: (number | string)[] = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
items.push(i);
}
} else {
// Always show first page
items.push(1);
if (currentPage > 3) {
items.push('ellipsis-start');
}
// Show pages around current
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
items.push(i);
}
if (currentPage < totalPages - 2) {
items.push('ellipsis-end');
}
// Always show last page
if (totalPages > 1) {
items.push(totalPages);
}
}
return items;
};
// Calculate favorite IDs
const favoriteIds = useMemo(() => {
return new Set(mediaList.filter(m => m.rating && m.rating >= 8).map(m => m.id));
}, [mediaList]);
// Check if we have search results
const hasSearchResults = searchQuery.trim().length > 0;
const hasCastResults = searchResultsCast.length > 0;
const hasMediaResults = mediaList.length > 0;
// Pagination for cast results (show first 12)
const paginatedCast = useMemo(() => {
return searchResultsCast.slice(0, itemsPerPage);
}, [searchResultsCast, itemsPerPage]);
return (
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
{/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div className="flex flex-wrap items-center gap-2">
{/* Genre Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<Star size={16} />
{selectedGenre || 'Genres'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
{allGenres.sort().map(genre => (
<DropdownMenuItem key={genre} onClick={() => setSelectedGenre(genre)}>{genre}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-col h-[calc(100vh-4rem-4rem)] w-full">
{/* Sticky Header - Filter + Results Summary + Count */}
<div className="px-6 pt-4 pb-4 bg-background border-b border-white/10 shrink-0 z-10">
{/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
<MediaFilters
mediaList={mediaList}
activeCategory={activeCategory}
selectedGenre={selectedGenre}
selectedStudio={selectedStudio}
selectedPlatform={selectedPlatform}
selectedDeveloper={selectedDeveloper}
selectedCategory={selectedCategory}
selectedSource={selectedSource}
onGenreChange={setSelectedGenre}
onStudioChange={setSelectedStudio}
onPlatformChange={setSelectedPlatform}
onDeveloperChange={setSelectedDeveloper}
onCategoryChange={setSelectedCategory}
onSourceChange={setSelectedSource}
onClearAll={handleClearAll}
/>
{/* Studio Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
Studios
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
{allStudios.sort().map(studio => (
<DropdownMenuItem key={studio} onClick={() => setSelectedStudio(studio)}>{studio}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-3">
{/* Grid item size slider - only show in grid mode */}
{viewMode === 'grid' && (
<div className="flex items-center gap-3 bg-[#1a1d26] rounded-xl px-4 py-2 border border-white/10">
<span className="text-xs font-bold text-gray-500">Size</span>
<input
type="range"
min="1"
max="10"
value={gridItemSize}
onChange={(e) => {
const newSize = Number(e.target.value);
setGridItemSize(newSize);
onGridItemSizeChange?.(newSize);
}}
className="w-24 h-2 bg-[#0d0f14] rounded-lg appearance-none cursor-pointer accent-[#e8466c]"
/>
<span className="text-xs font-bold text-[#e8466c] w-5 text-center">{gridItemSize}</span>
</div>
)}
{/* 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-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<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-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<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-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<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>
)}
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory) && (
<Button
variant="link"
size="sm"
className="text-zinc-400 font-bold"
onClick={() => {
setSelectedGenre(null);
setSelectedStudio(null);
setSelectedPlatform(null);
setSelectedDeveloper(null);
setSelectedCategory(null);
}}
>
Clear Filters
</Button>
)}
{/* View Toggle */}
<div className="flex items-center bg-[#1a1d26] rounded-xl p-1 border border-white/10">
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 transition-all rounded-lg",
viewMode === 'grid' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
)}
onClick={() => setViewMode('grid')}
>
<LayoutGrid size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 transition-all rounded-lg",
viewMode === 'list' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
)}
onClick={() => setViewMode('list')}
>
<List size={16} />
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-zinc-600 font-bold gap-2">
<ArrowUpDown size={16} />
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy('title-asc')}>Title (A-Z)</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy('title-desc')}>Title (Z-A)</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center bg-zinc-100 rounded-md p-1">
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 transition-all",
viewMode === 'grid' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
{/* Search Results Summary */}
{hasSearchResults && (
<div className="flex items-center gap-4 mb-4 p-3 bg-[#1a1d26] rounded-lg border border-white/10">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">Search results for:</span>
<Badge variant="secondary" className="bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30">
"{searchQuery}"
</Badge>
</div>
<div className="flex items-center gap-4 ml-auto">
{hasMediaResults && (
<div className="flex items-center gap-1.5 text-sm text-gray-400">
<LayoutGrid size={14} />
<span>{mediaList.length} media</span>
</div>
)}
onClick={() => setViewMode('grid')}
>
<LayoutGrid size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 transition-all",
viewMode === 'list' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
{hasCastResults && (
<div className="flex items-center gap-1.5 text-sm text-gray-400">
<Users size={14} />
<span>{searchResultsCast.length} cast</span>
</div>
)}
onClick={() => setViewMode('list')}
>
<List size={16} />
</Button>
</div>
</div>
)}
{/* Results Count */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
Showing {paginatedMedia.length} of {filteredMedia.length} results
</p>
</div>
</div>
{/* Content */}
{mediaList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<div className="w-16 h-16 bg-zinc-100 rounded-full flex items-center justify-center mb-4">
<Search size={32} />
{/* Scrollable Content Area */}
<div id="browse-scroll-container" className="flex-1 overflow-y-auto px-6 pt-4 pb-20">
{/* Cast Search Results */}
{hasSearchResults && hasCastResults && onCastClick && (
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<Users size={18} className="text-[#e8466c]" />
<h3 className="text-lg font-bold text-white">Cast Results</h3>
<Badge variant="secondary" className="bg-[#1a1d26] text-gray-400">
{searchResultsCast.length}
</Badge>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
{paginatedCast.map((person) => (
<div
key={person.id}
onClick={() => onCastClick(person)}
className="group cursor-pointer bg-[#1a1d26] rounded-lg p-3 border border-white/10 hover:border-[#e8466c]/50 transition-all duration-300 hover:bg-[#1f232c]"
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg overflow-hidden bg-[#0d0f14] shrink-0">
{person.photo ? (
<img
src={person.photo}
alt={person.name}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<User size={20} className="text-gray-600" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate group-hover:text-[#e8466c] transition-colors">
{person.name}
</p>
<p className="text-xs text-gray-500 truncate">{person.role}</p>
{person.filmography && person.filmography.length > 0 && (
<p className="text-xs text-gray-600 mt-1">
{person.filmography.length} role{person.filmography.length !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
))}
</div>
{searchResultsCast.length > itemsPerPage && (
<p className="text-xs text-gray-500 mt-3 text-center">
+{searchResultsCast.length - itemsPerPage} more cast members
</p>
)}
</div>
<p className="text-lg font-bold">No results found</p>
)}
{/* Content - inside scrollable area */}
{loading ? (
<Loading message="Loading media..." />
) : mediaList.length === 0 && !hasCastResults ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-500">
<div className="w-16 h-16 bg-[#1a1d26] rounded-full flex items-center justify-center mb-4">
<span className="text-2xl">📁</span>
</div>
<p className="text-lg font-bold text-gray-300">No results found</p>
<p className="text-sm">Try adjusting your search or filters</p>
</div>
) : mediaList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<p className="text-sm">No media results found for this search</p>
</div>
) : (
<div className={cn(
viewMode === 'grid'
? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-x-4 gap-y-8"
: "flex flex-col gap-2"
)}>
<AnimatePresence mode="popLayout">
{paginatedMedia.map((media) => (
viewMode === 'grid' ? (
<>
{hasSearchResults && (
<div className="flex items-center gap-2 mb-4">
<LayoutGrid size={18} className="text-[#e8466c]" />
<h3 className="text-lg font-bold text-white">Media Results</h3>
<Badge variant="secondary" className="bg-[#1a1d26] text-gray-400">
{mediaList.length}
</Badge>
</div>
)}
{viewMode === 'list' ? (
<MediaTable
mediaList={paginatedMedia}
onMediaClick={onMediaClick}
favoriteIds={favoriteIds}
/>
) : (
<div className={cn(gridColsClass, "gap-x-4 gap-y-8")}>
{paginatedMedia.map((media) => (
<MediaCard
key={media.id}
media={media}
onClick={onMediaClick}
showBadge={true}
showFavorite={true}
/>
) : (
<MediaListItem
key={media.id}
media={media}
onClick={onMediaClick}
/>
)
))}
</AnimatePresence>
</div>
))}
</div>
)}
</>
)}
{/* Pagination Controls */}
{mediaList.length > 0 && (
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-zinc-100 pt-8">
<div className="flex items-center gap-4">
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{[12, 20, 36, 48, 60].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
{/* End of scrollable content area */}
</div>
<div className="flex items-center gap-6">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 1}
className="gap-2 font-bold border-zinc-200"
>
<ChevronLeft size={16} />
Previous
</Button>
<div className="flex items-center gap-2">
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
<span className="text-sm text-zinc-400 font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span>
{/* Sticky Pagination Controls */}
{filteredMedia.length > 0 && (
<div className="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-500 font-medium">Items per page:</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="bg-[#1a1d26] border border-white/10 rounded-md px-2 py-1 text-sm font-medium text-gray-300 focus:ring-2 focus:ring-[#e8466c] outline-none"
>
{[12, 20, 36, 48, 60, 100].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-zinc-200"
>
Next
<ChevronRight size={16} />
</Button>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
className={cn(
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
currentPage === 1 && "pointer-events-none opacity-50"
)}
/>
</PaginationItem>
{getPaginationItems().map((item, index) => (
<React.Fragment key={index}>
{item === 'ellipsis-start' || item === 'ellipsis-end' ? (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem>
<PaginationLink
isActive={currentPage === item}
onClick={() => handlePageChange(item as number)}
className={cn(
"border-white/10",
currentPage === item
? "bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30"
: "bg-transparent text-gray-300 hover:bg-white/5 hover:text-white"
)}
>
{item}
</PaginationLink>
</PaginationItem>
)}
</React.Fragment>
))}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
className={cn(
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
(currentPage === totalPages || totalPages === 0) && "pointer-events-none opacity-50"
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
)}
+410 -237
View File
@@ -1,9 +1,25 @@
import { Staff, Media } from '@/types';
import { useNavigate } from 'react-router-dom';
import { motion } from 'motion/react';
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import {
ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye,
BookOpen, Theater, ArrowUpAZ, ArrowDownAZ, ArrowUpDown, Star
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { useState } from 'react';
import { cn } from '@/lib/utils';
interface CastDetailViewProps {
person: Staff;
@@ -12,310 +28,467 @@ interface CastDetailViewProps {
export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
const navigate = useNavigate();
const [sortBy, setSortBy] = useState<'year' | 'title' | 'role'>('role');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const handleMediaClick = (mediaId: string) => {
navigate(`/media/${mediaId}`);
};
const sortedFilmography = [...(person.filmography || [])].sort((a, b) => {
let comparison = 0;
if (sortBy === 'year') {
comparison = (a.year || 0) - (b.year || 0);
} else if (sortBy === 'title') {
comparison = (a.title || '').localeCompare(b.title || '');
} else if (sortBy === 'role') {
comparison = (a.role || '').localeCompare(b.role || '');
}
return sortOrder === 'asc' ? comparison : -comparison;
});
// Sort options
const sortOptions = [
{ value: 'year', label: 'Year', icon: Calendar },
{ value: 'title', label: 'Title', icon: ArrowUpAZ },
{ value: 'role', label: 'Role', icon: Briefcase },
] as const;
return (
<div className="min-h-screen bg-white pb-20">
{/* Hero Section */}
<div className="relative h-[40vh] md:h-[50vh] overflow-hidden bg-zinc-900">
<img
src={person.photo}
<div className="min-h-screen bg-background pb-16">
{/* Compact Hero Section */}
<div className="relative h-[35vh] md:h-[40vh] overflow-hidden bg-zinc-900">
<img
src={person.photo}
alt={person.name}
className="w-full h-full object-cover opacity-40 blur-xl scale-110"
className="w-full h-full object-cover opacity-30 blur-xl scale-110"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
<div className="absolute inset-0 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">
<motion.div
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" />
<div className="absolute inset-0 flex items-end px-4 sm:px-6 pb-8">
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-48 h-48 md:w-64 md:h-64 rounded-2xl overflow-hidden border-4 border-white shadow-2xl shrink-0"
className="shrink-0"
>
<img
src={person.photo}
alt={person.name}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
<Avatar className="h-32 md:h-40 w-auto aspect-[3/4] rounded-none border-3 border-background shadow-2xl">
<AvatarImage
src={person.photo}
alt={person.name}
className="object-cover"
referrerPolicy="no-referrer"
/>
<AvatarFallback className="rounded-none text-3xl">
<User className="h-12 w-12" />
</AvatarFallback>
</Avatar>
</motion.div>
<div className="flex-1 text-center md:text-left pb-4">
<div className="flex-1 text-center md:text-left pb-2">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
<h1 className="text-4xl md:text-6xl font-black text-zinc-900 mb-4 drop-shadow-sm">
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-3 tracking-tight">
{person.name}
</h1>
<div className="flex flex-wrap justify-center md:justify-start gap-3">
<div className="flex flex-wrap justify-center md:justify-start gap-2">
{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-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 font-medium px-3 py-1 text-xs">
{occ}
</Badge>
))}
{person.filmography && person.filmography.length > 0 && (
<Badge variant="outline" className="border-[#e8466c]/30 text-[#e8466c] font-medium px-3 py-1 text-xs">
<Star className="w-3 h-3 mr-1" />
{person.filmography.length}
</Badge>
)}
</div>
</motion.div>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="absolute top-24 left-6 bg-white/20 hover:bg-white/40 text-white rounded-full backdrop-blur-md"
className="absolute top-20 left-4 sm:left-6 bg-white/20 hover:bg-white/40 text-white rounded-xl backdrop-blur-md transition-all duration-300 hover:scale-105 border border-white/20 h-10 w-10"
>
<ArrowLeft size={24} />
<ArrowLeft size={20} />
</Button>
</div>
{/* Content Section */}
<div className="max-w-[1200px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Sidebar Info */}
<div className="space-y-8">
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6">
<h3 className="text-xl font-black text-zinc-900">Personal Info</h3>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Calendar size={20} />
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 mt-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Info - Modern shadcn Design */}
<div className="space-y-4 lg:col-span-1">
{/* Personal Info Card */}
<Card className="border-border/60 overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<div className="w-5 h-5 rounded bg-[#e8466c]/10 flex items-center justify-center">
<User size={12} className="text-[#e8466c]" />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Date</p>
<p className="font-bold text-zinc-700">{person.birthDate || 'Unknown'}</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<MapPin size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Place</p>
<p className="font-bold text-zinc-700">{person.birthPlace || 'Unknown'}</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Briefcase size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Known For</p>
<p className="font-bold text-zinc-700">{person.role}</p>
Personal Info
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{/* Birth Date */}
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
<Calendar size={14} />
</div>
<span className="text-xs text-muted-foreground">Born</span>
</div>
<span className="text-sm font-medium">{person.birthDate || '—'}</span>
</div>
<Separator />
{/* Birth Place */}
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
<MapPin size={14} />
</div>
<span className="text-xs text-muted-foreground">Origin</span>
</div>
<span className="text-sm font-medium truncate max-w-[140px]" title={person.birthPlace || undefined}>
{person.birthPlace || '—'}
</span>
</div>
<Separator />
{/* Known For */}
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
<Briefcase size={14} />
</div>
<span className="text-xs text-muted-foreground">Role</span>
</div>
<Badge variant="secondary" className="text-xs font-normal bg-[#e8466c]/10 text-[#e8466c] border-none">
{person.role}
</Badge>
</div>
{/* Ethnicity - only if present */}
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<User size={20} />
<>
<Separator />
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
<User size={14} />
</div>
<span className="text-xs text-muted-foreground">Ethnicity</span>
</div>
<span className="text-sm font-medium truncate max-w-[140px]">
{person.adult_specifics?.ethnicity || person.ethnicity}
</span>
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Ethnicity</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
</div>
</div>
</>
)}
</div>
</div>
</CardContent>
</Card>
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6">
<h3 className="text-xl font-black text-zinc-900">Measurements</h3>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Height</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.height || person.height} cm</p>
</div>
{/* Measurements Card - Only if data exists */}
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight ||
person.adult_specifics?.measurements || person.bust_size || person.hair_color || person.adult_specifics?.hair_color) && (
<Card className="border-border/60 overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<div className="w-5 h-5 rounded bg-[#e8466c]/10 flex items-center justify-center">
<Ruler size={12} className="text-[#e8466c]" />
</div>
{(person.weight || person.adult_specifics?.weight) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Ruler size={20} />
Measurements
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{/* Height & Weight Grid */}
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight) && (
<>
<div className="grid grid-cols-2 divide-x divide-border">
{(person.adult_specifics?.height || person.height) && (
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Height</p>
<p className="text-lg font-semibold text-foreground">
{person.adult_specifics?.height || person.height}
<span className="text-xs font-normal text-muted-foreground ml-0.5">cm</span>
</p>
</div>
)}
{(person.adult_specifics?.weight || person.weight) && (
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Weight</p>
<p className="text-lg font-semibold text-foreground">
{person.adult_specifics?.weight || person.weight}
<span className="text-xs font-normal text-muted-foreground ml-0.5">kg</span>
</p>
</div>
)}
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Weight</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.weight || person.weight} kg</p>
</div>
</div>
<Separator />
</>
)}
{/* Measurements (Bust-Waist-Hip) */}
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Measurements</p>
<p className="font-bold text-zinc-700">
<>
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1.5">Figure</p>
<p className="text-sm font-medium font-mono tracking-wide">
{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}`}
{person.bust_size && <span className="inline-flex items-center gap-0.5">{person.bust_size}{person.cup_size && <span className="text-xs text-muted-foreground">{person.cup_size}</span>}</span>}
{(person.bust_size || person.cup_size) && person.waist_size && <span className="text-muted-foreground mx-1"></span>}
{person.waist_size && <span>{person.waist_size}</span>}
{person.hip_size && <span className="text-muted-foreground mx-1"></span>}
{person.hip_size && <span>{person.hip_size}</span>}
</>
)}
</p>
</div>
</div>
<Separator />
</>
)}
{(person.hair_color || person.adult_specifics?.hair_color) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Palette size={20} />
{/* Hair & Eyes Grid */}
<div className="grid grid-cols-2 divide-x divide-border">
{(person.hair_color || person.adult_specifics?.hair_color) && (
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-2 mb-1">
<Palette size={12} className="text-[#e8466c]" />
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Hair</span>
</div>
<p className="text-sm font-medium truncate">
{person.adult_specifics?.hair_color || person.hair_color}
</p>
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Hair Color</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.hair_color || person.hair_color}</p>
)}
{(person.eye_color || person.adult_specifics?.eye_color) && (
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-2 mb-1">
<Eye size={12} className="text-[#e8466c]" />
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Eyes</span>
</div>
<p className="text-sm font-medium truncate">
{person.adult_specifics?.eye_color || person.eye_color}
</p>
</div>
</div>
)}
)}
</div>
{(person.eye_color || person.adult_specifics?.eye_color) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Eye size={20} />
{/* Tattoos & Piercings */}
{(person.adult_specifics?.tattoos || person.adult_specifics?.piercings) && (
<>
<Separator />
<div className="grid grid-cols-2 divide-x divide-border">
{person.adult_specifics?.tattoos && (
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Tattoos</p>
<p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.tattoos}</p>
</div>
)}
{person.adult_specifics?.piercings && (
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Piercings</p>
<p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.piercings}</p>
</div>
)}
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Eye Color</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.eye_color || person.eye_color}</p>
</div>
</div>
</>
)}
{person.adult_specifics?.tattoos && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Tattoos</p>
<p className="font-bold text-zinc-700">{person.adult_specifics.tattoos}</p>
</div>
</div>
)}
{person.adult_specifics?.piercings && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Piercings</p>
<p className="font-bold text-zinc-700">{person.adult_specifics.piercings}</p>
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* Main Bio & Roles */}
<div className="lg:col-span-2 space-y-12">
{person.bio && (
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
Biography
</h2>
<p className="text-zinc-600 leading-relaxed text-lg">
{person.bio}
</p>
</section>
)}
{/* Main Bio & Roles - Wider */}
<div className="lg:col-span-3">
<Tabs defaultValue={person.bio ? 'bio' : 'filmography'} className="w-full">
<TabsList className="mb-4 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto">
{person.bio && (
<TabsTrigger value="bio" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
<BookOpen size={14} />
Biography
</TabsTrigger>
)}
{person.filmography && person.filmography.length > 0 && (
<>
<TabsTrigger value="characters" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
<Theater size={14} />
Characters
</TabsTrigger>
<TabsTrigger value="filmography" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
<Film size={14} />
Filmography
</TabsTrigger>
</>
)}
</TabsList>
{person.filmography && person.filmography.length > 0 && (
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<User className="text-[#6d28d9]" />
Characters
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{person.filmography.map(item => (
<div
key={`${item.id}-char`}
className="flex items-center gap-4 p-4 rounded-2xl bg-zinc-50 border border-zinc-100"
>
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-white">
<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 flex-1">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Character</p>
<h4 className="font-black text-zinc-900 truncate">{item.characterName || item.role}</h4>
<button
onClick={() => handleMediaClick(item.id.toString())}
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left"
>
in {item.title}
</button>
{person.bio && (
<TabsContent value="bio" className="mt-0">
<Card className="border-border/60">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">Biography</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-foreground leading-relaxed text-sm">
{person.bio}
</p>
</CardContent>
</Card>
</TabsContent>
)}
{person.filmography && person.filmography.length > 0 && (
<>
<TabsContent value="characters" className="mt-0">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<AnimatePresence mode="popLayout">
{person.filmography.map((item, index) => (
<motion.div
key={`${item.id}-char`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<Card
className="hover:border-[#e8466c]/30 hover:shadow-md transition-all duration-200 cursor-pointer group border-border/60"
onClick={() => handleMediaClick(item.id.toString())}
>
<CardContent className="p-3 flex items-center gap-3">
<div className="w-14 h-14 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
<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 flex-1">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Character</p>
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#e8466c] transition-colors">
{item.characterName || item.role}
</h4>
<p className="text-xs text-[#e8466c] truncate">{item.title}</p>
</div>
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</div>
</TabsContent>
<TabsContent value="filmography" className="mt-0">
{/* Sort Toolbar */}
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-muted-foreground">
{person.filmography.length} {person.filmography.length === 1 ? 'title' : 'titles'}
</p>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2.5 rounded-lg text-xs border-border/60"
>
<ArrowUpDown size={14} className="mr-1.5" />
{sortOrder === 'asc' ? <ArrowUpAZ size={14} className="mr-1.5" /> : <ArrowDownAZ size={14} className="mr-1.5" />}
{sortOptions.find(o => o.value === sortBy)?.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
Sort by
</DropdownMenuItem>
<DropdownMenuSeparator />
{sortOptions.map(option => (
<DropdownMenuItem
key={option.value}
onClick={() => {
if (sortBy === option.value) {
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(option.value);
setSortOrder('asc');
}
}}
className="flex items-center justify-between text-xs"
>
<span className="flex items-center gap-2">
<option.icon size={14} />
{option.label}
</span>
{sortBy === option.value && (
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#e8466c]" /> : <ArrowDownAZ size={14} className="text-[#e8466c]" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</section>
)}
{person.filmography && person.filmography.length > 0 && (
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<Film className="text-[#6d28d9]" />
Filmography
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{person.filmography.map(item => (
<div
key={item.id}
onClick={() => handleMediaClick(item.id.toString())}
className="group flex items-center gap-4 p-4 rounded-2xl bg-white border border-zinc-100 hover:border-[#6d28d9]/30 hover:shadow-lg transition-all cursor-pointer"
>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 shadow-sm">
<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-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
{item.title}
</h4>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-1">
{item.year || 'Unknown'}
</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-zinc-200">
{item.role}
</Badge>
</div>
</div>
{/* Filmography Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
<AnimatePresence mode="popLayout">
{sortedFilmography.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<Card
onClick={() => handleMediaClick(item.id.toString())}
className="group cursor-pointer hover:border-[#e8466c]/30 hover:shadow-md transition-all duration-200 border-border/60"
>
<CardContent className="p-3 flex items-center gap-3">
<div className="w-12 h-16 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
<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 flex-1">
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#e8466c] transition-colors">
{item.title}
</h4>
<p className="text-xs text-muted-foreground mb-1">
{item.year || 'Unknown'}
</p>
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-border/50 font-normal">
{item.role}
</Badge>
{item.category && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 bg-muted font-normal">
{item.category}
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</div>
))}
</div>
</section>
)}
</TabsContent>
</>
)}
</Tabs>
</div>
</div>
</div>
+652 -257
View File
@@ -1,10 +1,50 @@
import { Staff, MediaCategory } from '@/types';
import { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from 'lucide-react';
import {
Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter,
LayoutGrid, Table2, Eye, Calendar, Star, ArrowUpAZ, ArrowDownAZ,
Briefcase, Film, Users, ChevronUp, ChevronDown
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
import Loading from '@/components/ui/loading';
import { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/lib/utils';
import { fetchAllCast } from '@/api';
@@ -22,21 +62,33 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
const [searchQuery, setSearchQuery] = useState(() => {
return localStorage.getItem('castSearchQuery') || '';
});
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height'>(() => {
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height') || 'name';
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height' | 'roleCount'>(() => {
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height' | 'roleCount') || 'roleCount';
});
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'asc';
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
});
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
return localStorage.getItem('castFilterOccupation') || '';
const saved = localStorage.getItem('castFilterOccupation');
return saved && saved !== '' ? saved : 'all';
});
const [filterMediaType, setFilterMediaType] = useState<string>(() => {
return localStorage.getItem('castFilterMediaType') || '';
const saved = localStorage.getItem('castFilterMediaType');
return saved && saved !== '' ? saved : 'all';
});
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
const [showFilters, setShowFilters] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'table'>(() => {
return (localStorage.getItem('castViewMode') as 'grid' | 'table') || 'grid';
});
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
// Sync itemsPerPage with prop when API settings are loaded
useEffect(() => {
if (initialItemsPerPage) {
setItemsPerPage(initialItemsPerPage);
}
}, [initialItemsPerPage]);
// Persist filters and sorts
useEffect(() => {
@@ -61,13 +113,13 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
const handleResetFilters = () => {
setSearchQuery('');
setSortBy('name');
setSortOrder('asc');
setFilterOccupation('');
setFilterMediaType('');
setSortBy('roleCount');
setSortOrder('desc');
setFilterOccupation('all');
setFilterMediaType('all');
};
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'name' || sortOrder !== 'asc';
const hasActiveFilters = searchQuery || (filterOccupation && filterOccupation !== 'all') || (filterMediaType && filterMediaType !== 'all') || sortBy !== 'roleCount' || sortOrder !== 'desc';
useEffect(() => {
const loadCast = async () => {
@@ -102,12 +154,12 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
}
// Filter by occupation
if (filterOccupation && !s.occupations?.includes(filterOccupation)) {
if (filterOccupation && filterOccupation !== 'all' && !s.occupations?.includes(filterOccupation)) {
return false;
}
// Filter by media type
if (filterMediaType && !s.media_types?.includes(filterMediaType)) {
if (filterMediaType && filterMediaType !== 'all' && !s.media_types?.includes(filterMediaType)) {
return false;
}
@@ -130,6 +182,10 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
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;
@@ -163,263 +219,602 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
return filteredStaff.slice(startIndex, startIndex + itemsPerPage);
}, [filteredStaff, currentPage, itemsPerPage]);
const handlePrevPage = () => {
setCurrentPage((prev) => Math.max(prev - 1, 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
const handlePageChange = (page: number) => {
setCurrentPage(page);
const scrollContainer = document.getElementById('cast-scroll-container');
if (scrollContainer) {
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handleNextPage = () => {
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
window.scrollTo({ top: 0, behavior: 'smooth' });
// Generate pagination items with ellipsis
const getPaginationItems = () => {
const items: (number | string)[] = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
items.push(i);
}
} else {
// Always show first page
items.push(1);
if (currentPage > 3) {
items.push('ellipsis-start');
}
// Show pages around current
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
items.push(i);
}
if (currentPage < totalPages - 2) {
items.push('ellipsis-end');
}
// Always show last page
if (totalPages > 1) {
items.push(totalPages);
}
}
return items;
};
// Persist view mode
useEffect(() => {
localStorage.setItem('castViewMode', viewMode);
}, [viewMode]);
// Sort handler for table
const handleSort = (column: typeof sortBy) => {
if (sortBy === column) {
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(column);
setSortOrder('asc');
}
};
// Sort options with labels
const sortOptions = [
{ value: 'name', label: 'Name', icon: ArrowUpAZ },
{ value: 'role', label: 'Role', icon: Briefcase },
{ value: 'birthDate', label: 'Birth Date', icon: Calendar },
{ value: 'height', label: 'Height', icon: ArrowUpDown },
{ value: 'roleCount', label: 'Role Count', icon: Star },
] as const;
return (
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
<div>
<h1 className="text-4xl font-black text-zinc-900 mb-2">Cast & Staff</h1>
<p className="text-zinc-500 font-medium">Discover the people behind your favorite media</p>
</div>
<TooltipProvider>
<div className="flex flex-col h-[calc(100vh-4rem-4rem)] w-full">
{/* Sticky Header - Filters */}
<div className="px-6 pt-4 pb-4 bg-background border-b border-white/10 shrink-0 z-10">
{/* Compact Toolbar - Like MediaFilters */}
<div className="flex flex-col gap-4">
{/* Top Row: Search, View Toggle, Count */}
<div className="flex items-center gap-2 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-[320px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
<Input
placeholder="Search cast..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-muted/50 border-none rounded-lg text-sm focus-visible:ring-[#e8466c]/30"
/>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<Input
placeholder="Search cast..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 w-full md:w-[300px] bg-zinc-100 border-none rounded-full h-11"
/>
</div>
<Button
variant={showFilters ? 'default' : 'outline'}
size="icon"
className={`rounded-full h-11 w-11 ${showFilters ? 'bg-[#6d28d9] text-white border-[#6d28d9]' : 'border-zinc-200'}`}
onClick={() => setShowFilters(!showFilters)}
>
<Filter size={20} />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full h-11 w-11 border-zinc-200"
onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
>
<ArrowUpDown size={20} />
</Button>
{hasActiveFilters && (
<Button
variant="ghost"
size="icon"
className="rounded-full h-11 w-11 text-zinc-400 hover:text-zinc-900"
onClick={handleResetFilters}
title="Reset filters"
{/* View Toggle */}
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(value: string | string[]) => {
const v = Array.isArray(value) ? value[0] : value;
if (v === 'grid' || v === 'table') {
setViewMode(v);
}
}}
className="bg-muted/50 p-0.5 rounded-lg"
>
<X size={20} />
</Button>
<ToggleGroupItem value="grid" aria-label="Grid view" className="h-8 w-8 p-0 rounded-md data-[state=on]:bg-background data-[state=on]:shadow-sm">
<LayoutGrid size={16} />
</ToggleGroupItem>
<ToggleGroupItem value="table" aria-label="Table view" className="h-8 w-8 p-0 rounded-md data-[state=on]:bg-background data-[state=on]:shadow-sm">
<Table2 size={16} />
</ToggleGroupItem>
</ToggleGroup>
{/* Count Badge */}
<Badge variant="secondary" className="h-8 px-2.5 bg-muted/80 text-muted-foreground font-normal">
{filteredStaff.length} {filteredStaff.length === 1 ? 'person' : 'people'}
</Badge>
</div>
{/* Bottom Row: Filter Dropdowns */}
<div className="flex flex-wrap items-center gap-2">
{/* Sort Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
(sortBy !== 'roleCount' || sortOrder !== 'desc')
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
<ArrowUpDown size={14} className="mr-1.5" />
{sortOrder === 'asc' ? <ArrowUpAZ size={14} className="mr-1.5" /> : <ArrowDownAZ size={14} className="mr-1.5" />}
{sortOptions.find(o => o.value === sortBy)?.label || 'Sort'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
Sort by
</DropdownMenuItem>
<DropdownMenuSeparator />
{sortOptions.map(option => (
<DropdownMenuItem
key={option.value}
onClick={() => {
if (sortBy === option.value) {
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(option.value);
setSortOrder('asc');
}
}}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<option.icon size={14} />
{option.label}
</span>
{sortBy === option.value && (
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#e8466c]" /> : <ArrowDownAZ size={14} className="text-[#e8466c]" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Occupation Filter */}
{uniqueOccupations.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
filterOccupation && filterOccupation !== 'all'
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
<Briefcase size={14} className="mr-1.5" />
{filterOccupation && filterOccupation !== 'all' ? filterOccupation : 'Occupation'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
Filter by Occupation
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setFilterOccupation('all')}>
All Occupations
</DropdownMenuItem>
{uniqueOccupations.map(occ => (
<DropdownMenuItem key={occ} onClick={() => setFilterOccupation(occ)}>
{occ}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Media Type Filter */}
{uniqueMediaTypes.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
filterMediaType && filterMediaType !== 'all'
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
<Film size={14} className="mr-1.5" />
{filterMediaType && filterMediaType !== 'all' ? filterMediaType : 'Media Type'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
Filter by Media Type
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setFilterMediaType('all')}>
All Media Types
</DropdownMenuItem>
{uniqueMediaTypes.map(type => (
<DropdownMenuItem key={type} onClick={() => setFilterMediaType(type)}>
{type}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Clear All */}
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={handleResetFilters}
className="h-8 px-2 text-muted-foreground hover:text-foreground hover:bg-muted/50"
>
<X size={14} className="mr-1" />
Clear
</Button>
)}
</div>
{/* Active Filter Badges */}
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-1.5">
{searchQuery && (
<Badge
variant="secondary"
className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => setSearchQuery('')}
>
Search: {searchQuery}
<X size={12} className="ml-1" />
</Badge>
)}
{filterOccupation && filterOccupation !== 'all' && (
<Badge
variant="secondary"
className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => setFilterOccupation('all')}
>
{filterOccupation}
<X size={12} className="ml-1" />
</Badge>
)}
{filterMediaType && filterMediaType !== 'all' && (
<Badge
variant="secondary"
className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => setFilterMediaType('all')}
>
{filterMediaType}
<X size={12} className="ml-1" />
</Badge>
)}
{(sortBy !== 'roleCount' || sortOrder !== 'desc') && (
<Badge
variant="secondary"
className="h-6 px-2 text-xs bg-muted text-muted-foreground hover:bg-muted/80 cursor-pointer"
onClick={() => { setSortBy('roleCount'); setSortOrder('desc'); }}
>
Sort: {sortOptions.find(o => o.value === sortBy)?.label}
<X size={12} className="ml-1" />
</Badge>
)}
</div>
)}
</div>
</div>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="bg-zinc-50 rounded-2xl p-6 mb-6"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="text-sm font-bold text-zinc-700 mb-2 block">Sort By</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
<option value="name">Name</option>
<option value="role">Role</option>
<option value="birthDate">Birth Date</option>
<option value="height">Height</option>
</select>
</div>
<div>
<label className="text-sm font-bold text-zinc-700 mb-2 block">Occupation</label>
<select
value={filterOccupation}
onChange={(e) => setFilterOccupation(e.target.value)}
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
<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-zinc-700 mb-2 block">Media Type</label>
<select
value={filterMediaType}
onChange={(e) => setFilterMediaType(e.target.value)}
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
<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">
Search: {searchQuery}
<button onClick={() => setSearchQuery('')} className="hover:text-zinc-900">
<X size={12} />
</button>
</Badge>
)}
{filterOccupation && (
<Badge variant="secondary" className="gap-1">
Occupation: {filterOccupation}
<button onClick={() => setFilterOccupation('')} className="hover:text-zinc-900">
<X size={12} />
</button>
</Badge>
)}
{filterMediaType && (
<Badge variant="secondary" className="gap-1">
Media Type: {filterMediaType}
<button onClick={() => setFilterMediaType('')} className="hover:text-zinc-900">
<X size={12} />
</button>
</Badge>
)}
{(sortBy !== 'name' || sortOrder !== 'asc') && (
<Badge variant="secondary" className="gap-1">
Sort: {sortBy} ({sortOrder})
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-zinc-900">
<X size={12} />
</button>
</Badge>
)}
</div>
</motion.div>
)}
{loading ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#6d28d9] mb-4" />
<p className="text-lg font-bold">Loading cast...</p>
</div>
) : filteredStaff.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<User size={48} className="mb-4 opacity-20" />
<p className="text-lg font-bold">No cast members found</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence mode="popLayout">
{paginatedStaff.map((person) => (
<motion.div
key={person.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="group bg-white rounded-2xl p-4 shadow-sm border border-zinc-100 hover:shadow-xl hover:border-[#6d28d9]/20 transition-all duration-300 cursor-pointer"
onClick={() => onPersonClick(person)}
>
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-zinc-100 group-hover:border-[#6d28d9] transition-colors">
<img
src={person.photo}
alt={person.name}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<h3 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
{person.name}
</h3>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider">
{person.role}
</p>
</div>
</div>
{person.filmography && person.filmography.length > 0 && (
<div className="bg-zinc-50 rounded-xl p-3 flex items-center gap-3">
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-white">
<img
src={person.filmography[0].poster || person.photo}
alt={person.filmography[0].title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
{/* Scrollable Content Area */}
<div id="cast-scroll-container" className="flex-1 overflow-y-auto px-6 pt-4 pb-20">
{/* Content Area */}
{loading ? (
<Loading message="Loading cast..." />
) : filteredStaff.length === 0 ? (
<Card className="border-dashed">
<CardContent 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">
<User size={40} />
</div>
<p className="text-xl font-bold">No cast members found</p>
</CardContent>
</Card>
) : viewMode === 'grid' ? (
/* Grid View - Modern Cards */
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
<AnimatePresence mode="popLayout">
{paginatedStaff.map((person) => (
<motion.div
key={person.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<Card
className="group cursor-pointer overflow-hidden hover:shadow-xl hover:border-[#e8466c]/30 transition-all duration-300 border-border/60"
onClick={() => onPersonClick(person)}
>
{/* Card Header with Avatar and Info */}
<div className="p-3">
<div className="flex items-start gap-3">
<Avatar className="h-12 w-12 rounded-lg border-2 border-border/50 group-hover:border-[#e8466c] transition-colors duration-300 shadow-sm">
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" className="object-cover" />
<AvatarFallback className="rounded-lg bg-muted">
<User className="h-5 w-5 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-foreground truncate group-hover:text-[#e8466c] transition-colors duration-300 text-sm leading-tight">
{person.name}
</h3>
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
{person.role}
</p>
<div className="flex items-center gap-1.5 mt-1.5">
{person.filmography && person.filmography.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 bg-muted">
<Star className="w-2.5 h-2.5 mr-0.5" />
{person.filmography.length}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{person.filmography.length} roles</p>
</TooltipContent>
</Tooltip>
)}
{person.birthDate && (
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
<Calendar className="w-2.5 h-2.5" />
{new Date(person.birthDate).getFullYear()}
</span>
)}
</div>
</div>
</div>
</div>
<div className="min-w-0">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest leading-none mb-1">Latest Role</p>
<p className="text-xs font-bold text-zinc-700 truncate">{person.filmography[0].title}</p>
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">{person.filmography[0].role}</p>
</div>
</div>
)}
</motion.div>
))}
</AnimatePresence>
</div>
)}
{/* Pagination Controls */}
{filteredStaff.length > 0 && (
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-zinc-100 pt-8">
<div className="flex items-center gap-4">
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
}}
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{[12, 20, 36, 48, 60].map(size => (
<option key={size} value={size}>{size}</option>
{/* Latest Role Section */}
{person.filmography && person.filmography.length > 0 && (
<div className="px-3 pb-3">
<div className="bg-muted/50 rounded-lg p-2 flex items-center gap-2 border border-border/40 group-hover:border-[#e8466c]/20 transition-colors">
<div className="w-8 h-11 rounded overflow-hidden shrink-0 bg-background border border-border/40">
<img
src={person.filmography[0].poster || person.photo}
alt={person.filmography[0].title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide leading-none">Latest</p>
<p className="text-[11px] font-medium text-foreground truncate">{person.filmography[0].title}</p>
<p className="text-[10px] text-[#e8466c] truncate">{person.filmography[0].role}</p>
</div>
</div>
</div>
)}
</Card>
</motion.div>
))}
</select>
</AnimatePresence>
</div>
) : (
/* Table View */
<Table className="w-full table-fixed">
<TableHeader>
<TableRow className="hover:bg-transparent border-border/60 bg-muted/30">
<TableHead className="w-14 rounded-tl-lg"></TableHead>
<TableHead
className="cursor-pointer hover:text-[#e8466c] transition-colors"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-1">
Name
{sortBy === 'name' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:text-[#e8466c] transition-colors"
onClick={() => handleSort('role')}
>
<div className="flex items-center gap-1">
Role
{sortBy === 'role' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
</div>
</TableHead>
<TableHead className="hidden md:table-cell">Latest Work</TableHead>
<TableHead
className="hidden sm:table-cell cursor-pointer hover:text-[#e8466c] transition-colors text-right"
onClick={() => handleSort('roleCount')}
>
<div className="flex items-center justify-end gap-1">
Roles
{sortBy === 'roleCount' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
</div>
</TableHead>
<TableHead className="w-10 rounded-tr-lg"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence mode="popLayout">
{paginatedStaff.map((person) => (
<motion.tr
key={person.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={cn(
"group cursor-pointer border-border/40 transition-colors",
hoveredRow === person.id ? "bg-muted/60" : "hover:bg-muted/40"
)}
onMouseEnter={() => setHoveredRow(person.id)}
onMouseLeave={() => setHoveredRow(null)}
onClick={() => onPersonClick(person)}
>
<TableCell className="py-3">
<Avatar className="h-10 w-10 rounded-lg border border-border/50">
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" />
<AvatarFallback className="rounded-lg bg-muted">
<User className="h-4 w-4 text-muted-foreground" />
</AvatarFallback>
</Avatar>
</TableCell>
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="group-hover:text-[#e8466c] transition-colors">{person.name}</span>
{person.birthDate && (
<span className="text-xs text-muted-foreground">
{new Date(person.birthDate).toLocaleDateString()}
</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="font-normal bg-muted/80 text-muted-foreground">
{person.role}
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">
{person.filmography && person.filmography.length > 0 ? (
<div className="flex items-center gap-2">
<div className="w-8 h-10 rounded overflow-hidden shrink-0 bg-muted">
<img
src={person.filmography[0].poster || person.photo}
alt={person.filmography[0].title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<p className="text-sm truncate">{person.filmography[0].title}</p>
<p className="text-xs text-muted-foreground">{person.filmography[0].role}</p>
</div>
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="hidden sm:table-cell text-right">
{person.filmography ? (
<Badge variant="outline" className="font-medium">
{person.filmography.length}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onPersonClick(person);
}}
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</motion.tr>
))}
</AnimatePresence>
</TableBody>
</Table>
)}
<div className="flex items-center gap-6">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 1}
className="gap-2 font-bold border-zinc-200"
>
<ChevronLeft size={16} />
Previous
</Button>
<div className="flex items-center gap-2">
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
<span className="text-sm text-zinc-400 font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-zinc-200"
>
Next
<ChevronRight size={16} />
</Button>
</div>
{/* End of scrollable content area */}
</div>
)}
</div>
{/* Sticky Pagination Controls */}
{filteredStaff.length > 0 && (
<div className="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-500 font-medium">Items per page:</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="bg-[#1a1d26] border border-white/10 rounded-md px-2 py-1 text-sm font-medium text-gray-300 focus:ring-2 focus:ring-[#e8466c] outline-none"
>
{[12, 20, 36, 48, 60, 100].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
className={cn(
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
currentPage === 1 && "pointer-events-none opacity-50"
)}
/>
</PaginationItem>
{getPaginationItems().map((item, index) => (
<React.Fragment key={index}>
{item === 'ellipsis-start' || item === 'ellipsis-end' ? (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem>
<PaginationLink
isActive={currentPage === item}
onClick={() => handlePageChange(item as number)}
className={cn(
"border-white/10",
currentPage === item
? "bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30"
: "bg-transparent text-gray-300 hover:bg-white/5 hover:text-white"
)}
>
{item}
</PaginationLink>
</PaginationItem>
)}
</React.Fragment>
))}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
className={cn(
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
(currentPage === totalPages || totalPages === 0) && "pointer-events-none opacity-50"
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
)}
</div>
</TooltipProvider>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { Media, MediaCategory } from '@/types';
import MediaCard from './MediaCard';
import {
Film,
Tv,
Gamepad2,
Users,
Heart,
FolderKanban,
Database,
Sparkles,
Clock,
ChevronRight,
Eye
} from 'lucide-react';
import { useMemo } from 'react';
import { motion } from 'motion/react';
import Loading from '@/components/ui/loading';
import { useNavigate } from 'react-router-dom';
interface DashboardViewProps {
mediaList: Media[];
onMediaClick: (media: Media) => void;
loading?: boolean;
}
export default function DashboardView({ mediaList, onMediaClick, loading = false }: DashboardViewProps) {
const navigate = useNavigate();
// Calculate statistics
const stats = useMemo(() => {
const categories = mediaList.reduce((acc, media) => {
acc[media.category] = (acc[media.category] || 0) + 1;
return acc;
}, {} as Record<MediaCategory, number>);
const favoritesCount = mediaList.filter(m => m.rating && m.rating >= 8).length;
return {
movies: categories['Movies'] || 0,
series: categories['TV Series'] || 0,
games: categories['Games'] || 0,
adult: categories['Adult'] || 0,
actors: new Set(mediaList.flatMap(m => m.staff?.map(s => s.id) || [])).size,
collections: 3, // Placeholder
favorites: favoritesCount
};
}, [mediaList]);
// Get recently added media
const recentMedia = useMemo(() => {
return [...mediaList].slice(0, 10);
}, [mediaList]);
// Get favorites
const favoritesMedia = useMemo(() => {
return [...mediaList]
.filter(m => m.rating && m.rating >= 8)
.slice(0, 8);
}, [mediaList]);
// Category card config
const categoryCards = [
{
key: 'movies',
label: 'MOVIES',
count: stats.movies,
icon: Film,
color: 'from-blue-500/20 to-blue-600/10',
iconBg: 'bg-blue-500/20',
path: '/movies'
},
{
key: 'series',
label: 'SERIES',
count: stats.series,
icon: Tv,
color: 'from-green-500/20 to-green-600/10',
iconBg: 'bg-green-500/20',
path: '/tv-series'
},
{
key: 'games',
label: 'GAMES',
count: stats.games,
icon: Gamepad2,
color: 'from-purple-500/20 to-purple-600/10',
iconBg: 'bg-purple-500/20',
path: '/games'
},
{
key: 'adult',
label: 'ADULT',
count: stats.adult,
icon: Eye,
color: 'from-rose-500/20 to-rose-600/10',
iconBg: 'bg-rose-500/20',
path: '/adult'
},
{
key: 'actors',
label: 'ACTORS',
count: stats.actors,
icon: Users,
color: 'from-amber-500/20 to-amber-600/10',
iconBg: 'bg-amber-500/20',
path: '/cast'
},
{
key: 'collections',
label: 'COLLECTIONS',
count: stats.collections,
icon: FolderKanban,
color: 'from-cyan-500/20 to-cyan-600/10',
iconBg: 'bg-cyan-500/20',
path: '/collections'
},
];
if (loading) {
return <Loading message="Loading dashboard..." />;
}
return (
<div className="pt-6 pb-20 px-6 max-w-[1920px] mx-auto">
{/* Welcome Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white" />
</div>
<h1 className="text-2xl font-bold text-foreground">
Welcome to MediaVault
</h1>
</div>
<p className="text-muted-foreground text-sm ml-11">Your media library at a glance</p>
</motion.div>
{/* Stats Cards */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-8"
>
{categoryCards.map((card, index) => {
const Icon = card.icon;
return (
<motion.div
key={card.key}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + index * 0.05 }}
onClick={() => navigate(card.path)}
className={`relative overflow-hidden rounded-xl p-5 bg-gradient-to-br ${card.color} border border-border/50 hover:border-border/80 transition-all duration-300 cursor-pointer group`}
>
<div className="flex items-start justify-between">
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">{card.label}</p>
<p className="text-3xl font-bold text-foreground">{card.count}</p>
</div>
<div className={`w-10 h-10 rounded-lg ${card.iconBg} flex items-center justify-center`}>
<Icon className="w-5 h-5 text-white" />
</div>
</div>
</motion.div>
);
})}
</motion.div>
{/* Favorites Section */}
{favoritesMedia.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mb-8"
>
<div
onClick={() => navigate('/browse?favorites=true')}
className="relative overflow-hidden rounded-xl p-6 bg-gradient-to-r from-[#e8466c]/10 to-[#f47298]/5 border border-[#e8466c]/20 hover:border-[#e8466c]/30 transition-all duration-300 cursor-pointer group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[#e8466c]/20 flex items-center justify-center">
<Heart className="w-6 h-6 text-[#e8466c]" />
</div>
<div>
<p className="text-xs font-semibold text-[#e8466c] uppercase tracking-wider">FAVORITES</p>
<p className="text-2xl font-bold text-foreground">{favoritesMedia.length} <span className="text-sm font-normal text-muted-foreground">items in your favorites</span></p>
</div>
</div>
<div className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
<span className="text-sm font-medium">View Favorites</span>
<ChevronRight className="w-5 h-5" />
</div>
</div>
</div>
</motion.div>
)}
{/* Recently Added Section */}
{recentMedia.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mb-8"
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-[#e8466c]" />
<h2 className="text-sm font-bold text-foreground uppercase tracking-wider">Recently Added</h2>
</div>
<button
onClick={() => navigate('/browse?sort=recent')}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
View All <ChevronRight className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-4">
{recentMedia.map((media) => (
<MediaCard
key={media.id}
media={media}
onClick={onMediaClick}
showBadge={true}
showFavorite={true}
/>
))}
</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 rounded-2xl flex items-center justify-center mb-6 border border-border">
<Database className="w-10 h-10" />
</div>
<p className="text-xl font-bold text-foreground">No media found</p>
<p className="text-sm">Start by adding media to your collection</p>
</div>
)}
</div>
);
}
+391 -256
View File
@@ -1,284 +1,419 @@
import { Media, Staff } from '@/types';
import { useNavigate } from 'react-router-dom';
import {
Play,
Bookmark,
MoreHorizontal,
Star,
ChevronLeft,
ChevronRight,
Search,
ListFilter
import { useState } from 'react';
import * as React from 'react';
import {
ArrowLeft, Calendar, Clock, Play, Star, Users, Disc, Layers,
Tv, BookOpen, Gamepad2, Film, Music, Package, Heart, Bookmark,
MoreHorizontal, Share2, ExternalLink
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { motion } from 'motion/react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Progress } from '@/components/ui/progress';
import OverviewTab from './details/tabs/OverviewTab';
import CastTab from './details/tabs/CastTab';
import SeasonsTab from './details/tabs/SeasonsTab';
import TracksTab from './details/tabs/TracksTab';
import SeriesTab from './details/tabs/SeriesTab';
interface DetailViewProps {
media: Media;
allMedia: Media[];
onPersonClick: (person: Staff) => void;
}
export default function DetailView({ media, onPersonClick }: DetailViewProps) {
const navigate = useNavigate();
return (
<div className="min-h-screen bg-zinc-50">
{/* Banner */}
<div className="relative h-[400px] w-full overflow-hidden">
<img
src={media.banner || media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-zinc-50 via-zinc-50/40 to-transparent" />
<button
onClick={() => 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"
>
<ChevronLeft size={24} />
</button>
</div>
const categoryIcons: Record<string, React.ReactNode> = {
'Anime': <Tv className="w-4 h-4" />,
'Movies': <Film className="w-4 h-4" />,
'TV Series': <Tv className="w-4 h-4" />,
'Music': <Music className="w-4 h-4" />,
'Books': <BookOpen className="w-4 h-4" />,
'Games': <Gamepad2 className="w-4 h-4" />,
'Consoles': <Package className="w-4 h-4" />,
'Adult': <Film className="w-4 h-4" />,
};
{/* Content */}
<div className="max-w-[1400px] mx-auto px-6 -mt-32 relative z-10 pb-24">
<div className="flex flex-col md:flex-row gap-8">
{/* Left Column: Poster */}
<div className="w-full md:w-[300px] shrink-0">
<motion.div
layoutId={`media-${media.id}`}
className={`rounded-xl overflow-hidden shadow-2xl bg-zinc-800 ${
media.aspectRatio === '16/9' ? 'aspect-video' :
media.aspectRatio === '1/1' ? 'aspect-square' :
'aspect-[2/3]'
}`}
>
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</motion.div>
const statusColors: Record<string, string> = {
'watching': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
'reading': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
'listening': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
'playing': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
'completed': 'bg-blue-500/10 text-blue-500 border-blue-500/20',
'planned': 'bg-amber-500/10 text-amber-500 border-amber-500/20',
'dropped': 'bg-red-500/10 text-red-500 border-red-500/20',
'on-hold': 'bg-muted text-muted-foreground border-border',
};
export default function DetailView({ media, allMedia, onPersonClick }: DetailViewProps) {
const navigate = useNavigate();
const [progress] = useState(media.playCount ? Math.min(100, (media.playCount * 10)) : 0);
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 hasFranchise = media.category === 'Games' && media.series && media.series.length > 0;
// Determine default tab based on available content
const getDefaultTab = () => {
if (hasEpisodes) return 'seasons';
if (hasTracks) return 'tracks';
if (hasCast) return 'cast';
return 'overview';
};
const [activeTab, setActiveTab] = useState(getDefaultTab());
const tabItems = [
{ id: 'overview', label: 'Overview', icon: BookOpen, hidden: false },
{ id: 'cast', label: 'Cast', icon: Users, hidden: !hasCast },
{ id: 'seasons', label: 'Seasons', icon: Layers, hidden: !hasEpisodes },
{ id: 'tracks', label: 'Tracks', icon: Disc, hidden: !hasTracks },
{ id: 'series', label: 'Series', icon: Gamepad2, hidden: !hasFranchise },
].filter(tab => !tab.hidden);
const statusBadgeClass = media.status ? statusColors[media.status] : 'bg-muted text-muted-foreground border-border';
return (
<TooltipProvider>
<div className="min-h-screen bg-background pb-20">
{/* Hero Section - Full height from top behind transparent navbar */}
<div className="relative h-[40vh] md:h-[45vh] overflow-hidden bg-zinc-900">
<img
src={media.banner || media.poster}
alt={media.title}
className="w-full h-full object-cover opacity-40 blur-sm scale-105"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/60 to-transparent" />
{/* Back Button - z-50 to ensure clickable */}
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="absolute top-4 left-4 sm:left-6 z-50 bg-black/30 hover:bg-black/50 text-white rounded-xl backdrop-blur-md transition-all duration-300 hover:scale-105 border border-white/20 h-10 w-10"
>
<ArrowLeft className="h-5 w-5" />
</Button>
{/* Quick Actions - z-50 to ensure clickable */}
<div className="absolute top-4 right-4 sm:right-6 z-50 flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
>
<Heart className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add to favorites</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
>
<Bookmark className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Bookmark</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
>
<Share2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Share</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>More options</TooltipContent>
</Tooltip>
</div>
{/* Right Column: Info */}
<div className="flex-1 pt-32 md:pt-40">
<div className="flex flex-wrap items-end justify-between gap-4 mb-6">
<div>
<h1 className="text-4xl font-black text-zinc-900 mb-2">
{media.title} <span className="text-zinc-400 font-medium">({media.year})</span>
</h1>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button size="icon" className="rounded-full bg-[#6d28d9] hover:bg-[#5b21b6]">
<Play size={20} fill="currentColor" />
</Button>
<Button size="icon" variant="outline" className="rounded-full border-zinc-300">
<Bookmark size={20} />
</Button>
<Button size="icon" variant="outline" className="rounded-full border-zinc-300">
<MoreHorizontal size={20} />
</Button>
{/* Hero Content - pt-16 to account for navbar + buttons */}
<div className="absolute inset-0 pt-16 flex items-end px-4 sm:px-6 pb-8">
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-6">
{/* Poster */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="shrink-0"
>
<Avatar className={`h-40 md:h-48 w-auto rounded-none border-4 border-background shadow-2xl ${
media.aspectRatio === '16/9' ? 'aspect-video' :
media.aspectRatio === '1/1' ? 'aspect-square' :
'aspect-[2/3]'
}`}>
<AvatarImage
src={media.poster}
alt={media.title}
className="object-cover"
referrerPolicy="no-referrer"
/>
<AvatarFallback className="rounded-none text-3xl bg-muted">
{categoryIcons[media.category] || <Film className="h-12 w-12" />}
</AvatarFallback>
</Avatar>
</motion.div>
{/* Title & Meta */}
<div className="flex-1 text-center md:text-left pb-2">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mb-3">
{categoryIcons[media.category] && (
<Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20">
{categoryIcons[media.category]}
<span className="ml-1">{media.category}</span>
</Badge>
)}
{media.type && (
<Badge variant="outline" className="text-xs">
{media.type}
</Badge>
)}
{media.status && (
<Badge variant="outline" className={`text-xs font-medium ${statusBadgeClass}`}>
{media.status.charAt(0).toUpperCase() + media.status.slice(1)}
</Badge>
)}
{media.completionStatus && (
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20 text-xs font-medium">
{media.completionStatus}
</Badge>
)}
</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
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-3 tracking-tight">
{media.title}
</h1>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
<span>{media.year}</span>
</div>
{media.rating && (
<div className="flex items-center gap-1.5">
<Star className="w-4 h-4 text-amber-500" />
<span>{media.rating.toFixed(1)}</span>
</div>
)}
{media.playtime && (
<div className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
<span>{media.playtime}h played</span>
</div>
)}
{hasEpisodes && (
<div className="flex items-center gap-1.5">
<Tv className="w-4 h-4" />
<span>{media.episodes!.length} episodes</span>
</div>
)}
{hasTracks && (
<div className="flex items-center gap-1.5">
<Disc className="w-4 h-4" />
<span>{media.tracks!.length} tracks</span>
</div>
)}
</div>
</div>
</motion.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>
))}
</div>
<div className="space-y-4">
{media.studios && media.studios.length > 0 && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Studios:</span>
{media.studios.join(', ')}
</p>
)}
{media.developers && media.developers.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Developers:</span>
{media.developers.map(dev => (
<Badge key={dev} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
{dev}
</Badge>
))}
</div>
)}
{media.platforms && media.platforms.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Platforms:</span>
{media.platforms.map(platform => (
<Badge key={platform} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
{platform}
</Badge>
))}
</div>
)}
{media.categories && media.categories.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Categories:</span>
{media.categories.map(category => (
<Badge key={category} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
{category}
</Badge>
))}
</div>
)}
{media.completionStatus && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Completion:</span>
{media.completionStatus}
</p>
)}
{media.source && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Source:</span>
{media.source}
</p>
)}
{media.playCount !== undefined && media.playCount !== null && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Play Count:</span>
{media.playCount}
</p>
)}
{media.playtime !== undefined && media.playtime !== null && media.playtime > 0 && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Playtime:</span>
{media.playtime}h
</p>
)}
{media.lastActivity && (
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Last Activity:</span>
{media.lastActivity}
</p>
)}
<div className="flex items-center gap-4">
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Links:</span>
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">AniDb</Button>
</div>
{/* Primary Action */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="shrink-0"
>
<Button size="lg" className="rounded-xl px-8 shadow-lg">
<Play className="w-5 h-5 mr-2 fill-current" />
Play
</Button>
</motion.div>
</div>
</div>
</div>
{/* Staff Section - Only show if staff data exists */}
{media.staff && media.staff.length > 0 && (
<section className="mt-20">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-black text-zinc-900">Cast & Crew</h2>
<div className="flex gap-2">
<Button variant="outline" size="icon" className="rounded-full border-zinc-200">
<ChevronLeft size={18} />
</Button>
<Button variant="outline" size="icon" className="rounded-full border-zinc-200">
<ChevronRight size={18} />
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{media.staff.map(person => (
<div
key={person.id}
className="flex items-center gap-4 bg-white p-3 rounded-xl shadow-sm border border-zinc-100 hover:shadow-md transition-shadow cursor-pointer group"
onClick={() => onPersonClick(person)}
>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0">
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" referrerPolicy="no-referrer" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
<p className="text-xs text-zinc-500 truncate">{person.role}</p>
</div>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-zinc-50">
<img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
</div>
</div>
))}
</div>
</section>
)}
{/* Episodes Section - Only show if episodes data exists */}
{media.episodes && media.episodes.length > 0 && (
<section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-xl">
<span className="opacity-40">{media.episodes.length}</span> Episode{media.episodes.length !== 1 ? 's' : ''}
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-zinc-100 border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-zinc-400">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-zinc-400">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-6">
{media.episodes.map(episode => (
<div key={episode.id} className="group cursor-pointer">
<div className="flex flex-col md:flex-row gap-6">
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-xl overflow-hidden shadow-sm relative">
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
{/* Content Section */}
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 mt-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Sidebar - Info Cards */}
<div className="space-y-4 lg:col-span-1">
{/* Progress Card */}
{progress > 0 && (
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground">Progress</span>
<span className="text-sm font-bold text-primary">{progress}%</span>
</div>
<div className="flex-1 py-1">
<div className="flex items-center justify-between mb-2">
<h3 className="font-black text-zinc-900 group-hover:text-[#6d28d9] transition-colors">
S1:E{episode.number} {episode.title}
</h3>
<span className="text-xs font-bold text-zinc-400">{episode.date} {episode.duration}</span>
<Progress value={progress} className="h-2" />
</CardContent>
</Card>
)}
{/* Studios */}
{media.studios && media.studios.length > 0 && (
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-4">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3 flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<Film className="w-3 h-3 text-primary" />
</div>
<p className="text-sm text-zinc-500 leading-relaxed line-clamp-3">
{episode.description}
</p>
Studios
</h3>
<div className="flex flex-wrap gap-1.5">
{media.studios.map(studio => (
<Badge key={studio} variant="secondary" className="text-xs">
{studio}
</Badge>
))}
</div>
</div>
<Separator className="mt-6 bg-zinc-200" />
</div>
))}
</CardContent>
</Card>
)}
{/* Platforms (for Games) */}
{media.platforms && media.platforms.length > 0 && (
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-4">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3 flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<Gamepad2 className="w-3 h-3 text-primary" />
</div>
Platforms
</h3>
<div className="flex flex-wrap gap-1.5">
{media.platforms.map(platform => (
<Badge key={platform} variant="secondary" className="text-xs">
{platform}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Developers (for Games) */}
{media.developers && media.developers.length > 0 && (
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-4">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3 flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<Users className="w-3 h-3 text-primary" />
</div>
Developers
</h3>
<div className="flex flex-wrap gap-1.5">
{media.developers.map(dev => (
<Badge key={dev} variant="secondary" className="text-xs">
{dev}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Source */}
{media.source && (
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-4">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<ExternalLink className="w-3 h-3 text-primary" />
</div>
Source
</h3>
<Badge variant="outline" className="text-xs capitalize">
{media.source}
</Badge>
</CardContent>
</Card>
)}
</div>
</section>
)}
{/* Main Content - Tabs */}
<div className="lg:col-span-3">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="mb-4 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto flex-wrap">
{tabItems.map(tab => {
const Icon = tab.icon;
return (
<TabsTrigger
key={tab.id}
value={tab.id}
className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm"
>
<Icon className="w-4 h-4" />
{tab.label}
</TabsTrigger>
);
})}
</TabsList>
<TabsContent value="overview" className="mt-0">
<OverviewTab media={media} />
</TabsContent>
{hasCast && (
<TabsContent value="cast" className="mt-0">
<CastTab staff={media.staff!} onPersonClick={onPersonClick} />
</TabsContent>
)}
{hasEpisodes && (
<TabsContent value="seasons" className="mt-0">
<SeasonsTab episodes={media.episodes!} />
</TabsContent>
)}
{hasTracks && (
<TabsContent value="tracks" className="mt-0">
<TracksTab tracks={media.tracks!} />
</TabsContent>
)}
{hasFranchise && (
<TabsContent value="series" className="mt-0">
<SeriesTab media={media} allMedia={allMedia} onMediaClick={(m) => navigate(`/media/${m.id}`)} />
</TabsContent>
)}
</Tabs>
</div>
</div>
</div>
</div>
</div>
</TooltipProvider>
);
}
+170 -40
View File
@@ -1,8 +1,9 @@
import { Search, User, X, Plus, Download, Settings } from 'lucide-react';
import { Search, User, X, Plus, Download, Settings, Menu } from 'lucide-react';
import { cn } from '@/lib/utils';
import React, { useState } from 'react';
import { Link, NavLink } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Link, NavLink, useLocation } from 'react-router-dom';
import { MediaCategory } from '@/types';
import { useTheme } from '@/contexts/ThemeContext';
interface HeaderProps {
onSearch: (query: string) => void;
@@ -23,6 +24,31 @@ export default function Header({
}: HeaderProps) {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [scrolled, setScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { theme } = useTheme();
const location = useLocation();
// Map category names to URL-friendly paths
const categoryPaths: Record<MediaCategory, string> = {
'Anime': 'anime',
'Movies': 'movies',
'TV Series': 'tv-series',
'Music': 'music',
'Books': 'books',
'Games': 'games',
'Consoles': 'consoles',
'Adult': 'adult'
};
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
@@ -39,94 +65,198 @@ export default function Header({
};
return (
<header
<header
className={cn(
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-300",
transparent ? "bg-transparent" : "bg-[#6d28d9]"
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-500",
transparent && !scrolled
? "bg-transparent"
: transparent && scrolled
? "backdrop-blur-xl bg-background/70 border-b border-border/30"
: "backdrop-blur-xl bg-gradient-to-r from-[#e8466c]/90 via-[#f47298]/90 to-[#e8466c]/90 border-b border-white/10"
)}
>
<div className="flex items-center gap-8">
<Link
<Link
to="/"
className="text-2xl font-black text-white flex items-center gap-1"
className={cn(
"text-2xl font-black flex items-center gap-2 transition-all duration-300 hover:scale-105",
(transparent && !scrolled) || !transparent ? "text-white" : "text-foreground"
)}
>
<div className="w-6 h-6 bg-white rounded-full flex items-center justify-center">
<div className="w-3 h-3 bg-[#6d28d9] rounded-full" />
<div className={cn(
"w-8 h-8 rounded-xl flex items-center justify-center shadow-lg transition-all duration-300",
(transparent && !scrolled) || !transparent
? "bg-white/20 backdrop-blur-sm border border-white/30"
: "bg-gradient-to-br from-[#e8466c] to-[#f47298] shadow-[#e8466c]/30"
)}>
<div className={cn(
"w-4 h-4 rounded-full",
(transparent && !scrolled) || !transparent ? "bg-white" : "bg-white"
)} />
</div>
kyoo
<span className="bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
omnyx
</span>
</Link>
<nav className="hidden md:flex items-center gap-6">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className={cn(
"md:hidden p-2 rounded-lg transition-all duration-300 hover:bg-white/10",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Menu size={20} />
</button>
<nav className="hidden md:flex items-center gap-1">
{enabledCategories.map(cat => (
<button
<NavLink
key={cat}
onClick={() => onCategoryChange(cat)}
className={cn(
"text-sm font-bold transition-colors uppercase tracking-wider",
activeCategory === cat ? "text-white" : "text-white/60 hover:text-white"
to={`/${categoryPaths[cat]}`}
className={({ isActive }) => cn(
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg relative",
(transparent && !scrolled) || !transparent
? isActive
? "text-white bg-white/10"
: "text-white/70 hover:text-white hover:bg-white/5"
: isActive
? "text-foreground bg-[#e8466c]/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{cat}
</button>
</NavLink>
))}
<div className="w-px h-4 bg-white/20 mx-2" />
<NavLink
<div className={cn(
"w-px h-6 mx-2",
(transparent && !scrolled) || !transparent ? "bg-white/20" : "bg-border"
)} />
<NavLink
to="/cast"
className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider",
isActive ? "text-white" : "text-white/60 hover:text-white"
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg",
(transparent && !scrolled) || !transparent
? isActive ? "text-white bg-white/10" : "text-white/70 hover:text-white hover:bg-white/5"
: isActive ? "text-foreground bg-[#e8466c]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
CAST
</NavLink>
</nav>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className={cn(
"flex items-center transition-all duration-300 overflow-hidden",
isSearchOpen ? "w-48 md:w-64 bg-white/10 rounded-full px-3 py-1" : "w-0"
"flex items-center transition-all duration-300 overflow-hidden rounded-2xl",
isSearchOpen ? "w-48 md:w-72 px-4 py-2.5" : "w-0",
(transparent && !scrolled) || !transparent
? "bg-white/10 backdrop-blur-md border border-white/20"
: "bg-muted/50 backdrop-blur-md border border-border"
)}>
<input
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={handleSearchChange}
className="bg-transparent border-none outline-none text-white text-sm w-full placeholder:text-white/50"
className={cn(
"bg-transparent border-none outline-none text-sm w-full placeholder:opacity-60",
(transparent && !scrolled) || !transparent
? "text-white placeholder:text-white"
: "text-foreground placeholder:text-muted-foreground"
)}
autoFocus={isSearchOpen}
/>
</div>
<button
<button
onClick={toggleSearch}
className="p-2 text-white/90 hover:text-white transition-colors"
className={cn(
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
{isSearchOpen ? <X size={20} /> : <Search size={20} />}
{isSearchOpen ? <X size={18} /> : <Search size={18} />}
</button>
<Link
to="/add"
className="p-2 text-white/90 hover:text-white transition-colors"
className={cn(
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Plus size={20} />
<Plus size={18} />
</Link>
<Link
to="/import"
className="p-2 text-white/90 hover:text-white transition-colors"
className={cn(
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white hover:bg-white/10"
: "text-foreground hover:text-foreground hover:bg-muted"
)}
>
<Download size={20} />
<Download size={18} />
</Link>
<Link
to="/settings"
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"
)}
>
<Settings size={20} />
<Settings size={18} />
</Link>
<button className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20">
<img
src="https://picsum.photos/seed/user/100/100"
alt="User"
<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-[#e8466c]/50"
)}>
<img
src="https://picsum.photos/seed/user/100/100"
alt="User"
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</button>
</div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden absolute top-full left-0 right-0 bg-background border-b border-border shadow-lg">
<nav className="flex flex-col p-4 gap-2">
{enabledCategories.map(cat => (
<NavLink
key={cat}
to={`/${categoryPaths[cat]}`}
onClick={() => setIsMobileMenuOpen(false)}
className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
isActive ? "text-[#e8466c] bg-[#e8466c]/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-[#e8466c] bg-[#e8466c]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
CAST
</NavLink>
</nav>
</div>
)}
</header>
);
}
+602 -73
View File
@@ -5,22 +5,97 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
import { importFromPlaynite, PlayniteConfig, PlayniteImportOptions } from '@/lib/playniteImporter';
import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter';
import { fetchSettings, updateSettings } from '@/api';
const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:3000';
export default function ImporterView() {
const navigate = useNavigate();
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: import.meta.env.VITE_XBVR_URL || BASE_URL });
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: import.meta.env.VITE_XBVR_URL || BASE_URL, updateExisting: true });
const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({
url: import.meta.env.VITE_STASHAPP_URL || '',
apiKey: import.meta.env.VITE_STASHAPP_API_KEY || ''
apiKey: import.meta.env.VITE_STASHAPP_API_KEY || '',
updateExisting: true
});
const [playniteConfig, setPlayniteConfig] = useState<PlayniteConfig>({
ip: import.meta.env.VITE_PLAYNITE_IP || '',
apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '',
port: parseInt(import.meta.env.VITE_PLAYNITE_PORT || '19821')
apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '',
port: import.meta.env.VITE_PLAYNITE_PORT ? parseInt(import.meta.env.VITE_PLAYNITE_PORT) : undefined,
updateExisting: true
});
const [playniteOptions, setPlayniteOptions] = useState<PlayniteImportOptions>({
limit: undefined,
nameFilter: undefined
});
const [jellyfinConfig, setJellyfinConfig] = useState<JellyfinConfig>({
url: import.meta.env.VITE_JELLYFIN_URL || '',
apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || ''
});
const [jellyfinOptions, setJellyfinOptions] = useState<JellyfinImportOptions>({
importMovies: true,
importSeries: true,
importMusic: true,
importCast: true,
limit: undefined,
libraryMappings: [],
updateExisting: true
});
const [jellyfinLibraries, setJellyfinLibraries] = useState<Array<{ Id: string; Name: string; CollectionType: string }>>([]);
const [libraryMappings, setLibraryMappings] = useState<LibraryMapping[]>([]);
const [showLibraryMapping, setShowLibraryMapping] = useState(false);
const [isInitialLoad, setIsInitialLoad] = useState(true);
// Load library mappings from API on mount
useEffect(() => {
const loadMappings = async () => {
try {
const settings = await fetchSettings();
if (settings?.jellyfinLibraryMappings) {
const mappings = JSON.parse(settings.jellyfinLibraryMappings);
setLibraryMappings(mappings);
setShowLibraryMapping(true);
}
} catch (error) {
console.error('Failed to load library mappings from API:', error);
// Fallback to localStorage
const savedMappings = localStorage.getItem('jellyfinLibraryMappings');
if (savedMappings) {
try {
setLibraryMappings(JSON.parse(savedMappings));
setShowLibraryMapping(true);
} catch (error) {
console.error('Failed to parse saved library mappings:', error);
}
}
}
setIsInitialLoad(false);
};
loadMappings();
}, []);
// Save library mappings to API and localStorage when they change
useEffect(() => {
if (libraryMappings.length > 0 && !isInitialLoad) {
// Save to localStorage as fallback
localStorage.setItem('jellyfinLibraryMappings', JSON.stringify(libraryMappings));
// Save to API
const saveMappings = async () => {
try {
const settings = await fetchSettings();
if (settings) {
settings.jellyfinLibraryMappings = JSON.stringify(libraryMappings);
await updateSettings(settings);
}
} catch (error) {
console.error('Failed to save library mappings to API:', error);
}
};
saveMappings();
}
}, [libraryMappings, isInitialLoad]);
const [progress, setProgress] = useState<ImportProgress>({
current: 0,
total: 0,
@@ -128,6 +203,7 @@ export default function ImporterView() {
const result = await importFromPlaynite(
playniteConfig,
playniteOptions,
addLog,
(progressUpdate) => {
setProgress(prev => ({ ...prev, ...progressUpdate }));
@@ -137,6 +213,120 @@ export default function ImporterView() {
setProgress(result);
};
const handleJellyfinImport = async () => {
setProgress({
current: 0,
total: 0,
stage: 'fetching',
message: 'Connecting to Jellyfin API...',
videosImported: 0,
actorsImported: 0,
errors: []
});
setImportLog([]);
// Update options with current library mappings
const optionsWithMappings = {
...jellyfinOptions,
libraryMappings: libraryMappings
};
const result = await importFromJellyfin(
jellyfinConfig,
optionsWithMappings,
addLog,
(progressUpdate) => {
setProgress(prev => ({ ...prev, ...progressUpdate }));
}
);
setProgress(result);
};
const handleJellyfinCleanup = async () => {
setProgress({
current: 0,
total: 0,
stage: 'fetching',
message: 'Connecting to Jellyfin API for cleanup...',
videosImported: 0,
actorsImported: 0,
errors: []
});
setImportLog([]);
const result = await cleanupJellyfinMedia(
jellyfinConfig,
jellyfinOptions,
addLog,
(progressUpdate) => {
setProgress(prev => ({ ...prev, ...progressUpdate }));
}
);
setProgress(result);
};
const handleFetchJellyfinLibraries = async () => {
try {
const libraries = await fetchJellyfinLibraries(jellyfinConfig);
setJellyfinLibraries(libraries);
// Merge existing mappings with new libraries
const newMappings: LibraryMapping[] = libraries.map(lib => {
// Check if mapping already exists
const existing = libraryMappings.find(m => m.libraryName === lib.Name);
if (existing) {
return existing;
}
// Create new mapping with default category
let defaultCategory: 'TV Series' | 'Anime' | 'Movies' | 'Music' = 'TV Series';
if (lib.CollectionType === 'movies') {
defaultCategory = 'Movies';
} else if (lib.CollectionType === 'music') {
defaultCategory = 'Music';
} else if (lib.CollectionType === 'tvshows') {
defaultCategory = 'TV Series';
}
return {
libraryName: lib.Name,
category: defaultCategory
};
});
setLibraryMappings(newMappings);
setShowLibraryMapping(true);
addLog(`Fetched ${libraries.length} libraries from Jellyfin`);
} catch (error) {
addLog(`Failed to fetch libraries: ${error}`);
}
};
const handleLibraryMappingChange = (libraryName: string, category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip') => {
setLibraryMappings(prev => {
const existing = prev.find(m => m.libraryName === libraryName);
if (existing) {
return prev.map(m => m.libraryName === libraryName ? { ...m, category } : m);
} else {
return [...prev, { libraryName, category }];
}
});
};
const handleLibraryPathSegmentsChange = (libraryName: string, value: string) => {
const segments = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
setLibraryMappings(prev => {
const existing = prev.find(m => m.libraryName === libraryName);
if (existing) {
return prev.map(m => m.libraryName === libraryName ? { ...m, pathSegments: segments } : m);
} else {
return [...prev, { libraryName, category: 'TV Series', pathSegments: segments }];
}
});
};
const resetImport = () => {
setProgress({
current: 0,
@@ -156,7 +346,7 @@ export default function ImporterView() {
};
return (
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
@@ -164,13 +354,13 @@ export default function ImporterView() {
variant="ghost"
size="icon"
onClick={() => navigate('/')}
className="text-zinc-600 hover:text-[#6d28d9]"
className="text-muted-foreground hover:text-[#6d28d9] hover:bg-muted/50 rounded-xl transition-all duration-300"
>
<ArrowLeft size={20} />
</Button>
<div>
<h1 className="text-2xl font-black text-zinc-900">Media Importers</h1>
<p className="text-sm text-zinc-500 font-medium">Import media from external platforms</p>
<h1 className="text-4xl font-black text-foreground mb-1">Media Importers</h1>
<p className="text-sm text-muted-foreground font-medium">Import media from external platforms</p>
</div>
</div>
</div>
@@ -179,45 +369,56 @@ export default function ImporterView() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{/* XBVR Importer Card */}
{xbvrConfig.url && (
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<Film className="text-purple-600" size={24} />
</div>
<div>
<h3 className="font-bold text-zinc-900">XBVR</h3>
<p className="text-xs text-zinc-500 font-medium">Adult Video Manager</p>
<h3 className="font-bold text-foreground">XBVR</h3>
<p className="text-xs text-muted-foreground font-medium">Adult Video Manager</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-zinc-200"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-zinc-600 mb-4">
<p className="text-sm text-muted-foreground mb-4">
Import adult videos and actors from your XBVR database.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">XBVR URL</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">XBVR URL</label>
<input
type="text"
value={xbvrConfig.url}
onChange={(e) => setXbvrConfig({ ...xbvrConfig, url: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="http://192.168.1.102:10001"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="xbvr-update-existing"
checked={xbvrConfig.updateExisting}
onChange={(e) => setXbvrConfig({ ...xbvrConfig, updateExisting: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<label htmlFor="xbvr-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
</div>
<Button
onClick={handleXBVRImport}
disabled={progress.stage !== 'idle' || !xbvrConfig.url}
className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-bold"
className="w-full bg-[#6d28d9] hover:bg-[#d13d60] text-white font-bold"
>
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
<>
@@ -237,52 +438,63 @@ export default function ImporterView() {
{/* StashAPP Importer Card */}
{stashappConfig.url && (
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<Film className="text-blue-600" size={24} />
</div>
<div>
<h3 className="font-bold text-zinc-900">StashAPP</h3>
<p className="text-xs text-zinc-500 font-medium">Adult Content Manager</p>
<h3 className="font-bold text-foreground">StashAPP</h3>
<p className="text-xs text-muted-foreground font-medium">Adult Content Manager</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-zinc-200"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-zinc-600 mb-4">
<p className="text-sm text-muted-foreground mb-4">
Import adult videos and performers from your StashAPP database.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">StashAPP URL</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">StashAPP URL</label>
<input
type="text"
value={stashappConfig.url}
onChange={(e) => setStashappConfig({ ...stashappConfig, url: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="http://192.168.1.102:10001"
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Key (optional)</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Key (optional)</label>
<input
type="password"
value={stashappConfig.apiKey || ''}
onChange={(e) => setStashappConfig({ ...stashappConfig, apiKey: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="Enter API key if required"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="stashapp-update-existing"
checked={stashappConfig.updateExisting}
onChange={(e) => setStashappConfig({ ...stashappConfig, updateExisting: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<label htmlFor="stashapp-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
</div>
<Button
onClick={handleStashAPPImport}
disabled={progress.stage !== 'idle' || !stashappConfig.url}
@@ -306,38 +518,38 @@ export default function ImporterView() {
{/* StashAPP Actor Updater Card */}
{stashappConfig.url && (
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<Users className="text-green-600" size={24} />
</div>
<div>
<h3 className="font-bold text-zinc-900">StashAPP Actor Updater</h3>
<p className="text-xs text-zinc-500 font-medium">Update existing actors</p>
<h3 className="font-bold text-foreground">StashAPP Actor Updater</h3>
<p className="text-xs text-muted-foreground font-medium">Update existing actors</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-zinc-200"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-zinc-600 mb-4">
<p className="text-sm text-muted-foreground mb-4">
Update existing actors with fresh data from StashAPP and create missing ones.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Key (optional)</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Key (optional)</label>
<input
type="password"
value={stashappConfig.apiKey || ''}
onChange={(e) => setStashappConfig({ ...stashappConfig, apiKey: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="Enter API key if required"
/>
</div>
@@ -364,63 +576,96 @@ export default function ImporterView() {
{/* Playnite Importer Card */}
{playniteConfig.ip && playniteConfig.apiToken && (
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
<Film className="text-orange-600" size={24} />
</div>
<div>
<h3 className="font-bold text-zinc-900">Playnite</h3>
<p className="text-xs text-zinc-500 font-medium">Game Library Manager</p>
<h3 className="font-bold text-foreground">Playnite</h3>
<p className="text-xs text-muted-foreground font-medium">Game Library Manager</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-zinc-200"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-zinc-600 mb-4">
<p className="text-sm text-muted-foreground mb-4">
Import games from your Playnite library via Bridge API.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">IP Address</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">IP Address</label>
<input
type="text"
value={playniteConfig.ip}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, ip: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="localhost"
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">Port</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Port</label>
<input
type="number"
value={playniteConfig.port || 19821}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, port: parseInt(e.target.value) || 19821 })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="19821"
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Token</label>
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Token</label>
<input
type="password"
value={playniteConfig.apiToken}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, apiToken: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="pb_your_token_here"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="playnite-update-existing"
checked={playniteConfig.updateExisting}
onChange={(e) => setPlayniteConfig({ ...playniteConfig, updateExisting: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<label htmlFor="playnite-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Limit (optional, for testing)</label>
<input
type="number"
value={playniteOptions.limit || ''}
onChange={(e) => setPlayniteOptions({ ...playniteOptions, limit: e.target.value ? parseInt(e.target.value) : undefined })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="e.g. 10"
/>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Name Filter (optional, for testing)</label>
<input
type="text"
value={playniteOptions.nameFilter || ''}
onChange={(e) => setPlayniteOptions({ ...playniteOptions, nameFilter: e.target.value || undefined })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="e.g. Reside"
/>
</div>
<Button
onClick={handlePlayniteImport}
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
@@ -441,29 +686,303 @@ export default function ImporterView() {
</div>
</div>
)}
{/* Jellyfin Importer Card */}
{jellyfinConfig.url && (
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<Film className="text-indigo-600" size={24} />
</div>
<div>
<h3 className="font-bold text-foreground">Jellyfin</h3>
<p className="text-xs text-muted-foreground font-medium">Media Server</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Import movies, series, music and cast from your Jellyfin server.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Jellyfin URL</label>
<input
type="text"
value={jellyfinConfig.url}
onChange={(e) => setJellyfinConfig({ ...jellyfinConfig, url: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="http://192.168.1.102:8096"
/>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Key</label>
<input
type="password"
value={jellyfinConfig.apiKey || ''}
onChange={(e) => setJellyfinConfig({ ...jellyfinConfig, apiKey: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="Enter API key"
/>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-2 block">Import Options</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMovies}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMovies: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Movies</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importSeries}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importSeries: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Series</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMusic}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMusic: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Music</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importCast}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importCast: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Cast</span>
</label>
</div>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Limit (optional, for testing)</label>
<input
type="number"
value={jellyfinOptions.limit || ''}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, limit: e.target.value ? parseInt(e.target.value) : undefined })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="e.g. 10"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="jellyfin-update-existing"
checked={jellyfinOptions.updateExisting}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, updateExisting: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<label htmlFor="jellyfin-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-2 block">Library Category Mapping</label>
<Button
onClick={handleFetchJellyfinLibraries}
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
variant="outline"
className="w-full mb-3 font-bold border-border"
>
<RefreshCw size={16} className="mr-2" />
Fetch Libraries
</Button>
{showLibraryMapping && libraryMappings.length > 0 && (
<div className="space-y-2 max-h-48 overflow-y-auto">
{libraryMappings.map(mapping => (
<div key={mapping.libraryName} className="space-y-1 p-2 border border-border rounded-lg">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted-foreground flex-1 truncate">{mapping.libraryName}</span>
<select
value={mapping.category}
onChange={(e) => handleLibraryMappingChange(mapping.libraryName, e.target.value as any)}
disabled={progress.stage !== 'idle'}
className="text-xs px-2 py-1 border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
>
<option value="TV Series">TV Series</option>
<option value="Anime">Anime</option>
<option value="Movies">Movies</option>
<option value="Music">Music</option>
<option value="skip">Nicht importieren</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Pfad-Segmente (kommagetrennt):</span>
<input
type="text"
value={mapping.pathSegments?.join(', ') || ''}
onChange={(e) => handleLibraryPathSegmentsChange(mapping.libraryName, e.target.value)}
disabled={progress.stage !== 'idle'}
placeholder="z.B. Serien, Animes"
className="text-xs px-2 py-1 border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed flex-1"
/>
</div>
</div>
))}
</div>
)}
</div>
<Button
onClick={handleJellyfinImport}
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold"
>
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
Importing...
</>
) : (
<>
<Download size={16} className="mr-2" />
Import from Jellyfin
</>
)}
</Button>
</div>
</div>
)}
{/* Jellyfin Cleanup Card */}
{jellyfinConfig.url && (
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6 hover:border-[#6d28d9]/50 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<RefreshCw className="text-red-600" size={24} />
</div>
<div>
<h3 className="font-bold text-foreground">Jellyfin Cleanup</h3>
<p className="text-xs text-muted-foreground font-medium">Remove deleted media</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Remove Jellyfin media and cast that no longer exist in your Jellyfin server.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-muted-foreground mb-2 block">Cleanup Options</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMovies}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMovies: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Movies</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importSeries}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importSeries: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Series</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMusic}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMusic: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Music</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importCast}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importCast: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Cast</span>
</label>
</div>
</div>
<Button
onClick={handleJellyfinCleanup}
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
className="w-full bg-red-600 hover:bg-red-700 text-white font-bold"
>
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
Cleaning up...
</>
) : (
<>
<RefreshCw size={16} className="mr-2" />
Cleanup Jellyfin Media
</>
)}
</Button>
</div>
</div>
)}
</div>
{/* Progress Section */}
{progress.stage !== 'idle' && (
<div className="bg-white border border-zinc-200 rounded-xl p-6">
<div className="bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
{progress.stage === 'complete' ? (
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="text-green-600" size={20} />
<div className="w-10 h-10 bg-green-500/10 rounded-full flex items-center justify-center border border-green-500/30">
<CheckCircle className="text-green-500" size={20} />
</div>
) : progress.stage === 'error' ? (
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<XCircle className="text-red-600" size={20} />
<div className="w-10 h-10 bg-red-500/10 rounded-full flex items-center justify-center border border-red-500/30">
<XCircle className="text-red-500" size={20} />
</div>
) : (
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
<Loader2 className="text-purple-600 animate-spin" size={20} />
<div className="w-10 h-10 bg-purple-500/10 rounded-full flex items-center justify-center border border-purple-500/30">
<Loader2 className="text-purple-500 animate-spin" size={20} />
</div>
)}
<div>
<h3 className="font-bold text-zinc-900">{progress.message}</h3>
<p className="text-xs text-zinc-500 font-medium">
<h3 className="font-bold text-foreground">{progress.message}</h3>
<p className="text-xs text-muted-foreground font-medium">
{progress.stage === 'fetching' && 'Connecting to external service...'}
{progress.stage === 'importing' && `Processing items... ${getProgressPercentage()}%`}
{progress.stage === 'complete' && 'Import finished'}
@@ -476,7 +995,7 @@ export default function ImporterView() {
variant="outline"
size="sm"
onClick={resetImport}
className="gap-2 font-bold border-zinc-200"
className="gap-2 font-bold border-border/50 hover:border-[#6d28d9]/50 transition-all duration-300"
>
<RefreshCw size={16} />
Reset
@@ -487,16 +1006,16 @@ export default function ImporterView() {
{/* Progress Bar */}
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
<div className="mb-6">
<div className="h-2 bg-zinc-100 rounded-full overflow-hidden">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full transition-all duration-300 ease-out",
progress.stage === 'error' ? "bg-red-500" : "bg-[#6d28d9]"
progress.stage === 'error' ? "bg-gradient-to-r from-red-500 to-red-600" : "bg-gradient-to-r from-[#6d28d9] to-[#f47298]"
)}
style={{ width: `${getProgressPercentage()}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-xs text-zinc-500 font-medium">
<div className="flex justify-between mt-2 text-xs text-muted-foreground font-medium">
<span>{progress.current} / {progress.total} items</span>
<span>{getProgressPercentage()}%</span>
</div>
@@ -505,26 +1024,36 @@ export default function ImporterView() {
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-zinc-50 rounded-lg p-4">
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
<div className="flex items-center gap-2 mb-2">
<Film size={16} className="text-zinc-400" />
<span className="text-xs font-bold text-zinc-500">{(progress as any).gamesImported !== undefined ? 'Games' : 'Videos'}</span>
<Film size={16} className="text-[#6d28d9]" />
<span className="text-xs font-bold text-muted-foreground">
{(progress as any).gamesImported !== undefined ? 'Games' :
(progress as any).moviesImported !== undefined ? 'Movies' :
(progress as any).seriesImported !== undefined ? 'Series' :
(progress as any).musicImported !== undefined ? 'Music' : 'Videos'}
</span>
</div>
<p className="text-2xl font-black text-zinc-900">{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported : progress.videosImported}</p>
<p className="text-2xl font-black text-foreground">
{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported :
(progress as any).moviesImported !== undefined ? (progress as any).moviesImported :
(progress as any).seriesImported !== undefined ? (progress as any).seriesImported :
(progress as any).musicImported !== undefined ? (progress as any).musicImported : progress.videosImported}
</p>
</div>
<div className="bg-zinc-50 rounded-lg p-4">
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
<div className="flex items-center gap-2 mb-2">
<Users size={16} className="text-zinc-400" />
<span className="text-xs font-bold text-zinc-500">Actors</span>
<Users size={16} className="text-[#6d28d9]" />
<span className="text-xs font-bold text-muted-foreground">{(progress as any).castImported !== undefined ? 'Cast' : 'Actors'}</span>
</div>
<p className="text-2xl font-black text-zinc-900">{progress.actorsImported}</p>
<p className="text-2xl font-black text-foreground">{(progress as any).castImported !== undefined ? (progress as any).castImported : progress.actorsImported}</p>
</div>
<div className="bg-zinc-50 rounded-lg p-4">
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-4 border border-border/50">
<div className="flex items-center gap-2 mb-2">
<AlertCircle size={16} className="text-zinc-400" />
<span className="text-xs font-bold text-zinc-500">Errors</span>
<AlertCircle size={16} className="text-red-500" />
<span className="text-xs font-bold text-muted-foreground">Errors</span>
</div>
<p className="text-2xl font-black text-zinc-900">{progress.errors.length}</p>
<p className="text-2xl font-black text-foreground">{progress.errors.length}</p>
</div>
</div>
@@ -532,7 +1061,7 @@ export default function ImporterView() {
{importLog.length > 0 && (
<div
ref={logContainerRef}
className="bg-zinc-900 rounded-lg p-4 max-h-64 overflow-y-auto"
className="bg-zinc-900/90 backdrop-blur-sm rounded-xl p-4 max-h-64 overflow-y-auto border border-border/50"
>
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">
{importLog.join('\n')}
@@ -543,10 +1072,10 @@ export default function ImporterView() {
{/* Errors */}
{progress.errors.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-bold text-red-600 mb-2">Errors</h4>
<div className="bg-red-50 border border-red-200 rounded-lg p-3 max-h-32 overflow-y-auto">
<h4 className="text-sm font-bold text-red-500 mb-2">Errors</h4>
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 max-h-32 overflow-y-auto backdrop-blur-sm">
{progress.errors.map((error, index) => (
<p key={index} className="text-xs text-red-700 font-medium mb-1">
<p key={index} className="text-xs text-red-500 font-medium mb-1">
{error}
</p>
))}
+9 -8
View File
@@ -21,6 +21,7 @@ interface LibrarySettingsProps {
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
Anime: <Tv size={18} />,
Movies: <Film size={18} />,
'TV Series': <Tv size={18} />,
Music: <Music size={18} />,
Books: <Book size={18} />,
Consoles: <Gamepad2 size={18} />,
@@ -34,29 +35,29 @@ export default function LibrarySettings({ enabledCategories, onToggleCategory }:
return (
<Dialog>
<DialogTrigger asChild>
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-colors">
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-all duration-300 hover:scale-110">
<Settings size={20} />
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
<DialogContent className="sm:max-w-[425px] bg-card/50 backdrop-blur-sm rounded-3xl border border-border/50">
<DialogHeader>
<DialogTitle className="text-2xl font-black text-zinc-900">Library Settings</DialogTitle>
<DialogDescription className="text-zinc-500 font-medium">
<DialogTitle className="text-2xl font-black text-foreground">Library Settings</DialogTitle>
<DialogDescription className="text-muted-foreground font-medium">
Toggle which media areas you want to see in your library.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-6">
{categories.map((category) => (
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 border border-zinc-100 transition-all hover:border-[#6d28d9]/20">
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-muted/30 border border-border/50 transition-all hover:border-[#e8466c]/30 hover:bg-muted/50">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#e8466c] shadow-sm border border-border/30">
{CATEGORY_ICONS[category]}
</div>
<div>
<Label htmlFor={category} className="text-sm font-black text-zinc-900 cursor-pointer">
<Label htmlFor={category} className="text-sm font-black text-foreground cursor-pointer">
{category}
</Label>
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
{enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
</p>
</div>
+502 -32
View File
@@ -1,14 +1,78 @@
import { Media } from '@/types';
import React, { useState } from 'react';
import { Media, MediaCategory } from '@/types';
import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import { motion, AnimatePresence } from 'motion/react';
import {
Star,
Heart,
Gamepad2,
Film,
Tv,
Eye,
Play,
Calendar,
Hash,
Trophy,
} from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
interface MediaCardProps {
key?: string;
media: Media;
onClick: (media: Media) => void;
showBadge?: boolean;
showFavorite?: boolean;
isFavorite?: boolean;
onFavoriteToggle?: (media: Media) => void;
variant?: 'default' | 'compact' | 'hero' | 'minimal';
}
export default function MediaCard({ media, onClick }: MediaCardProps) {
const categoryConfig: Record<
MediaCategory,
{ label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive'; icon: React.ElementType | null }
> = {
Anime: { label: 'ANIME', variant: 'secondary', icon: null },
Movies: { label: 'MOVIE', variant: 'secondary', icon: Film },
'TV Series': { label: 'SERIES', variant: 'secondary', icon: Tv },
Music: { label: 'MUSIC', variant: 'secondary', icon: null },
Books: { label: 'BOOK', variant: 'secondary', icon: null },
Games: { label: 'GAME', variant: 'secondary', icon: Gamepad2 },
Consoles: { label: 'CONSOLE', variant: 'secondary', icon: null },
Adult: { label: 'ADULT', variant: 'destructive', icon: Eye },
};
const statusConfig: Record<
string,
{ label: string; color: string; ringColor: string }
> = {
watching: { label: 'Watching', color: 'bg-blue-500', ringColor: 'ring-blue-500' },
completed: { label: 'Completed', color: 'bg-green-500', ringColor: 'ring-green-500' },
planned: { label: 'Planned', color: 'bg-gray-500', ringColor: 'ring-gray-500' },
dropped: { label: 'Dropped', color: 'bg-red-500', ringColor: 'ring-red-500' },
reading: { label: 'Reading', color: 'bg-amber-500', ringColor: 'ring-amber-500' },
listening: { label: 'Listening', color: 'bg-purple-500', ringColor: 'ring-purple-500' },
playing: { label: 'Playing', color: 'bg-indigo-500', ringColor: 'ring-indigo-500' },
'on-hold': { label: 'On Hold', color: 'bg-orange-500', ringColor: 'ring-orange-500' },
};
export default function MediaCard({
media,
onClick,
showBadge = true,
showFavorite = true,
isFavorite = false,
onFavoriteToggle,
variant = 'default'
}: MediaCardProps) {
const statusColors = {
watching: 'bg-blue-500',
completed: 'bg-green-500',
@@ -43,40 +107,446 @@ export default function MediaCard({ media, onClick }: MediaCardProps) {
'1/1': 'aspect-[1/1]',
}[getAspectRatio()];
return (
<motion.div
const categoryInfo = categoryConfig[media.category];
const CategoryIcon = categoryInfo?.icon;
const [isHovered, setIsHovered] = useState(false);
const handleFavoriteClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onFavoriteToggle?.(media);
};
const formatPlayCount = (count?: number) => {
if (!count) return null;
if (count === 1) return '1x played';
if (count < 1000) return `${count}x played`;
return `${(count / 1000).toFixed(1)}k played`;
};
const renderRating = () => {
if (!media.rating) return null;
const stars = Math.floor(media.rating / 2);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<div className="flex">
{[...Array(5)].map((_, i) => (
<Star
key={i}
size={10}
className={cn(
i < stars
? 'text-primary fill-primary'
: 'text-muted-foreground/50'
)}
/>
))}
</div>
<span className="text-xs font-semibold">{media.rating.toFixed(1)}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Rating: {media.rating}/10</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const renderCategoryBadge = () => {
if (!showBadge || !categoryInfo) return null;
return (
<Badge
variant={categoryInfo.variant}
className="absolute top-2 right-2 z-20 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider backdrop-blur-sm bg-opacity-90"
>
{CategoryIcon && <CategoryIcon size={10} />}
{categoryInfo.label}
</Badge>
);
};
const renderFavoriteButton = () => {
if (!showFavorite) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: isHovered ? 1 : 0, scale: isHovered ? 1 : 0.8 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
className="absolute top-2 left-2 z-20"
>
<Button
variant="ghost"
size="icon"
onClick={handleFavoriteClick}
className={cn(
'h-7 w-7 rounded-full backdrop-blur-sm transition-all duration-200',
isFavorite
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-black/50 text-white hover:bg-black/70 hover:text-white'
)}
>
<Heart
size={14}
className={cn('transition-transform', isFavorite && 'fill-current scale-110')}
/>
</Button>
</motion.div>
</AnimatePresence>
);
};
const renderStatusIndicator = () => {
if (!media.status) return null;
const status = statusConfig[media.status];
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'absolute top-2 z-20 w-3 h-3 rounded-full border-2 border-background shadow-md',
status.color,
showFavorite ? 'left-10' : 'left-2'
)}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Status: {status.label}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const renderCompactVariant = () => (
<motion.div
layoutId={`media-${media.id}`}
className="group cursor-pointer"
onClick={() => onClick(media)}
whileHover={{ y: -4 }}
transition={{ duration: 0.2 }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
whileHover={{ y: -2 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<div className={cn(
"relative rounded-lg overflow-hidden shadow-lg bg-zinc-800 transition-all duration-300",
aspectRatioClass
)}>
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
referrerPolicy="no-referrer"
/>
{media.status && (
<div className={cn(
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
statusColors[media.status]
)} />
<Card
className={cn(
'relative overflow-hidden border-0 bg-muted/50 transition-all duration-300',
aspectRatioClass,
isHovered && 'ring-2 ring-primary/20 shadow-xl'
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
</div>
<div className="mt-3 space-y-1">
<h3 className="text-sm font-bold text-zinc-900 line-clamp-1 group-hover:text-[#6d28d9] transition-colors">
{media.title}
</h3>
<p className="text-xs font-medium text-zinc-500">
{media.year}
</p>
</div>
>
<div className="absolute inset-0 bg-muted">
<img
src={media.poster}
alt={media.title}
className="h-full w-full object-cover object-center"
referrerPolicy="no-referrer"
/>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
{renderCategoryBadge()}
{renderFavoriteButton()}
{renderStatusIndicator()}
<div className="absolute bottom-0 left-0 right-0 p-2">
<h3 className="text-xs font-semibold text-white line-clamp-1">{media.title}</h3>
<p className="text-[10px] text-white/60">{media.year}</p>
</div>
</Card>
</motion.div>
);
const renderMinimalVariant = () => (
<motion.div
layoutId={`media-${media.id}`}
className="group cursor-pointer"
onClick={() => onClick(media)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
whileHover={{ y: -2 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<Card
className={cn(
'relative overflow-hidden border-0 transition-all duration-300',
aspectRatioClass,
isHovered && 'shadow-lg'
)}
>
<div className="absolute inset-0 bg-muted">
<img
src={media.poster}
alt={media.title}
className="h-full w-full object-cover object-center"
referrerPolicy="no-referrer"
/>
</div>
<div
className={cn(
'absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent opacity-0 transition-opacity duration-300',
isHovered && 'opacity-100'
)}
/>
{showFavorite && (
<Button
variant="ghost"
size="icon"
onClick={handleFavoriteClick}
className={cn(
'absolute top-2 right-2 h-7 w-7 rounded-full backdrop-blur-sm opacity-0 transition-opacity duration-300',
isHovered && 'opacity-100',
isFavorite
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-black/50 text-white hover:bg-black/70'
)}
>
<Heart size={14} className={cn(isFavorite && 'fill-current')} />
</Button>
)}
<div
className={cn(
'absolute bottom-0 left-0 right-0 p-3 translate-y-2 opacity-0 transition-all duration-300',
isHovered && 'translate-y-0 opacity-100'
)}
>
<h3 className="text-xs font-semibold text-white line-clamp-2">{media.title}</h3>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-white/70">{media.year}</span>
{media.rating && (
<>
<span className="text-[10px] text-white/50"></span>
<span className="text-[10px] text-white/70">{media.rating.toFixed(1)}</span>
</>
)}
</div>
</div>
</Card>
</motion.div>
);
const renderDefaultVariant = () => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<motion.div
layoutId={`media-${media.id}`}
className="group cursor-pointer"
onClick={() => onClick(media)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
whileHover={{ y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<Card
className={cn(
'relative overflow-hidden border-0 bg-card transition-all duration-300',
aspectRatioClass,
isHovered && 'ring-2 ring-primary/30 shadow-2xl'
)}
>
<div className="absolute inset-0 bg-muted">
<img
src={media.poster}
alt={media.title}
className="h-full w-full object-cover object-center"
referrerPolicy="no-referrer"
/>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/95 via-black/40 to-transparent" />
{renderCategoryBadge()}
{renderFavoriteButton()}
{renderStatusIndicator()}
<div className="absolute bottom-0 left-0 right-0 p-3 space-y-2">
<h3 className="text-sm font-bold text-white line-clamp-2 leading-tight">
{media.title}
</h3>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">{renderRating()}</div>
</div>
<Separator className="bg-white/10" />
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1 text-white/70">
<Calendar size={11} />
{media.year}
</span>
{media.playCount && media.playCount > 0 && (
<>
<span className="text-white/30"></span>
<span className="flex items-center gap-1 text-white/70">
<Play size={11} />
{formatPlayCount(media.playCount)}
</span>
</>
)}
{media.studios && media.studios.length > 0 && (
<>
<span className="text-white/30"></span>
<span className="truncate max-w-[100px] text-white/50">
{media.studios[0]}
</span>
</>
)}
</div>
{media.genres && media.genres.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{media.genres.slice(0, 2).map((genre) => (
<Badge key={genre} variant="outline" className="text-[9px] py-0 h-4 border-white/20 text-white/60">
{genre}
</Badge>
))}
{media.genres.length > 2 && (
<Badge variant="outline" className="text-[9px] py-0 h-4 border-white/20 text-white/60">
+{media.genres.length - 2}
</Badge>
)}
</div>
)}
</div>
{isHovered && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute inset-0 bg-primary/5 pointer-events-none"
/>
)}
</Card>
</motion.div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="space-y-1">
<p className="font-semibold">{media.title}</p>
{media.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{media.description}</p>
)}
<div className="flex items-center gap-2 text-xs pt-1">
<span>{media.category}</span>
{media.year && (
<>
<span></span>
<span>{media.year}</span>
</>
)}
{media.rating && (
<>
<span></span>
<span>{media.rating}/10</span>
</>
)}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const renderHeroVariant = () => (
<motion.div
layoutId={`media-${media.id}`}
className="group cursor-pointer"
onClick={() => onClick(media)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
whileHover={{ y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<Card
className={cn(
'relative overflow-hidden border-0 bg-card transition-all duration-300',
aspectRatioClass,
isHovered && 'ring-2 ring-primary/30 shadow-2xl'
)}
>
<div className="absolute inset-0 bg-muted">
<img
src={media.poster}
alt={media.title}
className="h-full w-full object-cover object-center"
referrerPolicy="no-referrer"
/>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent" />
{renderCategoryBadge()}
{renderFavoriteButton()}
{renderStatusIndicator()}
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-3">
{media.rating && (
<Badge variant="secondary" className="text-xs">
<Trophy size={12} className="mr-1" />
{media.rating.toFixed(1)}/10
</Badge>
)}
<h3 className="text-lg font-bold text-white line-clamp-2 leading-tight">
{media.title}
</h3>
{media.description && (
<p className="text-sm text-white/70 line-clamp-2">{media.description}</p>
)}
<div className="flex items-center gap-3 text-sm">
<span className="flex items-center gap-1 text-white/80">
<Calendar size={14} />
{media.year}
</span>
{media.playCount && media.playCount > 0 && (
<span className="flex items-center gap-1 text-white/80">
<Play size={14} />
{formatPlayCount(media.playCount)}
</span>
)}
</div>
{media.genres && media.genres.length > 0 && (
<div className="flex flex-wrap gap-2">
{media.genres.slice(0, 4).map((genre) => (
<Badge key={genre} variant="outline" className="text-xs border-white/20 text-white/70">
{genre}
</Badge>
))}
</div>
)}
</div>
{isHovered && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute inset-0 bg-primary/5 pointer-events-none"
/>
)}
</Card>
</motion.div>
);
const renderVariant = () => {
switch (variant) {
case 'compact':
return renderCompactVariant();
case 'minimal':
return renderMinimalVariant();
case 'hero':
return renderHeroVariant();
default:
return renderDefaultVariant();
}
};
return renderVariant();
}
+88 -74
View File
@@ -1,100 +1,114 @@
import { Media } from '@/types';
import React from 'react';
import { Media, MediaCategory } from '@/types';
import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import { Star, Play, Bookmark } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Star, Heart, Gamepad2, Film, Tv, Eye } from 'lucide-react';
interface MediaListItemProps {
key?: string;
media: Media;
onClick: (media: Media) => void;
isFavorite?: boolean;
onFavoriteToggle?: (media: Media) => void;
}
export default function MediaListItem({ media, onClick }: MediaListItemProps) {
const statusColors = {
watching: 'bg-blue-500',
completed: 'bg-green-500',
planned: 'bg-gray-500',
dropped: 'bg-red-500',
reading: 'bg-amber-500',
listening: 'bg-purple-500',
playing: 'bg-indigo-500',
'on-hold': 'bg-orange-500',
};
const categoryConfig: Record<MediaCategory, { label: string; color: string; bgColor: string; icon: any }> = {
'Anime': { label: 'ANIME', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: null },
'Movies': { label: 'MOVIE', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Film },
'TV Series': { label: 'SERIES', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: Tv },
'Music': { label: 'MUSIC', color: 'text-pink-400', bgColor: 'bg-pink-500/20', icon: null },
'Books': { label: 'BOOK', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: null },
'Games': { label: 'GAME', color: 'text-indigo-400', bgColor: 'bg-indigo-500/20', icon: Gamepad2 },
'Consoles': { label: 'CONSOLE', color: 'text-orange-400', bgColor: 'bg-orange-500/20', icon: null },
'Adult': { label: 'ADULT', color: 'text-rose-400', bgColor: 'bg-rose-500/20', icon: Eye },
};
const getAspectRatio = () => {
if (media.aspectRatio) return media.aspectRatio;
switch (media.category) {
case 'Music': return '1/1';
case 'Games':
case 'Adult': return '16/9';
default: return '2/3';
}
};
export default function MediaListItem({ media, onClick, isFavorite = false, onFavoriteToggle }: MediaListItemProps) {
const categoryInfo = categoryConfig[media.category];
const CategoryIcon = categoryInfo?.icon;
const aspectRatioClass = {
'2/3': 'w-24 h-32',
'16/9': 'w-48 h-27', // 16:9 ratio for w-48 is approx h-27
'1/1': 'w-24 h-24',
}[getAspectRatio()];
const handleFavoriteClick = (e: React.MouseEvent) => {
e.stopPropagation();
onFavoriteToggle?.(media);
};
return (
<motion.div
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="group flex items-center gap-6 p-4 rounded-xl hover:bg-zinc-50 transition-colors cursor-pointer border border-transparent hover:border-zinc-200"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="group flex items-center px-4 py-2 hover:bg-muted/30 transition-colors cursor-pointer border-b border-border/30 last:border-b-0"
onClick={() => onClick(media)}
>
<div className={cn(
"relative rounded-lg overflow-hidden shrink-0 shadow-md bg-zinc-800 transition-all duration-300",
aspectRatioClass
)}>
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
{media.status && (
<div className={cn(
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
statusColors[media.status]
)} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
{media.title}
</h3>
<span className="text-sm font-bold text-zinc-400">({media.year})</span>
{/* TITLE Column: Poster + Title + Rating (like screenshot 2) */}
<div className="flex-1 min-w-0 flex items-center gap-3 mr-4">
{/* Poster Thumbnail */}
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-muted">
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="flex items-center gap-4 mb-3">
<div className="flex items-center gap-1 text-xs font-bold text-zinc-500">
<Star size={14} className="text-yellow-500" fill="currentColor" />
{media.rating || 'N/A'}
</div>
<div className="text-xs font-bold text-zinc-400 uppercase tracking-wider">
{media.genres?.slice(0, 3).join(' • ') || 'Anime'}
{/* Title + Rating stacked */}
<div className="min-w-0">
<h3 className="text-sm font-medium text-foreground truncate group-hover:text-[#e8466c] transition-colors">
{media.title}
</h3>
<div className="flex items-center gap-1 mt-0.5">
<Star size={10} className="text-[#e8466c] fill-[#e8466c]" />
<span className="text-xs font-medium text-[#e8466c]">
{media.rating?.toFixed(1) || '-'}
</span>
</div>
</div>
<p className="text-sm text-zinc-500 line-clamp-2 max-w-2xl">
{media.description || "No description available for this title."}
</p>
</div>
<div className="hidden md:flex items-center gap-2">
<Button size="icon" variant="ghost" className="rounded-full text-zinc-400 hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
<Play size={18} fill="currentColor" />
</Button>
<Button size="icon" variant="ghost" className="rounded-full text-zinc-400 hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
<Bookmark size={18} />
</Button>
{/* TYPE Column */}
<div className="w-[70px] shrink-0 mr-4">
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
categoryInfo.bgColor,
categoryInfo.color
)}>
{CategoryIcon && <CategoryIcon size={9} />}
{categoryInfo.label}
</span>
</div>
{/* GENRE Column */}
<div className="w-[140px] shrink-0 mr-4">
<span className="text-sm text-muted-foreground truncate block">
{media.genres?.slice(0, 2).join(', ') || '-'}
</span>
</div>
{/* YEAR Column */}
<div className="w-[60px] shrink-0 text-center mr-4">
<span className="text-sm text-muted-foreground/80">{media.year}</span>
</div>
{/* PLAYS Column */}
<div className="w-[50px] shrink-0 text-right mr-4">
<span className="text-sm text-muted-foreground/80">{media.playCount || 0}</span>
</div>
{/* FAVORITE Column (Heart) */}
<div className="w-8 shrink-0 flex justify-end">
<button
onClick={handleFavoriteClick}
className={cn(
"p-1 rounded transition-colors",
isFavorite
? "text-[#e8466c]"
: "text-muted-foreground/40 hover:text-muted-foreground/60"
)}
>
<Heart size={14} className={cn(isFavorite && "fill-current")} />
</button>
</div>
</motion.div>
);
+264
View File
@@ -0,0 +1,264 @@
import React, { useState, useMemo } from 'react';
import { Media, MediaCategory } from '@/types';
import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import {
Star,
Heart,
Gamepad2,
Film,
Tv,
Eye,
Music,
BookOpen,
Monitor,
ArrowUpDown,
ArrowUp,
ArrowDown
} from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface MediaTableProps {
mediaList: Media[];
onMediaClick: (media: Media) => void;
onFavoriteToggle?: (media: Media) => void;
favoriteIds?: Set<string>;
}
type SortField = 'title' | 'category' | 'genre' | 'rating' | 'year' | 'plays';
type SortDirection = 'asc' | 'desc';
const categoryConfig: Record<MediaCategory, {
label: string;
color: string;
bgColor: string;
icon: React.ElementType | null;
}> = {
'Anime': { label: 'ANIME', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: null },
'Movies': { label: 'MOVIE', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Film },
'TV Series': { label: 'SERIES', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: Tv },
'Music': { label: 'MUSIC', color: 'text-pink-400', bgColor: 'bg-pink-500/20', icon: Music },
'Books': { label: 'BOOK', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: BookOpen },
'Games': { label: 'GAME', color: 'text-indigo-400', bgColor: 'bg-indigo-500/20', icon: Gamepad2 },
'Consoles': { label: 'CONSOLE', color: 'text-orange-400', bgColor: 'bg-orange-500/20', icon: Monitor },
'Adult': { label: 'ADULT', color: 'text-rose-400', bgColor: 'bg-rose-500/20', icon: Eye },
};
export default function MediaTable({
mediaList,
onMediaClick,
onFavoriteToggle,
favoriteIds = new Set()
}: MediaTableProps) {
const [sortField, setSortField] = useState<SortField>('title');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedMedia = useMemo(() => {
const sorted = [...mediaList];
sorted.sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'title':
comparison = a.title.localeCompare(b.title);
break;
case 'category':
comparison = a.category.localeCompare(b.category);
break;
case 'genre':
const genreA = a.genres?.[0] || '';
const genreB = b.genres?.[0] || '';
comparison = genreA.localeCompare(genreB);
break;
case 'rating':
comparison = (b.rating || 0) - (a.rating || 0);
break;
case 'year':
comparison = b.year.localeCompare(a.year);
break;
case 'plays':
comparison = (b.playCount || 0) - (a.playCount || 0);
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
return sorted;
}, [mediaList, sortField, sortDirection]);
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) {
return <ArrowUpDown size={14} className="text-muted-foreground/40 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />;
}
return sortDirection === 'asc'
? <ArrowUp size={14} className="text-[#e8466c] ml-1" />
: <ArrowDown size={14} className="text-[#e8466c] ml-1" />;
};
const handleFavoriteClick = (e: React.MouseEvent, media: Media) => {
e.stopPropagation();
onFavoriteToggle?.(media);
};
return (
<Table className="w-full">
<TableHeader>
<TableRow className="border-b border-border/20 hover:bg-transparent">
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[45%]"
onClick={() => handleSort('title')}
>
<div className="flex items-center">
Title <SortIcon field="title" />
</div>
</TableHead>
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[80px]"
onClick={() => handleSort('category')}
>
<div className="flex items-center">
Type <SortIcon field="category" />
</div>
</TableHead>
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[18%]"
onClick={() => handleSort('genre')}
>
<div className="flex items-center">
Genre <SortIcon field="genre" />
</div>
</TableHead>
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[70px] text-center"
onClick={() => handleSort('rating')}
>
<div className="flex items-center justify-center">
Rating <SortIcon field="rating" />
</div>
</TableHead>
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[60px] text-center"
onClick={() => handleSort('year')}
>
<div className="flex items-center justify-center">
Year <SortIcon field="year" />
</div>
</TableHead>
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[60px] text-right"
onClick={() => handleSort('plays')}
>
<div className="flex items-center justify-end">
Plays <SortIcon field="plays" />
</div>
</TableHead>
<TableHead className="w-[40px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedMedia.map((media) => {
const categoryInfo = categoryConfig[media.category];
const CategoryIcon = categoryInfo?.icon;
const isFavorite = favoriteIds.has(media.id);
return (
<TableRow
key={media.id}
className="border-b border-border/20 hover:bg-muted/30 transition-colors cursor-pointer group"
onClick={() => onMediaClick(media)}
>
{/* Title Cell with Poster */}
<TableCell className="py-2">
<div className="flex items-center gap-3">
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-muted">
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground truncate group-hover:text-[#e8466c] transition-colors">
{media.title}
</div>
</div>
</div>
</TableCell>
{/* Type Badge */}
<TableCell>
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
categoryInfo.bgColor,
categoryInfo.color
)}>
{CategoryIcon && <CategoryIcon size={9} />}
{categoryInfo.label}
</span>
</TableCell>
{/* Genre */}
<TableCell>
<span className="text-sm text-muted-foreground truncate block">
{media.genres?.join(', ') || '-'}
</span>
</TableCell>
{/* Rating */}
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Star size={12} className="text-[#e8466c] fill-[#e8466c]" />
<span className="text-sm font-medium text-foreground/80">
{media.rating?.toFixed(1) || '-'}
</span>
</div>
</TableCell>
{/* Year */}
<TableCell className="text-center">
<span className="text-sm text-muted-foreground/80">{media.year}</span>
</TableCell>
{/* Plays */}
<TableCell className="text-right">
<span className="text-sm text-muted-foreground/80">{media.playCount || 0}</span>
</TableCell>
{/* Favorite */}
<TableCell>
<button
onClick={(e) => handleFavoriteClick(e, media)}
className={cn(
"p-1 rounded transition-colors",
isFavorite
? "text-[#e8466c]"
: "text-muted-foreground/40 hover:text-muted-foreground/60"
)}
>
<Heart size={14} className={cn(isFavorite && "fill-current")} />
</button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
+482 -221
View File
@@ -1,20 +1,34 @@
import React, { useState, useEffect } from 'react';
import { MediaCategory, UserSettings } from '@/types';
import { MediaCategory, UserSettings, CustomColors } from '@/types';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Film, Music, Book, Tv, Gamepad2, ShieldAlert, LayoutGrid, List, Globe, Monitor, Sun, Moon, Save, ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Slider } from '@/components/ui/slider';
import {
Film, Music, BookOpen, Tv, Gamepad2, ShieldAlert, LayoutGrid, List, Globe, Monitor, Sun, Moon,
Save, ArrowLeft, Type, Image as ImageIcon, Palette, Library, Eye, Sparkles, Languages, Settings2,
Check, AlertCircle, MonitorPlay
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { fetchSettings, updateSettings } from '@/api';
import { useTheme } from '@/contexts/ThemeContext';
import { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/lib/utils';
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 CATEGORY_ICONS: Record<MediaCategory, React.ElementType> = {
Anime: Tv,
Movies: Film,
'TV Series': Tv,
Music: Music,
Books: BookOpen,
Consoles: Gamepad2,
Games: Gamepad2,
Adult: ShieldAlert,
};
const ITEMS_PER_PAGE_OPTIONS = [12, 20, 36, 48, 60];
@@ -31,9 +45,13 @@ interface SettingsViewProps {
}
export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
const navigate = useNavigate();
const { setTheme } = useTheme();
const [activeTab, setActiveTab] = useState('library');
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,
@@ -44,6 +62,12 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
// Page Settings State
const [pageTitle, setPageTitle] = useState<string>('');
const [favicon, setFavicon] = useState<string>('');
const [customColors, setCustomColors] = useState<CustomColors>({});
const [faviconPreview, setFaviconPreview] = useState<string>('');
useEffect(() => {
loadSettings();
}, []);
@@ -53,6 +77,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
const loadedSettings = await fetchSettings();
if (loadedSettings) {
setSettings(loadedSettings);
setPageTitle(loadedSettings.pageTitle || '');
setFavicon(loadedSettings.favicon || '');
setCustomColors(loadedSettings.customColors || {});
setFaviconPreview(loadedSettings.favicon || '');
}
} catch (error) {
console.error('Failed to load settings:', error);
@@ -65,10 +93,18 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
setIsSaving(true);
setSaveStatus('idle');
try {
const savedSettings = await updateSettings(settings);
const updatedSettings: UserSettings = {
...settings,
pageTitle: pageTitle || undefined,
favicon: favicon || undefined,
customColors: Object.keys(customColors).length > 0 ? customColors : undefined,
};
const savedSettings = await updateSettings(updatedSettings);
if (savedSettings) {
setSettings(savedSettings);
setSaveStatus('success');
// Sync theme with theme context
setTheme(savedSettings.theme);
onSettingsSaved?.();
} else {
setSaveStatus('error');
@@ -91,235 +127,460 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
}));
};
const handleFaviconUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
setFavicon(base64);
setFaviconPreview(base64);
};
reader.readAsDataURL(file);
}
};
const handleRemoveFavicon = () => {
setFavicon('');
setFaviconPreview('');
};
const handleColorChange = (colorKey: keyof CustomColors, value: string) => {
setCustomColors(prev => ({
...prev,
[colorKey]: value || undefined,
}));
};
if (isLoading) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-zinc-400 font-medium">Loading settings...</div>
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-muted-foreground font-medium">Loading settings...</div>
</div>
);
}
const enabledCount = settings.enabledCategories.length;
const totalCategories = 8;
return (
<div className="min-h-screen bg-white pt-20">
{/* Content */}
<div className="max-w-[1600px] 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-zinc-400 hover:text-[#6d28d9] transition-colors mb-2"
>
<ArrowLeft size={16} />
Back to home
</Link>
<h1 className="text-3xl font-black text-zinc-900">Settings</h1>
</div>
<button
onClick={handleSave}
disabled={isSaving}
className="bg-[#6d28d9] text-white hover:bg-[#5b21b6] font-bold px-6 py-3 h-12 rounded-lg flex items-center gap-2 transition-colors disabled:opacity-50"
>
{isSaving ? (
'Saving...'
) : (
<>
<Save size={16} />
Save Changes
</>
)}
</button>
</div>
{saveStatus === 'success' && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 font-medium">
Settings saved successfully!
</div>
)}
{saveStatus === 'error' && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 font-medium">
Failed to save settings. Please try again.
</div>
)}
<div className="grid gap-8">
{/* Library Settings */}
<section>
<h2 className="text-xl font-black text-zinc-900 mb-6">Library Settings</h2>
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100">
<p className="text-sm font-medium text-zinc-500 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-white border border-zinc-100 transition-all hover:border-[#6d28d9]/20">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-zinc-50 flex items-center justify-center text-[#6d28d9]">
{CATEGORY_ICONS[category]}
</div>
<div>
<Label htmlFor={category} className="text-sm font-black text-zinc-900 cursor-pointer">
{category}
</Label>
<p className="text-[10px] font-bold text-zinc-400 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 className="min-h-screen bg-background pb-16">
{/* Header */}
<div className="border-b border-border/50">
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="rounded-lg"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-sm text-muted-foreground">Manage your preferences</p>
</div>
</div>
</section>
<div className="flex items-center gap-3">
<AnimatePresence mode="wait">
{saveStatus === 'success' && (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="flex items-center gap-2 text-sm text-emerald-500 bg-emerald-500/10 px-3 py-1.5 rounded-lg"
>
<Check className="h-4 w-4" />
Saved
</motion.div>
)}
{saveStatus === 'error' && (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="flex items-center gap-2 text-sm text-red-500 bg-red-500/10 px-3 py-1.5 rounded-lg"
>
<AlertCircle className="h-4 w-4" />
Error
</motion.div>
)}
</AnimatePresence>
<Button
onClick={handleSave}
disabled={isSaving}
className="gap-2"
>
<Save className="h-4 w-4" />
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 py-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="mb-6 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto flex-wrap">
<TabsTrigger value="library" className="gap-2">
<Library className="h-4 w-4" />
Library
</TabsTrigger>
<TabsTrigger value="display" className="gap-2">
<Monitor className="h-4 w-4" />
Display
</TabsTrigger>
<TabsTrigger value="content" className="gap-2">
<Eye className="h-4 w-4" />
Content
</TabsTrigger>
<TabsTrigger value="appearance" className="gap-2">
<Palette className="h-4 w-4" />
Appearance
</TabsTrigger>
</TabsList>
{/* Library Settings */}
<TabsContent value="library" className="mt-0 space-y-6">
<Card className="border-border/60">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Media Categories</CardTitle>
<CardDescription>Toggle which media types appear in your library</CardDescription>
</div>
<Badge variant="secondary">{enabledCount}/{totalCategories} enabled</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => {
const Icon = CATEGORY_ICONS[category];
const isEnabled = settings.enabledCategories.includes(category);
return (
<div
key={category}
className={cn(
"flex items-center justify-between p-4 rounded-lg border transition-all cursor-pointer",
isEnabled
? "bg-background border-primary/30"
: "bg-muted/30 border-border/50 opacity-60"
)}
onClick={() => toggleCategory(category)}
>
<div className="flex items-center gap-3">
<div className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center border",
isEnabled
? "bg-primary/10 text-primary border-primary/20"
: "bg-muted text-muted-foreground border-border"
)}>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-foreground">{category}</p>
<p className="text-xs text-muted-foreground">
{isEnabled ? 'Visible in library' : 'Hidden'}
</p>
</div>
</div>
<Switch
checked={isEnabled}
onCheckedChange={() => toggleCategory(category)}
/>
</div>
);
})}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Display Settings */}
<section>
<h2 className="text-xl font-black text-zinc-900 mb-6">Display Settings</h2>
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100 space-y-6">
{/* Items per page */}
<div>
<Label className="text-sm font-black text-zinc-900 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-lg text-sm font-bold transition-all ${
settings.itemsPerPage === option
? 'bg-[#6d28d9] text-white'
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
}`}
>
{option}
</button>
))}
</div>
</div>
<TabsContent value="display" className="mt-0 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="border-border/60">
<CardHeader>
<CardTitle>View Options</CardTitle>
<CardDescription>Configure how items are displayed</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Items per page */}
<div className="space-y-3">
<Label>Items per page</Label>
<div className="flex gap-2 flex-wrap">
{ITEMS_PER_PAGE_OPTIONS.map((option) => (
<Button
key={option}
variant={settings.itemsPerPage === option ? 'default' : 'outline'}
size="sm"
onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: option }))}
>
{option}
</Button>
))}
</div>
</div>
{/* Default view */}
<div>
<Label className="text-sm font-black text-zinc-900 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-lg text-sm font-bold transition-all ${
settings.defaultView === 'grid'
? 'bg-[#6d28d9] text-white'
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
}`}
>
<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-lg text-sm font-bold transition-all ${
settings.defaultView === 'list'
? 'bg-[#6d28d9] text-white'
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
}`}
>
<List size={18} />
List
</button>
</div>
</div>
<Separator />
{/* Theme */}
<div>
<Label className="text-sm font-black text-zinc-900 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-lg text-sm font-bold transition-all ${
settings.theme === theme
? 'bg-[#6d28d9] text-white'
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
}`}
>
{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>
{/* Default view */}
<div className="space-y-3">
<Label>Default view</Label>
<div className="grid grid-cols-2 gap-3">
<Button
variant={settings.defaultView === 'grid' ? 'default' : 'outline'}
className="justify-center gap-2"
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))}
>
<LayoutGrid className="h-4 w-4" />
Grid
</Button>
<Button
variant={settings.defaultView === 'list' ? 'default' : 'outline'}
className="justify-center gap-2"
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'list' }))}
>
<List className="h-4 w-4" />
List
</Button>
</div>
</div>
<Separator />
{/* Grid item size */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Grid item size</Label>
<span className="text-sm font-medium text-primary">{settings.gridItemSize}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground">Small</span>
<Slider
value={settings.gridItemSize}
min={1}
max={10}
onValueChange={(value) => setSettings(prev => ({ ...prev, gridItemSize: value }))}
className="flex-1"
/>
<span className="text-xs text-muted-foreground">Large</span>
</div>
</div>
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Languages className="h-4 w-4 text-primary" />
Language
</CardTitle>
<CardDescription>Interface language preference</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{LANGUAGE_OPTIONS.map((option) => (
<Button
key={option.value}
variant={settings.language === option.value ? 'default' : 'outline'}
size="sm"
onClick={() => setSettings(prev => ({ ...prev, language: option.value }))}
className="justify-center"
>
{option.label}
</Button>
))}
</div>
</CardContent>
</Card>
</div>
</section>
</TabsContent>
{/* Content Settings */}
<section>
<h2 className="text-xl font-black text-zinc-900 mb-6">Content Settings</h2>
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100 space-y-4">
{/* Show adult content */}
<div className="flex items-center justify-between p-4 rounded-xl bg-white border border-zinc-100">
<div>
<Label htmlFor="showAdult" className="text-sm font-black text-zinc-900 cursor-pointer">
Show adult content
</Label>
<p className="text-xs font-medium text-zinc-500 mt-1">
Display adult media in your library
</p>
<TabsContent value="content" className="mt-0 space-y-6">
<Card className="border-border/60">
<CardHeader>
<CardTitle>Content Preferences</CardTitle>
<CardDescription>Control what content is shown</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30 border border-border/50">
<div>
<Label htmlFor="showAdult" className="cursor-pointer">Show adult content</Label>
<p className="text-sm text-muted-foreground">Display adult media in your library</p>
</div>
<Switch
id="showAdult"
checked={settings.showAdultContent}
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, showAdultContent: checked }))}
/>
</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-white border border-zinc-100">
<div>
<Label htmlFor="autoPlay" className="text-sm font-black text-zinc-900 cursor-pointer">
Auto-play trailers
</Label>
<p className="text-xs font-medium text-zinc-500 mt-1">
Automatically play trailers when viewing media
</p>
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30 border border-border/50">
<div>
<Label htmlFor="autoPlay" className="cursor-pointer">Auto-play trailers</Label>
<p className="text-sm text-muted-foreground">Automatically play trailers when viewing media</p>
</div>
<Switch
id="autoPlay"
checked={settings.autoPlayTrailers}
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))}
/>
</div>
<Switch
id="autoPlay"
checked={settings.autoPlayTrailers}
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))}
/>
</div>
</div>
</section>
</CardContent>
</Card>
</TabsContent>
{/* Language Settings */}
<section>
<h2 className="text-xl font-black text-zinc-900 mb-6">Language</h2>
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100">
<div className="flex items-center gap-2 mb-4">
<Globe size={18} className="text-[#6d28d9]" />
<Label className="text-sm font-black text-zinc-900">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-lg text-sm font-bold transition-all ${
settings.language === option.value
? 'bg-[#6d28d9] text-white'
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
}`}
>
{option.label}
</button>
))}
</div>
{/* Appearance Settings */}
<TabsContent value="appearance" className="mt-0 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="border-border/60">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Theme
</CardTitle>
<CardDescription>Choose your preferred color scheme</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
{([
{ value: 'light' as const, icon: Sun, label: 'Light' },
{ value: 'dark' as const, icon: Moon, label: 'Dark' },
{ value: 'system' as const, icon: Monitor, label: 'System' },
]).map(({ value, icon: Icon, label }) => (
<Button
key={value}
variant={settings.theme === value ? 'default' : 'outline'}
className="flex-col gap-2 h-auto py-4"
onClick={() => setSettings(prev => ({ ...prev, theme: value }))}
>
<Icon className="h-5 w-5" />
<span className="text-xs">{label}</span>
</Button>
))}
</div>
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Type className="h-4 w-4 text-primary" />
Page Title
</CardTitle>
<CardDescription>Customize the page title</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={pageTitle}
onChange={(e) => setPageTitle(e.target.value)}
placeholder="Leave empty for default title"
/>
<p className="text-xs text-muted-foreground">
Custom title for your page. Leave empty to use the default title.
</p>
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-primary" />
Favicon
</CardTitle>
<CardDescription>Upload a custom favicon</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
{faviconPreview && (
<div className="relative">
<img
src={faviconPreview}
alt="Favicon preview"
className="w-16 h-16 rounded-lg object-cover border border-border"
/>
<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">
<Button variant="outline" className="cursor-pointer" asChild>
<span>{favicon ? 'Change favicon' : 'Upload favicon'}</span>
</Button>
</label>
</div>
</div>
<p className="text-xs text-muted-foreground mt-3">
The image will be converted to Base64 format.
</p>
</CardContent>
</Card>
<Card className="border-border/60 lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-primary" />
Custom Colors
</CardTitle>
<CardDescription>Customize the application colors</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-4">
{[
{ key: 'primary', label: 'Primary' },
{ key: 'secondary', label: 'Secondary' },
{ key: 'background', label: 'Background' },
{ key: 'surface', label: 'Surface' },
{ key: 'text', label: 'Text' },
{ key: 'muted', label: 'Muted' },
{ key: 'border', label: 'Border' },
].map(({ key, label }) => (
<div key={key} className="space-y-2">
<Label className="text-xs">{label}</Label>
<div className="flex gap-2">
<input
type="color"
value={customColors[key as keyof CustomColors] || '#e8466c'}
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
className="w-10 h-10 rounded-lg cursor-pointer border-0 p-0"
/>
<Input
value={customColors[key as keyof CustomColors] || ''}
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
placeholder="#e8466c"
className="flex-1 text-xs"
/>
</div>
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-4">
Leave color fields empty to use the default theme colors.
</p>
</CardContent>
</Card>
</div>
</section>
</div>
</TabsContent>
</Tabs>
</div>
</div>
);
+413
View File
@@ -0,0 +1,413 @@
import { useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Library,
Users,
FolderKanban,
Database,
Settings,
Sun,
LogOut,
Menu,
X,
Plus,
Film,
Tv,
Gamepad2,
Heart,
Eye,
Flame,
Clock,
ChevronRight
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/contexts/ThemeContext';
import { MediaCategory } from '@/types';
interface SidebarProps {
enabledCategories: MediaCategory[];
onToggleCategory: (category: MediaCategory) => void;
pageTitle?: string;
mediaCounts?: {
all: number;
movies: number;
series: number;
games: number;
adult: number;
favorites: number;
};
activeFilter?: string;
onFilterChange?: (filter: string) => void;
}
export default function Sidebar({
enabledCategories,
onToggleCategory,
pageTitle,
mediaCounts = { all: 24, movies: 8, series: 6, games: 6, adult: 4, favorites: 11 },
activeFilter = 'all',
onFilterChange
}: SidebarProps) {
const [isMobileOpen, setIsMobileOpen] = useState(false);
const { theme, setTheme } = useTheme();
const location = useLocation();
const navigate = useNavigate();
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
const handleLogout = () => {
console.log('Logout clicked');
};
const handleFilterClick = (filter: string) => {
onFilterChange?.(filter);
if (filter === 'all') {
navigate('/browse');
} else if (filter === 'movies') {
navigate('/movies');
} else if (filter === 'series') {
navigate('/tv-series');
} else if (filter === 'games') {
navigate('/games');
} else if (filter === 'adult') {
navigate('/adult');
} else if (filter === 'favorites') {
navigate('/browse?favorites=true');
}
};
const handleQuickFilter = (filter: string) => {
if (filter === 'most-played') {
navigate('/browse?sort=plays');
} else if (filter === 'recently-added') {
navigate('/browse?sort=recent');
}
};
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
};
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-64 bg-[#0d0f14] border-r border-white/5 z-50 flex flex-col transition-transform duration-300',
isMobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
)}
>
{/* Logo */}
<div className="p-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gradient-to-br from-[#e8466c] to-[#f47298] rounded-lg flex items-center justify-center">
<svg viewBox="0 0 24 24" className="w-5 h-5 text-white" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
</div>
<span className="text-lg font-bold text-white">{pageTitle || 'MediaVault'}</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{/* Main Navigation */}
<NavLink
to="/"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<LayoutDashboard size={18} />
<span className="font-medium text-sm">Dashboard</span>
</NavLink>
<NavLink
to="/browse"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/browse') || isActive('/movies') || isActive('/tv-series') || isActive('/games') || isActive('/adult')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Library size={18} />
<span className="font-medium text-sm">Library</span>
</NavLink>
<NavLink
to="/cast"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/cast')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Users size={18} />
<span className="font-medium text-sm">Actors</span>
</NavLink>
<NavLink
to="/collections"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/collections')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<FolderKanban size={18} />
<span className="font-medium text-sm">Collections</span>
</NavLink>
<NavLink
to="/sources"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/sources')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Database size={18} />
<span className="font-medium text-sm">Sources</span>
</NavLink>
<NavLink
to="/settings"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/settings')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Settings size={18} />
<span className="font-medium text-sm">Settings</span>
</NavLink>
{/* MEDIA TYPE Section */}
<div className="mt-6">
<div className="px-3 mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Media Type</span>
</div>
<button
onClick={() => handleFilterClick('all')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'all'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Library size={16} />
<span className="text-sm">All</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'all' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.all}
</span>
</button>
{enabledCategories.includes('Movies') && (
<button
onClick={() => handleFilterClick('movies')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'movies' || location.pathname === '/movies'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Film size={16} />
<span className="text-sm">Movies</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'movies' || location.pathname === '/movies' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.movies}
</span>
</button>
)}
{enabledCategories.includes('TV Series') && (
<button
onClick={() => handleFilterClick('series')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'series' || location.pathname === '/tv-series'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Tv size={16} />
<span className="text-sm">Series</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'series' || location.pathname === '/tv-series' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.series}
</span>
</button>
)}
{enabledCategories.includes('Games') && (
<button
onClick={() => handleFilterClick('games')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'games' || location.pathname === '/games'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Gamepad2 size={16} />
<span className="text-sm">Games</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'games' || location.pathname === '/games' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.games}
</span>
</button>
)}
{enabledCategories.includes('Adult') && (
<button
onClick={() => handleFilterClick('adult')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'adult' || location.pathname === '/adult'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Eye size={16} />
<span className="text-sm">Adult</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'adult' || location.pathname === '/adult' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.adult}
</span>
</button>
)}
<button
onClick={() => handleFilterClick('favorites')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'favorites'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Heart size={16} />
<span className="text-sm">Favorites</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'favorites' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.favorites}
</span>
</button>
</div>
{/* QUICK FILTER Section */}
<div className="mt-6">
<div className="px-3 mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Quick Filter</span>
</div>
<button
onClick={() => handleQuickFilter('most-played')}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/5 transition-colors group"
>
<Flame size={16} className="text-orange-500" />
<span className="text-sm">Most Played</span>
</button>
<button
onClick={() => handleQuickFilter('recently-added')}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/5 transition-colors group"
>
<Clock size={16} className="text-cyan-500" />
<span className="text-sm">Recently Added</span>
</button>
</div>
</nav>
{/* Bottom section */}
<div className="p-3 border-t border-white/5 space-y-1">
<button
onClick={toggleTheme}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/5 transition-colors"
>
<Sun size={16} />
<span className="text-sm font-medium">{theme === 'dark' ? 'Light theme' : 'Dark theme'}</span>
</button>
{/* User avatar */}
<div className="flex items-center gap-3 px-3 py-3 mt-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center text-white text-sm font-bold">
N
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">User</p>
</div>
<button
onClick={handleLogout}
className="text-gray-400 hover:text-white transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
</div>
</aside>
</>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { Staff } from '@/types';
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Users, ChevronDown, ChevronUp, User } from 'lucide-react';
import { motion } from 'motion/react';
interface CastTabProps {
staff: Staff[];
onPersonClick: (person: Staff) => void;
}
export default function CastTab({ staff, onPersonClick }: CastTabProps) {
const [showAll, setShowAll] = useState(false);
const displayLimit = 8;
const displayedCast = showAll ? staff : staff.slice(0, displayLimit);
const hasMore = staff.length > displayLimit;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Users className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Cast & Crew
</h2>
<Badge variant="secondary" className="text-xs">
{staff.length}
</Badge>
</div>
</div>
{/* Cast Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{displayedCast.map((person, index) => (
<motion.div
key={person.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<Card
className="group cursor-pointer hover:border-primary/30 hover:shadow-md transition-all duration-200 border-border/60"
onClick={() => onPersonClick(person)}
>
<CardContent className="p-3">
<div className="flex items-center gap-3">
<Avatar className="h-14 w-10 rounded-lg border border-border/30">
<AvatarImage
src={person.photo}
alt={person.name}
className="object-cover"
referrerPolicy="no-referrer"
/>
<AvatarFallback className="rounded-lg bg-muted">
<User className="h-4 w-4 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm text-foreground truncate group-hover:text-primary transition-colors">
{person.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{person.characterName || person.role}
</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
{/* Show More/Less Button */}
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowAll(!showAll)}
className="gap-2 rounded-lg"
>
{showAll ? (
<>
<ChevronUp className="w-4 h-4" />
Show Less
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
Show {staff.length - displayLimit} More
</>
)}
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,92 @@
import { Media } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { BookOpen, Tag } from 'lucide-react';
interface OverviewTabProps {
media: Media;
}
export default function OverviewTab({ media }: OverviewTabProps) {
return (
<div className="space-y-6">
{/* Genres */}
{media.genres && media.genres.length > 0 && (
<Card className="border-border/60 overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<Tag className="w-3 h-3 text-primary" />
</div>
Genres
</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div className="flex flex-wrap gap-2">
{media.genres.map(genre => (
<Badge
key={genre}
variant="secondary"
className="text-xs px-3 py-1 bg-primary/5 text-primary border-primary/20 hover:bg-primary/10 transition-colors"
>
{genre}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Tags */}
{media.tags && media.tags.length > 0 && (
<Card className="border-border/60 overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<Tag className="w-3 h-3 text-primary" />
</div>
Tags
</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div className="flex flex-wrap gap-2">
{media.tags.map(tag => (
<Badge
key={tag}
variant="outline"
className="text-xs px-3 py-1 border-border/50 hover:bg-muted/50 transition-colors"
>
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Description */}
<Card className="border-border/60 overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<BookOpen className="w-3 h-3 text-primary" />
</div>
Synopsis
</CardTitle>
</CardHeader>
<CardContent className="p-4">
{media.description ? (
<div
className="text-foreground leading-relaxed prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: media.description }}
/>
) : (
<p className="text-muted-foreground text-sm italic">
No description available.
</p>
)}
</CardContent>
</Card>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { Episode } from '@/types';
import { useState, useMemo, useEffect } from 'react';
import { Search, Play, Clock, Calendar, ChevronDown, Tv } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
interface SeasonsTabProps {
episodes: Episode[];
}
export default function SeasonsTab({ episodes }: SeasonsTabProps) {
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
// Group episodes by season
const episodesBySeason = useMemo(() => {
if (!episodes) return {};
const grouped: Record<number, typeof episodes> = {};
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;
}, [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;
});
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Tv className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Episodes
</h2>
<Badge variant="secondary" className="text-xs">
{episodes.length}
</Badge>
<span className="text-xs text-muted-foreground">
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
</span>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search episodes..."
className="pl-9 w-full sm:w-[200px] bg-muted/50 border-none rounded-lg h-9 text-sm"
/>
</div>
</div>
{/* Seasons */}
<div className="space-y-3">
{Object.keys(episodesBySeason)
.map(Number)
.sort((a, b) => a - b)
.map(season => (
<Collapsible
key={season}
open={expandedSeasons.has(season)}
onOpenChange={() => toggleSeason(season)}
>
<Card className="border-border/60 overflow-hidden">
<CollapsibleTrigger asChild>
<CardHeader className="py-3 px-4 cursor-pointer hover:bg-muted/30 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-foreground">Season {season}</h3>
<Badge variant="outline" className="text-xs border-primary/30 text-primary">
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
</Badge>
</div>
<ChevronDown
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${
expandedSeasons.has(season) ? 'rotate-180' : ''
}`}
/>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="p-0">
<div className="divide-y divide-border/50">
{episodesBySeason[season].map((episode, index) => (
<div
key={episode.id}
className="group p-4 hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="flex flex-col sm:flex-row gap-4">
{/* Thumbnail */}
<div className="w-full sm:w-[160px] shrink-0 aspect-video rounded-lg overflow-hidden relative bg-muted border border-border/30">
{episode.thumbnail ? (
<img
src={episode.thumbnail}
alt={episode.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Play className="w-8 h-8 text-muted-foreground" />
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<div className="w-10 h-10 rounded-full bg-primary/90 text-primary-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
<Play className="w-5 h-5 fill-current ml-0.5" />
</div>
</div>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-1">
Episode {episode.episode_number}
</p>
<h4 className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
{episode.title}
</h4>
{episode.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{episode.description}
</p>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0">
{episode.duration > 0 && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{episode.duration}m</span>
</div>
)}
{episode.air_date && (
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
<span>{episode.air_date}</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
))}
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
))}
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { Media } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Gamepad2, Layers } from 'lucide-react';
interface SeriesTabProps {
media: Media;
allMedia: Media[];
onMediaClick: (media: Media) => void;
}
export default function SeriesTab({ media, allMedia, onMediaClick }: SeriesTabProps) {
// Filter games that share at least one series with the current game
const seriesGames = allMedia.filter(
(m) =>
m.category === 'Games' &&
m.id !== media.id &&
m.series &&
media.series &&
m.series.some((s) => media.series!.includes(s))
);
if (seriesGames.length === 0) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Layers className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Series
</h2>
</div>
<Card className="border-border/60">
<CardContent className="p-6 text-center">
<Gamepad2 className="w-12 h-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-muted-foreground text-sm">
No other games found in the same series.
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Layers className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Series
</h2>
<Badge variant="secondary" className="text-xs">
{seriesGames.length}
</Badge>
</div>
<div className="flex flex-wrap gap-1.5">
{media.series?.map((s) => (
<Badge
key={s}
variant="outline"
className="text-xs border-primary/30 text-primary"
>
{s}
</Badge>
))}
</div>
</div>
{/* Games Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{seriesGames.map((game) => (
<Card
key={game.id}
className="group cursor-pointer hover:border-primary/30 hover:shadow-md transition-all duration-200 border-border/60 overflow-hidden"
onClick={() => onMediaClick(game)}
>
<div className={`aspect-[2/3] overflow-hidden bg-muted ${
game.aspectRatio === '16/9' ? 'aspect-video' :
game.aspectRatio === '1/1' ? 'aspect-square' : 'aspect-[2/3]'
}`}>
<img
src={game.poster}
alt={game.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
</div>
<CardContent className="p-3">
<p className="font-medium text-sm text-foreground truncate group-hover:text-primary transition-colors">
{game.title}
</p>
<p className="text-xs text-muted-foreground">
{game.year}
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { Track } from '@/types';
import { Search, Play, Disc, Clock } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
interface TracksTabProps {
tracks: Track[];
}
export default function TracksTab({ tracks }: TracksTabProps) {
const formatDuration = (seconds: number | null) => {
if (!seconds) return '—';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Disc className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Tracks
</h2>
<Badge variant="secondary" className="text-xs">
{tracks.length}
</Badge>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search tracks..."
className="pl-9 w-full sm:w-[200px] bg-muted/50 border-none rounded-lg h-9 text-sm"
/>
</div>
</div>
{/* Tracks List */}
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<div className="divide-y divide-border/50">
{tracks.map((track, index) => (
<div
key={track.id}
className="group flex items-center gap-4 p-3 hover:bg-muted/30 transition-colors cursor-pointer"
>
{/* Track Number / Play Button */}
<div className="w-8 text-center">
<span className="text-sm text-muted-foreground group-hover:hidden">
{track.track_number}
</span>
<div className="hidden group-hover:flex items-center justify-center">
<Play className="w-4 h-4 text-primary fill-current" />
</div>
</div>
{/* Track Info */}
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
{track.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{track.artist}
</p>
</div>
{/* Duration */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>{formatDuration(track.duration)}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
+375
View File
@@ -0,0 +1,375 @@
import React from 'react';
import { Media, MediaCategory } from '@/types';
import { cn } from '@/lib/utils';
import {
Star,
Building2,
Monitor,
Users,
FolderTree,
Database,
X
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuGroup
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
interface FilterOption {
label: string;
value: string;
count?: number;
}
interface MediaFiltersProps {
mediaList: Media[];
activeCategory: MediaCategory;
selectedGenre: string | null;
selectedStudio: string | null;
selectedPlatform: string | null;
selectedDeveloper: string | null;
selectedCategory: string | null;
selectedSource: string | null;
onGenreChange: (value: string | null) => void;
onStudioChange: (value: string | null) => void;
onPlatformChange: (value: string | null) => void;
onDeveloperChange: (value: string | null) => void;
onCategoryChange: (value: string | null) => void;
onSourceChange: (value: string | null) => void;
onClearAll: () => void;
}
export default function MediaFilters({
mediaList,
activeCategory,
selectedGenre,
selectedStudio,
selectedPlatform,
selectedDeveloper,
selectedCategory,
selectedSource,
onGenreChange,
onStudioChange,
onPlatformChange,
onDeveloperChange,
onCategoryChange,
onSourceChange,
onClearAll
}: MediaFiltersProps) {
// Extract unique filter values
const genres = React.useMemo(() =>
Array.from(new Set(mediaList.flatMap(m => m.genres || []))).sort(),
[mediaList]
);
const studios = React.useMemo(() =>
Array.from(new Set(mediaList.flatMap(m => m.studios || []))).sort(),
[mediaList]
);
const platforms = React.useMemo(() =>
Array.from(new Set(mediaList.flatMap(m => m.platforms || []))).sort(),
[mediaList]
);
const developers = React.useMemo(() =>
Array.from(new Set(mediaList.flatMap(m => m.developers || []))).sort(),
[mediaList]
);
const categories = React.useMemo(() =>
Array.from(new Set(mediaList.flatMap(m => m.series || []))).sort(),
[mediaList]
);
const sources = React.useMemo(() =>
Array.from(new Set(mediaList.map(m => m.source).filter(Boolean))).sort() as string[],
[mediaList]
);
const hasActiveFilters = selectedGenre || selectedStudio || selectedPlatform ||
selectedDeveloper || selectedCategory || selectedSource;
// Get available filters based on category
const getAvailableFilters = () => {
const baseFilters = ['genre'];
switch (activeCategory) {
case 'Movies':
case 'TV Series':
return [...baseFilters, 'studio'];
case 'Games':
return [...baseFilters, 'platform', 'developer', 'category'];
case 'Adult':
return [...baseFilters, 'studio'];
default:
return baseFilters;
}
};
const availableFilters = getAvailableFilters();
return (
<div className="flex flex-wrap items-center gap-2">
{/* Genre Filter - Always available */}
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedGenre
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Star size={14} className="mr-2" />
{selectedGenre || 'Genres'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Genre
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onGenreChange(null)}>
All Genres
</DropdownMenuItem>
{genres.map(genre => (
<DropdownMenuItem key={genre} onClick={() => onGenreChange(genre)}>
{genre}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Studio Filter - For Movies/Series/Adult */}
{availableFilters.includes('studio') && studios.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedStudio
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Building2 size={14} className="mr-2" />
{selectedStudio || 'Studios'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Studio
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onStudioChange(null)}>
All Studios
</DropdownMenuItem>
{studios.map(studio => (
<DropdownMenuItem key={studio} onClick={() => onStudioChange(studio)}>
{studio}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Platform Filter - For Games */}
{availableFilters.includes('platform') && platforms.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedPlatform
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Monitor size={14} className="mr-2" />
{selectedPlatform || 'Platforms'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Platform
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onPlatformChange(null)}>
All Platforms
</DropdownMenuItem>
{platforms.map(platform => (
<DropdownMenuItem key={platform} onClick={() => onPlatformChange(platform)}>
{platform}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Developer Filter - For Games */}
{availableFilters.includes('developer') && developers.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedDeveloper
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Users size={14} className="mr-2" />
{selectedDeveloper || 'Developers'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Developer
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDeveloperChange(null)}>
All Developers
</DropdownMenuItem>
{developers.map(developer => (
<DropdownMenuItem key={developer} onClick={() => onDeveloperChange(developer)}>
{developer}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Category/Series Filter - For Games */}
{availableFilters.includes('category') && categories.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedCategory
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<FolderTree size={14} className="mr-2" />
{selectedCategory || 'Series'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Series
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onCategoryChange(null)}>
All Series
</DropdownMenuItem>
{categories.map(category => (
<DropdownMenuItem key={category} onClick={() => onCategoryChange(category)}>
{category}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Source Filter */}
{sources.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedSource
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Database size={14} className="mr-2" />
{selectedSource || 'Source'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Source
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSourceChange(null)}>
All Sources
</DropdownMenuItem>
{sources.map(source => (
<DropdownMenuItem key={source} onClick={() => onSourceChange(source)}>
{source}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Clear All Filters */}
{hasActiveFilters && (
<button
onClick={onClearAll}
className="h-9 px-3 inline-flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
>
<X size={14} className="mr-2" />
Clear
</button>
)}
{/* Active Filter Badges */}
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-1 ml-2">
{selectedGenre && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onGenreChange(null)}
>
{selectedGenre} <X size={12} className="ml-1" />
</Badge>
)}
{selectedStudio && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onStudioChange(null)}
>
{selectedStudio} <X size={12} className="ml-1" />
</Badge>
)}
{selectedPlatform && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onPlatformChange(null)}
>
{selectedPlatform} <X size={12} className="ml-1" />
</Badge>
)}
{selectedDeveloper && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onDeveloperChange(null)}
>
{selectedDeveloper} <X size={12} className="ml-1" />
</Badge>
)}
{selectedCategory && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onCategoryChange(null)}
>
{selectedCategory} <X size={12} className="ml-1" />
</Badge>
)}
{selectedSource && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onSourceChange(null)}
>
{selectedSource} <X size={12} className="ml-1" />
</Badge>
)}
</div>
)}
</div>
);
}
+46
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={[]}
/>
);
}
@@ -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}
/>
);
}
@@ -0,0 +1,51 @@
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}
allMedia={allMedia}
onPersonClick={onPersonClick}
/>
);
}
+327
View File
@@ -0,0 +1,327 @@
import { useLocation, useNavigate, NavLink } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useTheme } from '@/contexts/ThemeContext';
import { MediaCategory } from '@/types';
import {
LayoutDashboard,
Library,
Users,
FolderKanban,
Database,
Settings,
Sun,
Moon,
LogOut,
Film,
Tv,
Gamepad2,
Heart,
Eye,
Flame,
Clock,
User,
Music,
BookOpen,
Monitor,
Download,
} from 'lucide-react';
// shadcn/ui sidebar components
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from '@/components/ui/sidebar';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
interface AppSidebarProps {
enabledCategories: MediaCategory[];
onToggleCategory: (category: MediaCategory) => void;
pageTitle?: string;
mediaCounts?: Record<string, number>;
activeFilter?: string;
onFilterChange?: (filter: string) => void;
user?: {
name: string;
email: string;
avatar?: string;
};
}
export default function AppSidebar({
enabledCategories,
pageTitle = 'MediaVault',
mediaCounts = {},
activeFilter,
onFilterChange,
user,
}: AppSidebarProps) {
const { theme, setTheme } = useTheme();
const location = useLocation();
const navigate = useNavigate();
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
const handleLogout = () => {
console.log('Logout clicked');
};
// Category config with icons, colors and routes
const categoryConfig: Record<MediaCategory, { icon: any; label: string; route: string; color: string }> = {
'Anime': { icon: Tv, label: 'Anime', route: '/anime', color: 'text-purple-400' },
'Movies': { icon: Film, label: 'Movies', route: '/movies', color: 'text-blue-400' },
'TV Series': { icon: Tv, label: 'Series', route: '/tv-series', color: 'text-green-400' },
'Music': { icon: Music, label: 'Music', route: '/music', color: 'text-pink-400' },
'Books': { icon: BookOpen, label: 'Books', route: '/books', color: 'text-yellow-400' },
'Adult': { icon: Eye, label: 'Adult', route: '/adult', color: 'text-rose-400' },
'Consoles': { icon: Monitor, label: 'Consoles', route: '/consoles', color: 'text-orange-400' },
'Games': { icon: Gamepad2, label: 'Games', route: '/games', color: 'text-indigo-400' },
};
const handleFilterClick = (filter: string) => {
onFilterChange?.(filter);
if (filter === 'favorites') {
navigate('/browse?favorites=true');
return;
}
// Find route for category
const config = categoryConfig[filter as MediaCategory];
if (config) {
navigate(config.route);
}
};
const handleQuickFilter = (filter: string) => {
const routes: Record<string, string> = {
'most-played': '/browse?sort=plays',
'recently-added': '/browse?sort=recent',
};
navigate(routes[filter] || '/browse');
};
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
};
// Build category routes for Library isActive check
const categoryRoutes = enabledCategories.map(cat => categoryConfig[cat].route);
const mainNavItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', isActive: isActive('/') },
{ to: '/browse', icon: Library, label: 'Library', isActive: isActive('/browse') || categoryRoutes.some(route => isActive(route)) },
{ to: '/cast', icon: Users, label: 'Actors', isActive: isActive('/cast') },
//{ to: '/collections', icon: FolderKanban, label: 'Collections', isActive: isActive('/collections') },
{ to: '/import', icon: Download, label: 'Import', isActive: isActive('/import') },
//{ to: '/sources', icon: Database, label: 'Sources', isActive: isActive('/sources') },
{ to: '/settings', icon: Settings, label: 'Settings', isActive: isActive('/settings') },
];
// Build media type filters from enabled categories
const mediaTypeFilters = enabledCategories.map(cat => {
const config = categoryConfig[cat];
return {
id: cat.toLowerCase().replace(/\s+/g, '-'),
icon: config.icon,
label: config.label,
count: mediaCounts[cat] || 0,
color: config.color,
category: cat,
};
});
return (
<Sidebar>
<SidebarHeader className="p-4">
<NavLink to="/" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center shadow-lg shadow-[#e8466c]/20">
<Database className="w-4 h-4 text-white" />
</div>
<span className="text-lg font-bold text-sidebar-foreground tracking-tight">{pageTitle}</span>
</NavLink>
</SidebarHeader>
<SidebarContent className="px-2">
{/* Main Navigation */}
<SidebarGroup>
<SidebarGroupLabel>
Navigation
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{mainNavItems.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
asChild
isActive={item.isActive}
className={cn(
item.isActive
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
: ''
)}
>
<NavLink to={item.to} className="flex items-center gap-2 w-full">
<item.icon className={cn('w-4 h-4 shrink-0', item.isActive ? 'text-[#e8466c]' : '')} />
<span className="truncate">{item.label}</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Media Type Filters */}
<SidebarGroup>
<SidebarGroupLabel>
Media Type
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{mediaTypeFilters.map((filter) => {
const isFilterActive = activeFilter === filter.id;
return (
<SidebarMenuItem key={filter.id}>
<SidebarMenuButton
onClick={() => handleFilterClick(filter.category)}
isActive={isFilterActive}
className={cn(
isFilterActive
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
: ''
)}
>
<filter.icon
className={cn(
'w-4 h-4 shrink-0',
isFilterActive ? 'text-[#e8466c]' : filter.color || ''
)}
/>
<span className="truncate flex-1 text-left">{filter.label}</span>
<span
className={cn(
'ml-auto text-xs font-medium px-2 py-0.5 rounded-full shrink-0',
isFilterActive
? 'bg-[#e8466c]/20 text-[#e8466c]'
: 'bg-sidebar-accent text-sidebar-foreground/60'
)}
>
{filter.count}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Quick Filters */}
<SidebarGroup>
<SidebarGroupLabel>
Quick Filters
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => handleQuickFilter('most-played')}
>
<Flame className="w-4 h-4 text-orange-400 shrink-0" />
<span className="truncate">Most Played</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => handleQuickFilter('recently-added')}
>
<Clock className="w-4 h-4 text-cyan-400 shrink-0" />
<span className="truncate">Recently Added</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-2 space-y-1">
{/* Theme Toggle */}
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
className="w-full justify-start gap-2 text-sidebar-foreground/60 hover:text-sidebar-foreground hover:bg-sidebar-accent"
>
{theme === 'dark' ? (
<>
<Sun className="w-4 h-4 text-amber-400" />
<span>Light Mode</span>
</>
) : (
<>
<Moon className="w-4 h-4 text-sidebar-foreground/60" />
<span>Dark Mode</span>
</>
)}
</Button>
{/* User Profile */}
{user ? (
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-sidebar-accent">
<Avatar className="w-8 h-8">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c] text-xs">
{user.name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-sidebar-foreground truncate">{user.name}</p>
<p className="text-xs text-sidebar-foreground/50 truncate">{user.email}</p>
</div>
</div>
) : (
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-sidebar-accent">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c]">
<User className="w-4 h-4" />
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-sidebar-foreground">Guest</p>
<p className="text-xs text-sidebar-foreground/50">Not logged in</p>
</div>
</div>
)}
{/* Logout */}
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="w-full justify-start gap-2 text-sidebar-foreground/60 hover:text-red-400 hover:bg-red-500/10"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>
</Button>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 select-none after:absolute after:inset-0 after: after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+19
View File
@@ -0,0 +1,19 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+14
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-[#e8466c] mb-4" />
<p className="text-lg font-bold">{message}</p>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
}
/>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number
max?: number
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, max = 100, ...props }, ref) => {
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
return (
<div
ref={ref}
role="progressbar"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<div
className="h-full w-full flex-1 bg-primary transition-all duration-500"
style={{ transform: `translateX(-${100 - percentage}%)` }}
/>
</div>
)
}
)
Progress.displayName = "Progress"
export { Progress }
+199
View File
@@ -0,0 +1,199 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+136
View File
@@ -0,0 +1,136 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+723
View File
@@ -0,0 +1,723 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement> {
value?: number
min?: number
max?: number
step?: number
onValueChange?: (value: number) => void
}
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
({ className, value, min = 0, max = 100, step = 1, onValueChange, onChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = Number(e.target.value)
onValueChange?.(newValue)
onChange?.(e)
}
return (
<input
type="range"
ref={ref}
value={value}
min={min}
max={max}
step={step}
onChange={handleChange}
className={cn(
"w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary",
className
)}
{...props}
/>
)
}
)
Slider.displayName = "Slider"
export { Slider }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+80
View File
@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+87
View File
@@ -0,0 +1,87 @@
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+49
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,
};
+74
View File
@@ -0,0 +1,74 @@
import { createContext, useContext, useEffect, useState, useCallback, 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 = useCallback((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;
}
+7 -1
View File
@@ -127,7 +127,13 @@ export const MOCK_MEDIA: Media[] = [
studios: ['Example Studio'],
}
];
export const DETAIL_MEDIA: Media = {}
export const DETAIL_MEDIA: Media = {
id: '',
title: '',
year: '',
poster: '',
category: 'Movies'
}
/*
export const DETAIL_MEDIA: Media = {
id: 'mob-psycho',
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+100 -32
View File
@@ -83,7 +83,7 @@
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--radius: 0.75rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
@@ -92,40 +92,71 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* MediaVault accent color - pink/coral */
--mv-accent: #e8466c;
--mv-accent-hover: #d13d60;
--mv-accent-light: #f47298;
/* Custom gradient colors */
--gradient-purple: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 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%);
--gradient-pink: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--background: oklch(0.145 0.005 35);
--foreground: oklch(0.82 0.008 35);
--card: oklch(0.17 0.005 35);
--card-foreground: oklch(0.82 0.008 35);
--popover: oklch(0.17 0.005 35);
--popover-foreground: oklch(0.82 0.008 35);
--primary: oklch(0.82 0.008 35);
--primary-foreground: oklch(0.145 0.005 35);
--secondary: oklch(0.21 0.005 35);
--secondary-foreground: oklch(0.82 0.008 35);
--muted: oklch(0.19 0.005 35);
--muted-foreground: oklch(0.55 0.01 35);
--accent: oklch(0.21 0.005 35);
--accent-foreground: oklch(0.82 0.008 35);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--border: oklch(0.82 0.008 35 / 10%);
--input: oklch(0.82 0.008 35 / 15%);
--ring: oklch(0.55 0 0);
--chart-1: oklch(0.7 0.08 35);
--chart-2: oklch(0.55 0.04 35);
--chart-3: oklch(0.4 0.02 35);
--chart-4: oklch(0.3 0.015 35);
--chart-5: oklch(0.2 0.01 35);
--sidebar: oklch(0.125 0.005 35);
--sidebar-foreground: oklch(0.82 0.008 35);
--sidebar-primary: oklch(0.55 0.22 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.19 0.005 35);
--sidebar-accent-foreground: oklch(0.82 0.008 35);
--sidebar-border: oklch(0.82 0.008 35 / 8%);
--sidebar-ring: oklch(0.55 0 0);
/* MediaVault accent color - pink/coral */
--mv-accent: #e8466c;
--mv-accent-hover: #d13d60;
--mv-accent-light: #f47298;
/* Custom gradient colors for dark mode - softer on eyes */
--gradient-purple: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 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%);
--gradient-pink: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
--gradient-orange: linear-gradient(135deg, #f97316 0%, #fb923c 50%, #fbbf24 100%);
--gradient-cyan: linear-gradient(135deg, #06b6d4 0%, #22d3ee 50%, #67e8f9 100%);
/* Background gradients for dark mode */
--bg-gradient-subtle: radial-gradient(circle at top right, rgba(232, 70, 108, 0.06) 0%, transparent 50%),
radial-gradient(circle at bottom left, rgba(232, 70, 108, 0.04) 0%, transparent 50%);
--bg-gradient-mesh: linear-gradient(135deg, rgba(232, 70, 108, 0.02) 0%, rgba(244, 114, 152, 0.02) 50%, rgba(249, 168, 201, 0.02) 100%);
}
@layer base {
@@ -133,9 +164,46 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground transition-[background-color,border-color] duration-200;
}
html {
@apply font-sans;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.708 0 0);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.556 0 0);
}
/* Glassmorphism utility */
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
+453
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);
});
});
});
+364
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);
});
});
});
+431
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);
});
});
});
+524
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
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 [];
}
}
+202
View File
@@ -0,0 +1,202 @@
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,
series: apiItem.series,
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
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;
}
}
+83
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;
}
}
+224
View File
@@ -0,0 +1,224 @@
// 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[];
series?: 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> {}
File diff suppressed because it is too large Load Diff
+251 -14
View File
@@ -1,67 +1,163 @@
/**
* 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;
}
/**
* Options for controlling the Playnite import process
*/
export interface PlayniteImportOptions {
/** Maximum number of items to import (optional) */
limit?: number;
/** Filter items by name (case-insensitive, optional - for debugging) */
nameFilter?: string;
}
/**
* Progress tracking for the import operation
*/
export interface ImportProgress {
/** Current number of items processed */
current: number;
/** Total number of items to process */
total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string;
/** Number of games successfully imported */
gamesImported: number;
/** Array of error messages encountered during import */
errors: string[];
}
/**
* Game data structure as returned by the Playnite API
*/
export interface PlayniteGame {
/** Unique identifier for the game */
id: string;
/** Game name */
name: string;
/** Alternate name for sorting purposes */
sortingName?: string;
/** Game description */
description?: string;
/** User notes */
notes?: string;
/** Game version */
version?: string;
/** Whether the game is hidden */
hidden?: boolean;
/** Whether the game is marked as favorite */
favorite?: boolean;
/** User rating (0-100) */
userScore?: number;
/** Community rating (0-100) */
communityScore?: number;
/** Critic rating (0-100) */
criticScore?: number;
/** Release date in ISO format */
releaseDate?: string;
/** Completion status (e.g., 'Completed', 'Playing', 'Abandoned') */
completionStatus?: string;
/** Game categories */
categories?: string[];
/** Game tags */
tags?: string[];
/** Game features */
features?: string[];
/** Game genres */
genres?: string[];
/** Developer names */
developers?: string[];
/** Publisher names */
publishers?: string[];
/** Series name */
series?: string[];
/** Platform names */
platforms?: string[];
/** Age rating names */
ageRatings?: string[];
/** Region names */
regions?: string[];
/** External links */
links?: Array<{
name: string;
url: string;
}>;
/** Total playtime in seconds */
playtime?: number;
/** Number of times played */
playCount?: number;
/** Last activity timestamp */
lastActivity?: string;
/** Date added to library */
added?: string;
/** Last played date */
lastPlayed?: string;
/** Source platform/library */
source?: string;
/** Whether the game is currently installed */
isInstalled?: boolean;
/** Cover image as base64 data URI */
coverBase64?: string;
/** Background image as base64 data URI */
backgroundBase64?: string;
/** Icon image as base64 data URI */
iconBase64?: string;
}
/**
* Response structure for the Playnite games API endpoint
*/
export interface PlayniteGamesResponse {
/** Total number of games available */
total: number;
/** Offset for pagination */
offset: number;
/** Limit for pagination */
limit: number;
/** Array of game objects */
games: PlayniteGame[];
}
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void;
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/*
async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
try {
const coverResponse = await fetch(`${baseUrl}/api/games/${gameId}/cover`, {
@@ -85,8 +181,80 @@ async function fetchGameCover(baseUrl: string, headers: Record<string, string>,
}
}
async function fetchGameBackground(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
try {
const backgroundResponse = await fetch(`${baseUrl}/api/games/${gameId}/background`, {
method: 'GET',
headers
});
if (!backgroundResponse.ok) {
return null;
}
const blob = await backgroundResponse.blob();
const arrayBuffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const mimeType = blob.type || 'image/jpeg';
return `data:${mimeType};base64,${base64}`;
} catch (error) {
return null;
}
}
async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
try {
const iconResponse = await fetch(`${baseUrl}/api/games/${gameId}/icon`, {
method: 'GET',
headers
});
if (!iconResponse.ok) {
return null;
}
const blob = await iconResponse.blob();
const arrayBuffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const mimeType = blob.type || 'image/png';
return `data:${mimeType};base64,${base64}`;
} catch (error) {
return null;
}
}
*/
/**
* Imports games from a Playnite library into the Omnyx media database
*
* This function performs the following steps:
* 1. Fetches existing media from Omnyx to check for duplicates
* 2. Fetches all games from the Playnite API
* 3. Fetches detailed information for each game
* 4. Converts Playnite game data to Omnyx media format
* 5. Imports or updates each game in the Omnyx database
*
* @param config - Configuration for connecting to Playnite
* @param options - Import options to control behavior
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromPlaynite(
* { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 },
* { limit: 10, nameFilter: 'Reside' },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.gamesImported} games`);
* ```
*/
export async function importFromPlaynite(
config: PlayniteConfig,
options: PlayniteImportOptions,
logCallback: LogCallback,
progressCallback: ProgressCallback
): Promise<ImportProgress> {
@@ -99,6 +267,8 @@ export async function importFromPlaynite(
errors: []
};
const { limit, nameFilter } = options;
const baseUrl = `http://${config.ip}:${config.port || 19821}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -109,11 +279,14 @@ export async function importFromPlaynite(
logCallback('Starting Playnite import...');
// Step 0: Fetch existing media to check for duplicates and enable updates
logCallback('Fetching existing media from Kyoo API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
logCallback('Fetching existing media from Omnyx API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
const existingMediaData = await existingMediaResponse.json();
const existingMedia = new Map(
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
(existingMediaData.data?.items || []).map((m: Media) => [
m.cleanname || m.title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-'),
m
])
);
logCallback(`Found ${existingMedia.size} existing games in database`);
@@ -121,7 +294,7 @@ export async function importFromPlaynite(
logCallback(`Fetching games from ${baseUrl}/api/games...`);
progressCallback({ message: 'Fetching games from Playnite...' });
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=5000`, {
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=${limit || 5000}`, {
method: 'GET',
headers
});
@@ -131,22 +304,49 @@ export async function importFromPlaynite(
}
const gamesData: PlayniteGamesResponse = await gamesResponse.json();
const games = gamesData.games || [];
let games = gamesData.games || [];
// Apply name filter if provided (case-insensitive)
if (nameFilter) {
const filterLower = nameFilter.toLowerCase();
games = games.filter(game => game.name?.toLowerCase().includes(filterLower));
logCallback(`Filtered to ${games.length} games matching "${nameFilter}"`);
}
// Apply limit if provided (after name filter)
if (limit && games.length > limit) {
games = games.slice(0, limit);
logCallback(`Limited to ${games.length} games`);
}
logCallback(`Found ${games.length} games in Playnite`);
// Deduplicate games by name (case-insensitive, trimmed)
const uniqueGamesMap = new Map<string, PlayniteGame>();
for (const game of games) {
const normalizedName = game.name.toLowerCase().trim();
if (!uniqueGamesMap.has(normalizedName)) {
uniqueGamesMap.set(normalizedName, game);
}
}
const uniqueGames = Array.from(uniqueGamesMap.values());
if (uniqueGames.length !== games.length) {
logCallback(`Deduplicated: ${games.length}${uniqueGames.length} unique games`);
}
// Step 2: Fetch detailed information for each game
progressCallback({
total: games.length,
total: uniqueGames.length,
current: 0,
stage: 'fetching',
message: 'Fetching game details...'
});
const detailedGames: PlayniteGame[] = [];
for (let i = 0; i < games.length; i++) {
const game = games[i];
for (let i = 0; i < uniqueGames.length; i++) {
const game = uniqueGames[i];
try {
logCallback(`Fetching details for: ${game.name} (${i + 1}/${games.length})`);
logCallback(`Fetching details for: ${game.name} (${i + 1}/${uniqueGames.length})`);
const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, {
method: 'GET',
@@ -155,6 +355,18 @@ export async function importFromPlaynite(
if (detailResponse.ok) {
const detailData: PlayniteGame = await detailResponse.json();
/*
// Fetch images
const [cover, background, icon] = await Promise.all([
fetchGameCover(baseUrl, headers, game.id),
fetchGameBackground(baseUrl, headers, game.id),
fetchGameIcon(baseUrl, headers, game.id)
]);
detailData.coverBase64 = cover;
detailData.backgroundBase64 = background;
detailData.iconBase64 = icon;
*/
detailedGames.push(detailData);
logCallback(`✓ Fetched details for: ${game.name}`);
} else {
@@ -188,9 +400,33 @@ export async function importFromPlaynite(
for (let i = 0; i < detailedGames.length; i++) {
const game = detailedGames[i];
const existingGame = existingMedia.get(game.name);
const cleanName = game.name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-');
const existingGame = existingMedia.get(cleanName);
const isUpdate = existingGame !== undefined;
if (!isUpdate) {
// Debug: show similar titles from database for games not found
const similarTitles = Array.from(existingMedia.keys()).filter((key): key is string =>
typeof key === 'string' && (key.includes(cleanName.substring(0, 10)) || cleanName.includes(key.substring(0, 10)))
).slice(0, 5);
if (similarTitles.length > 0) {
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND. Similar titles in DB: ${similarTitles.join(', ')}`);
} else {
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND (will import)`);
}
} else {
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - FOUND (will update)`);
}
// 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();
@@ -218,7 +454,7 @@ export async function importFromPlaynite(
}
// Staff is for actors/performers only - leave empty for games
const staff: any[] = [];
const staff: Staff[] = [];
// Determine type based on genres/features
let type = 'Game';
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
@@ -241,7 +477,7 @@ export async function importFromPlaynite(
series: game.series ? [game.series] : [],
ageRatings: game.ageRatings || [],
regions: game.regions || [],
source: game.source || null,
source: SOURCE_CATEGORY_MAPPING['playnite']?.includes('Games') ? (game.source || 'playnite') : null,
gameId: game.id,
pluginId: null,
completionStatus: game.completionStatus || 'Not Played',
@@ -267,7 +503,8 @@ export async function importFromPlaynite(
achievements: [],
year: year.toString(),
poster: game.coverBase64 || null,
banner: null,
banner: game.backgroundBase64 || null,
icon: game.iconBase64 || null,
rating: rating,
category: 'Game',
status: game.completionStatus === 'Completed' ? 'completed' :
+144 -19
View File
@@ -1,32 +1,77 @@
/**
* StashAPP Importer Module
*
* This module provides functionality to import adult video content and performers from a StashAPP instance
* into the Omnyx media database. It fetches scene and performer data via GraphQL, converts it to the Omnyx
* media format, and handles both new imports and updates to existing entries.
*
* @module stashappImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to a StashAPP instance
*/
export interface StashAPPConfig {
/** URL of the StashAPP server */
url: string;
/** API key for authentication (optional) */
apiKey?: string;
blacklist?: ['/AI/', 'temp', 'backup'];
/** List of path patterns to blacklist during import */
blacklist?: string[];
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean;
}
/**
* Progress tracking for the import operation
*/
export interface ImportProgress {
/** Current number of items processed */
current: number;
/** Total number of items to process */
total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string;
/** Number of videos successfully imported */
videosImported: number;
/** Number of actors successfully imported */
actorsImported: number;
/** Array of error messages encountered during import */
errors: string[];
}
/**
* Scene data structure as returned by the StashAPP GraphQL API
*/
export interface StashAPPScene {
/** Unique identifier for the scene */
id: string;
/** Scene title */
title: string;
/** Scene description/details */
details: string;
/** Scene URL */
url: string;
/** Release date in ISO format */
date: string;
/** Rating on a 0-100 scale */
rating100: number;
/** Whether the scene is organized */
organized: boolean;
/** O-counter value */
o_counter: number;
/** Creation timestamp */
created_at: string;
/** Last update timestamp */
updated_at: string;
/** File paths for various media assets */
paths: {
screenshot: string;
preview: string;
@@ -37,6 +82,7 @@ export interface StashAPPScene {
funscript: string;
caption: string;
};
/** Array of file information */
files: Array<{
size: number;
duration: number;
@@ -46,6 +92,7 @@ export interface StashAPPScene {
height: number;
path: string;
}>;
/** Array of performers in the scene */
performers: Array<{
id: string;
name: string;
@@ -77,7 +124,30 @@ export interface StashAPPScene {
export interface StashAPPScenePerformer {
id: string;
name: string;
disambiguation: string;
url: string;
gender: string;
birthdate: string;
ethnicity: string;
country: string;
eye_color: string;
height_cm: number;
measurements: string;
fake_tits: boolean;
career_length: string;
tattoos: string;
piercings: string;
alias_list: string[];
favorite: boolean;
ignore_auto_tag: boolean;
created_at?: string;
updated_at?: string;
details: string;
death_date: string;
hair_color: string;
weight: number;
image_path: string;
scene_count: number;
}
export interface StashAPPPerformer {
@@ -127,9 +197,24 @@ export interface StashAPPPerformersResponse {
};
}
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/**
* Checks if a file path matches any blacklist pattern
* @param filePath - The file path to check
* @param blacklist - Array of blacklist patterns
* @returns True if the path is blacklisted, false otherwise
*/
function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
if (!blacklist || blacklist.length === 0) {
return false;
@@ -137,6 +222,17 @@ function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
return blacklist.some(pattern => filePath.includes(pattern));
}
/**
* Updates or creates actor entries from StashAPP performers
*
* This function fetches all performers from StashAPP and updates or creates
* corresponding actor entries in the Omnyx database.
*
* @param config - Configuration for connecting to StashAPP
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*/
export async function updateActorsFromStashAPP(
config: StashAPPConfig,
logCallback: LogCallback,
@@ -155,12 +251,12 @@ export async function updateActorsFromStashAPP(
try {
logCallback('Starting StashAPP actor update...');
// Fetch existing cast from Kyoo API
logCallback('Fetching existing cast from Kyoo API...');
// Fetch existing cast from Omnyx API
logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
const existingCastData = await existingCastResponse.json();
const existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
const existingActors = new Map<string, Staff>(
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
@@ -245,12 +341,12 @@ export async function updateActorsFromStashAPP(
for (let i = 0; i < performers.length; i++) {
const performer = performers[i];
const existingActor: any = existingActors.get(performer.name);
const existingActor: Staff | undefined = existingActors.get(performer.name);
try {
if (existingActor) {
// Update existing actor
const updateData: any = {
const updateData: Partial<Staff> = {
name: performer.name,
};
@@ -359,6 +455,31 @@ export async function updateActorsFromStashAPP(
}
}
/**
* Imports scenes and performers from a StashAPP instance into the Omnyx media database
*
* This function performs the following steps:
* 1. Fetches existing media and cast from Omnyx to check for duplicates
* 2. Fetches all scenes from StashAPP via GraphQL
* 3. Extracts unique performers from all scenes
* 4. Imports or updates performers first
* 5. Imports or updates scenes with their associated performers
*
* @param config - Configuration for connecting to StashAPP
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromStashAPP(
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
* ```
*/
export async function importFromStashAPP(
config: StashAPPConfig,
logCallback: LogCallback,
@@ -378,19 +499,19 @@ export async function importFromStashAPP(
logCallback('Starting StashAPP import...');
// Step 0: Fetch existing media and cast to check for duplicates
logCallback('Fetching existing media from Kyoo API...');
logCallback('Fetching existing media from Omnyx API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
const existingMediaData = await existingMediaResponse.json();
const existingTitles = new Set(
existingMediaData.data?.items?.map((m: any) => m.title) || []
existingMediaData.data?.items?.map((m: Media) => m.title) || []
);
logCallback(`Found ${existingTitles.size} existing videos in database`);
logCallback('Fetching existing cast from Kyoo API...');
logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
const existingCastData = await existingCastResponse.json();
const existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
const existingActors = new Map<string, Staff>(
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
@@ -521,12 +642,12 @@ export async function importFromStashAPP(
for (let i = 0; i < uniquePerformers.length; i++) {
const performer = uniquePerformers[i];
const existingActor: any = existingActors.get(performer.name);
const existingActor: Staff | undefined = existingActors.get(performer.name);
try {
if (existingActor) {
// Update existing actor
const updateData: any = {
const updateData: Partial<Staff> = {
name: performer.name,
};
@@ -642,11 +763,14 @@ export async function importFromStashAPP(
// Check for duplicate
if (existingTitles.has(scene.title)) {
logCallback(`⊘ Skipped duplicate: ${scene.title}`);
progressCallback({
current: uniquePerformers.length + i + 1
});
continue;
if (!config.updateExisting) {
logCallback(`⊘ Skipped duplicate: ${scene.title} (updateExisting is false)`);
progressCallback({
current: uniquePerformers.length + i + 1
});
continue;
}
logCallback(`→ Updating existing: ${scene.title}`);
}
try {
@@ -702,6 +826,7 @@ export async function importFromStashAPP(
director: null,
writer: null,
releaseDate: releaseDate,
source: SOURCE_CATEGORY_MAPPING['stashapp']?.includes('Adult') ? 'stashapp' : null,
genres: [],
tags: [],
studios: [],
+108 -9
View File
@@ -1,44 +1,96 @@
/**
* XBVR Importer Module
*
* This module provides functionality to import VR adult video content from an XBVR instance into the Omnyx media database.
* It fetches scene data from the DeoVR API endpoint, extracts actors and video details, and handles both new imports
* and updates to existing entries. The module specifically filters for content in the 'Recent' scene group.
*
* @module xbvrImporter
*/
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
/**
* Configuration for connecting to an XBVR instance
*/
export interface XBVRConfig {
/** URL of the XBVR server */
url: string;
/** API key for authentication (optional) */
apiKey?: string;
/** If true, update existing media entries; if false, only import new entries */
updateExisting?: boolean;
}
/**
* Progress tracking for the import operation
*/
export interface ImportProgress {
/** Current number of items processed */
current: number;
/** Total number of items to process */
total: number;
/** Current stage of the import process */
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
/** Human-readable status message */
message: string;
/** Number of videos successfully imported */
videosImported: number;
/** Number of actors successfully imported */
actorsImported: number;
/** Array of error messages encountered during import */
errors: string[];
}
/**
* Basic video information from the DeoVR scene list
*/
export interface XBVRVideo {
/** Video title */
title: string;
/** Video length in seconds */
videoLength: number;
/** URL to the video thumbnail */
thumbnailUrl: string;
/** URL to fetch detailed video information */
video_url: string;
}
/**
* Detailed video information as returned by the XBVR API
*/
export interface XBVRVideoDetail {
/** Unique video identifier */
id: number;
/** Video title */
title: string;
/** Video description */
description: string;
/** Release date as Unix timestamp */
date: number;
/** URL to the video thumbnail */
thumbnailUrl: string;
/** Average rating */
rating_avg: number;
/** Screen type (e.g., '180', '360', 'dome') */
screenType: string;
/** Stereo mode (e.g., 'sbs', 'tb') */
stereoMode: string;
/** Video length in seconds */
videoLength: number;
/** Pay site information */
paysite?: {
name: string;
};
/** Array of actors in the video */
actors: Array<{
id: number;
name: string;
}>;
/** Array of category tags */
categories: Array<{
tag: {
name: string;
@@ -46,16 +98,59 @@ export interface XBVRVideoDetail {
}>;
}
/**
* Scene list structure as returned by the DeoVR API
*/
export interface XBVRSceneList {
/** Array of scene groups */
scenes: Array<{
/** Name of the scene group (e.g., 'Recent', 'Favorites') */
name: string;
/** List of videos in this group */
list: XBVRVideo[];
}>;
}
/**
* Callback function for logging import progress messages
* @param message - The log message to display
*/
export type LogCallback = (message: string) => void;
/**
* Callback function for updating import progress
* @param progress - Partial progress object with updated fields
*/
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
/**
* Imports VR adult videos and actors from an XBVR instance into the Omnyx media database
*
* This function performs the following steps:
* 1. Fetches existing media and cast from Omnyx to check for duplicates
* 2. Fetches the scene list from the DeoVR API endpoint
* 3. Extracts videos from the 'Recent' scene group
* 4. Fetches detailed information for each video
* 5. Imports or updates actors first
* 6. Imports or updates videos with their associated actors
*
* Videos and actors containing 'aka:' in their name are automatically skipped.
*
* @param config - Configuration for connecting to XBVR
* @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state
*
* @example
* ```typescript
* const progress = await importFromXBVR(
* { url: 'http://localhost:9999', apiKey: 'your-api-key' },
* (msg) => console.log(msg),
* (prog) => updateUI(prog)
* );
* console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`);
* ```
*/
export async function importFromXBVR(
config: XBVRConfig,
logCallback: LogCallback,
@@ -75,19 +170,19 @@ export async function importFromXBVR(
logCallback('Starting DeoVR import...');
// Step 0: Fetch existing media and cast to check for duplicates
logCallback('Fetching existing media from Kyoo API...');
logCallback('Fetching existing media from Omnyx API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
const existingMediaData = await existingMediaResponse.json();
const existingTitles = new Set(
existingMediaData.data?.items?.map((m: any) => m.title) || []
existingMediaData.data?.items?.map((m: Media) => m.title) || []
);
logCallback(`Found ${existingTitles.size} existing videos in database`);
logCallback('Fetching existing cast from Kyoo API...');
logCallback('Fetching existing cast from Omnyx API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
const existingCastData = await existingCastResponse.json();
const existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
@@ -255,11 +350,14 @@ export async function importFromXBVR(
// Check for duplicate
if (existingTitles.has(video.title)) {
logCallback(`⊘ Skipped duplicate: ${video.title}`);
progressCallback({
current: uniqueActors.length + i + 1
});
continue;
if (!config.updateExisting) {
logCallback(`⊘ Skipped duplicate: ${video.title} (updateExisting is false)`);
progressCallback({
current: uniqueActors.length + i + 1
});
continue;
}
logCallback(`→ Updating existing: ${video.title}`);
}
try {
@@ -308,6 +406,7 @@ export async function importFromXBVR(
director: null,
writer: null,
releaseDate: releaseDate,
source: SOURCE_CATEGORY_MAPPING['xbvr']?.includes('Adult') ? 'xbvr' : null,
genres: categories,
tags: categories,
studios: video.paysite?.name ? [video.paysite.name] : [],
+4 -1
View File
@@ -2,9 +2,12 @@ import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { TooltipProvider } from '@/components/ui/tooltip';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<TooltipProvider>
<App />
</TooltipProvider>
</StrictMode>,
);
+70
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
}),
}));
+44 -4
View File
@@ -3,6 +3,7 @@ export type MediaCategory = 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books'
export interface Media {
id: string;
title: string;
cleanname?: string;
year: string;
poster: string;
category: MediaCategory;
@@ -16,8 +17,10 @@ export interface Media {
studios?: string[];
status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold';
episodes?: Episode[];
tracks?: Track[];
staff?: Staff[];
categories?: string[];
series?: string[];
platforms?: string[];
developers?: string[];
completionStatus?: string;
@@ -28,15 +31,26 @@ export interface Media {
}
export interface Episode {
id: string;
number: number;
id: number;
media_id: number;
season: number;
episode_number: number;
title: string;
date: string;
duration: string;
description: string;
air_date: string;
duration: number;
thumbnail: string;
}
export interface Track {
id: number;
media_id: number;
track_number: number;
title: string;
duration: number | null;
artist: string;
}
export interface Staff {
id: string;
name: string;
@@ -100,11 +114,37 @@ export interface UserSettings {
id?: number;
enabledCategories: MediaCategory[];
itemsPerPage: number;
gridItemSize: number; // 1-10 scale
defaultView: 'grid' | 'list';
showAdultContent: boolean;
autoPlayTrailers: boolean;
language: string;
theme: 'light' | 'dark' | 'system';
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[]
// Page Settings
pageTitle?: string; // Custom page title
favicon?: string; // Base64 encoded favicon/image
customColors?: CustomColors; // Custom color scheme
createdAt?: string;
updatedAt?: string;
}
export interface CustomColors {
primary?: string; // Primary accent color (hex)
secondary?: string; // Secondary accent color (hex)
background?: string; // Background color (hex)
surface?: string; // Surface/card color (hex)
text?: string; // Text color (hex)
muted?: string; // Muted text color (hex)
border?: string; // Border color (hex)
}
// Source to Category mapping - ensures sources are only used with appropriate categories
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
'xbvr': ['Adult'],
'stashapp': ['Adult'],
'playnite': ['Games'],
'manual': ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Adult', 'Consoles', 'Games'],
};
+26
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"
}
+6 -1
View File
@@ -17,8 +17,13 @@ export default defineConfig(({mode}) => {
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
// Do not modifyfile watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: [],
},
};
});