Compare commits
38 Commits
dda118a2f7
..
app
| Author | SHA1 | Date | |
|---|---|---|---|
| 15fe7670c8 | |||
| d61472f069 | |||
| 83617f75e4 | |||
| 901b342871 | |||
| b0cb8ca0a2 | |||
| 4605b251be | |||
| 073c8a6c5d | |||
| 9a72ba3064 | |||
| 34bb4a27be | |||
| e5cdd6b383 | |||
| 63c5d0a7c0 | |||
| 432416cfc5 | |||
| a407b57006 | |||
| b57b22c30b | |||
| a6d153ac1e | |||
| 6250164656 | |||
| 9c7e5a2b19 | |||
| dff599e5af | |||
| 6c316fbf84 | |||
| 0d530ea99c | |||
| 555209ed4b | |||
| 52d272c701 | |||
| b36b72b8e0 | |||
| 53c6f5c555 | |||
| f482807387 | |||
| 444c908449 | |||
| b29732a653 | |||
| 96593a6235 | |||
| 07c3270e12 | |||
| 04156486e2 | |||
| f5c3e96823 | |||
| a610ce304e | |||
| 6438a23301 | |||
| d6ad4c80b3 | |||
| 73c578f1ec | |||
| 1caadd12e1 | |||
| 6d5397505a | |||
| d6a0aac5f7 |
@@ -2,3 +2,21 @@
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
|
||||
# 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
|
||||
VITE_XBVR_URL=""
|
||||
|
||||
# StashAPP Importer
|
||||
VITE_STASHAPP_URL=""
|
||||
VITE_STASHAPP_API_KEY=""
|
||||
|
||||
# Playnite Importer
|
||||
VITE_PLAYNITE_IP="localhost"
|
||||
VITE_PLAYNITE_PORT="19821"
|
||||
VITE_PLAYNITE_API_TOKEN=""
|
||||
|
||||
@@ -6,3 +6,5 @@ coverage/
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
/docs
|
||||
/.windsurf
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
# Omnyx Backend Migrationsplan
|
||||
|
||||
## Ziel
|
||||
|
||||
Migration von PHP + MySQL zu Bun + Hono + Drizzle ORM für eine Hybrid-Architektur:
|
||||
- **Lokal:** SQLite (Desktop/Mobile, offline-fähig)
|
||||
- **Server:** PostgreSQL (Docker, Multi-User)
|
||||
- **Desktop:** Tauri + Bun-Sidecar
|
||||
- **Mobile:** Capacitor + SQLite via WASM
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Komponente | Technologie |
|
||||
|---|---|
|
||||
| **Runtime** | Bun (TypeScript-nativ, bun:sqlite built-in) |
|
||||
| **Server** | Hono (leicht, Zod-Validation) |
|
||||
| **ORM** | Drizzle ORM (bun:sqlite / node-postgres) |
|
||||
| **DB lokal** | bun:sqlite |
|
||||
| **DB Server** | PostgreSQL (Docker) |
|
||||
| **Desktop** | Tauri + Bun-Sidecar |
|
||||
| **Mobile** | Capacitor + @libsql/client-web (WASM) |
|
||||
|
||||
## Projektstruktur (separates Repo)
|
||||
|
||||
```
|
||||
omnyx-backend/
|
||||
├── src/
|
||||
│ ├── index.ts ← Hono-Server + Router
|
||||
│ ├── db/
|
||||
│ │ ├── schema.ts ← Drizzle-Schema (alle Tabellen)
|
||||
│ │ ├── index.ts ← DB-Connection (bun:sqlite / pg)
|
||||
│ │ └── migrate.ts ← Migration runner
|
||||
│ ├── routes/
|
||||
│ │ ├── media.ts ← CRUD /api/media
|
||||
│ │ ├── cast.ts ← CRUD /api/cast
|
||||
│ │ └── settings.ts ← CRUD /api/settings
|
||||
│ ├── middleware/
|
||||
│ │ ├── cors.ts
|
||||
│ │ └── error.ts
|
||||
│ └── lib/
|
||||
│ └── json-fields.ts ← Helper für JSON-Array-Felder
|
||||
├── scripts/
|
||||
│ └── migrate-from-php.ts ← Import-Skript von alter MySQL-DB
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Datenbank-Tabellen
|
||||
|
||||
### media
|
||||
| Spalte | Typ | Anmerkung |
|
||||
|--------|-----|-----------|
|
||||
| id | integer | PK autoIncrement |
|
||||
| title | text | NOT NULL |
|
||||
| year | integer | NOT NULL |
|
||||
| poster | text | nullable |
|
||||
| banner | text | nullable |
|
||||
| description | text | nullable |
|
||||
| rating | real | nullable |
|
||||
| category | text | nullable |
|
||||
| type | text | default 'Movie' |
|
||||
| status | text | default 'Released' |
|
||||
| aspect_ratio | text | nullable |
|
||||
| runtime | integer | nullable |
|
||||
| director | text | nullable |
|
||||
| writer | text | nullable |
|
||||
| release_date | text | nullable |
|
||||
| source | text | nullable |
|
||||
| created_at | text | default datetime() |
|
||||
| updated_at | text | default datetime() |
|
||||
| genres | text | JSON-Array |
|
||||
| tags | text | JSON-Array |
|
||||
| studios | text | JSON-Array |
|
||||
| categories | text | JSON-Array |
|
||||
| series | text | JSON-Array |
|
||||
| platforms | text | JSON-Array |
|
||||
| developers | text | JSON-Array |
|
||||
| completion_status | text | nullable |
|
||||
| play_count | integer | default 0 |
|
||||
| last_activity | text | nullable |
|
||||
| playtime | integer | default 0 |
|
||||
|
||||
### episodes
|
||||
| Spalte | Typ |
|
||||
|--------|-----|
|
||||
| id | integer PK |
|
||||
| media_id | integer FK → media |
|
||||
| season | integer |
|
||||
| episode_number | integer |
|
||||
| title | text |
|
||||
| description | text |
|
||||
| air_date | text |
|
||||
| duration | integer |
|
||||
| thumbnail | text |
|
||||
|
||||
### tracks
|
||||
| Spalte | Typ |
|
||||
|--------|-----|
|
||||
| id | integer PK |
|
||||
| media_id | integer FK → media |
|
||||
| track_number | integer |
|
||||
| title | text |
|
||||
| duration | text (format: "M:SS") |
|
||||
| artist | text |
|
||||
|
||||
### cast (staff)
|
||||
| Spalte | Typ |
|
||||
|--------|-----|
|
||||
| id | integer PK |
|
||||
| name | text NOT NULL |
|
||||
| cleanname | text nullable |
|
||||
| photo | text nullable |
|
||||
| bio | text nullable |
|
||||
| birth_date | text nullable |
|
||||
| birth_place | text nullable |
|
||||
| occupations | text (JSON-Array) |
|
||||
| created_at | text |
|
||||
| updated_at | text |
|
||||
| media_types | text (JSON-Array) |
|
||||
| bust_size | integer nullable |
|
||||
| cup_size | text nullable |
|
||||
| waist_size | integer nullable |
|
||||
| hip_size | integer nullable |
|
||||
| height | integer nullable |
|
||||
| weight | integer nullable |
|
||||
| hair_color | text nullable |
|
||||
| eye_color | text nullable |
|
||||
| ethnicity | text nullable |
|
||||
|
||||
### adult_specifics
|
||||
| Spalte | Typ |
|
||||
|--------|-----|
|
||||
| id | integer PK |
|
||||
| cast_id | integer FK → cast |
|
||||
| tattoos | text nullable |
|
||||
| piercings | text nullable |
|
||||
| measurements | text nullable |
|
||||
| shoe_size | integer nullable |
|
||||
|
||||
### media_cast_staff (Junction)
|
||||
| Spalte | Typ |
|
||||
|--------|-----|
|
||||
| media_id | integer FK |
|
||||
| cast_id | integer FK |
|
||||
| role | text |
|
||||
| character_name | text nullable |
|
||||
| character_image | text nullable |
|
||||
| PK | (media_id, cast_id) |
|
||||
|
||||
### cast_media_filmography (Junction)
|
||||
| Spalte | Typ |
|
||||
|--------|-----|
|
||||
| cast_id | integer FK |
|
||||
| media_id | integer FK |
|
||||
| role | text |
|
||||
| character_name | text nullable |
|
||||
|
||||
### settings
|
||||
| Spalte | Typ |
|
||||
|--------|-----|
|
||||
| id | integer PK |
|
||||
| enabled_categories | text (JSON-Array) |
|
||||
| items_per_page | integer default 20 |
|
||||
| grid_item_size | integer default 5 |
|
||||
| default_view | text default 'grid' |
|
||||
| show_adult_content | integer (boolean) default 0 |
|
||||
| auto_play_trailers | integer (boolean) default 0 |
|
||||
| language | text default 'en' |
|
||||
| theme | text default 'system' |
|
||||
| jellyfin_library_mappings | text nullable |
|
||||
| page_title | text nullable |
|
||||
| favicon | text nullable |
|
||||
| custom_colors | text nullable (JSON) |
|
||||
| created_at | text |
|
||||
| updated_at | text |
|
||||
|
||||
## API-Endpoints (1:1 von PHP)
|
||||
|
||||
### Media
|
||||
```
|
||||
GET /api/media?page=1&limit=10000 → PaginatedResponse<ApiMediaItem>
|
||||
GET /api/media/:id → ApiResponse<ApiMediaItem>
|
||||
POST /api/media → ApiResponse<ApiMediaItem>
|
||||
PUT /api/media/:id → ApiResponse<ApiMediaItem>
|
||||
DELETE /api/media/:id → ApiResponse<{message}>
|
||||
```
|
||||
|
||||
### Cast
|
||||
```
|
||||
GET /api/cast?page=1&limit=100000 → PaginatedResponse<ApiCastItem>
|
||||
GET /api/cast/:id → ApiResponse<ApiCastItem>
|
||||
GET /api/cast/:id/media → PaginatedResponse<ApiMediaItem>
|
||||
POST /api/cast → ApiResponse<ApiCastItem>
|
||||
PUT /api/cast/:id → ApiResponse<ApiCastItem>
|
||||
DELETE /api/cast/:id → ApiResponse<{message}>
|
||||
```
|
||||
|
||||
### Settings
|
||||
```
|
||||
GET /api/settings → ApiResponse<ApiSettingsItem>
|
||||
POST /api/settings → ApiResponse<ApiSettingsItem>
|
||||
PUT /api/settings → ApiResponse<ApiSettingsItem>
|
||||
```
|
||||
|
||||
## Response-Format (identisch zu PHP)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## DB-Connection (Driver-Abstraktion)
|
||||
|
||||
```typescript
|
||||
if (process.env.DATABASE_URL) {
|
||||
// Server-Modus → PostgreSQL
|
||||
const { Pool } = await import('pg');
|
||||
const { drizzle } = await import('drizzle-orm/node-postgres');
|
||||
db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }));
|
||||
} else {
|
||||
// Lokaler Modus → bun:sqlite
|
||||
const { Database } = await import('bun:sqlite');
|
||||
const { drizzle } = await import('drizzle-orm/bun-sqlite');
|
||||
const sqlite = new Database('./data/omnyx.db', { create: true });
|
||||
sqlite.exec('PRAGMA journal_mode=WAL;');
|
||||
db = drizzle(sqlite);
|
||||
}
|
||||
```
|
||||
|
||||
## Desktop-Bundle (Tauri)
|
||||
|
||||
- Bun-Binary via `bun build --compile` (~50MB Single-File)
|
||||
- Tauri startet Binary als Sidecar
|
||||
- React-Frontend (produktions-Build) in Tauri-WebView
|
||||
- API auf `localhost:3001`
|
||||
|
||||
## Mobile (Capacitor + WASM SQLite)
|
||||
|
||||
- SQLite läuft direkt im WebView via `@libsql/client/web`
|
||||
- Drizzle-Queries identisch zum Backend
|
||||
- Kein separater Server nötig
|
||||
- Optional: Sync mit Server bei Internet-Verbindung
|
||||
|
||||
## Docker-Deployment
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
build: .
|
||||
environment:
|
||||
DATABASE_URL: postgres://user:pass@db:5432/omnyx
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
```
|
||||
|
||||
## Status (23.05.2026)
|
||||
|
||||
| Phase | Beschreibung | Status |
|
||||
|---|---|---|
|
||||
| P1 | Backend-Projekt (Bun + Hono + Drizzle) | ✅ **Fertig** |
|
||||
| P2 | Hono-Routen (Media, Cast, Settings) | ✅ **Fertig** |
|
||||
| P3 | Tests + TypeScript-Lint | ✅ **Fertig** |
|
||||
| P4 | Docker-Config aktualisiert | ✅ **Fertig** |
|
||||
| P5 | Tauri Desktop (Bun-Sidecar) | ✅ **Fertig** |
|
||||
| P6 | PostgreSQL-Support + Deployment | ⏳ Ausstehend |
|
||||
| P7 | Sync-Logik (Hybrid) | ⏳ Optional |
|
||||
| P8 | Capacitor Mobile (lokales SQLite) | ⏳ Ausstehend |
|
||||
|
||||
### Aktuelle Struktur
|
||||
|
||||
```
|
||||
G:\kyoo\
|
||||
├── backend/ ← Bun + Hono + Drizzle
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts ← Hono-Server (Port 3001)
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── schema.ts ← Drizzle-Schema (9 Tabellen)
|
||||
│ │ │ ├── index.ts ← DB-Connection (bun:sqlite / pg-fähig)
|
||||
│ │ │ └── migrate.ts ← Auto-Migration (CREATE TABLE IF NOT EXISTS)
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── media.ts ← CRUD /api/media
|
||||
│ │ │ ├── cast.ts ← CRUD /api/cast + POST /adult
|
||||
│ │ │ ├── settings.ts ← CRUD /api/settings
|
||||
│ │ │ └── images.ts ← Proxy-/Cache-Endpunkt /api/images/proxy
|
||||
│ │ ├── lib/converters.ts ← Type-Converter
|
||||
│ │ └── types.ts ← API-Types
|
||||
│ ├── uploads/ ← Gecachte Bilder (offline-fähig)
|
||||
│ ├── dist-exe/ ← Compiled binary (~50MB)
|
||||
│ ├── Dockerfile ← Bun-basiert
|
||||
│ ├── package.json
|
||||
│ └── README.md
|
||||
├── frontend/ ← React SPA + Tauri-Desktop-Shell
|
||||
│ ├── src-tauri/ ← Tauri v2 Desktop-Konfiguration
|
||||
│ │ ├── src/lib.rs ← Sidecar-Spawn (omnyx-backend)
|
||||
│ │ ├── binaries/ ← Kompilierte Binaries pro Target
|
||||
│ │ ├── tauri.conf.json ← Window 1280x800, externalBin
|
||||
│ │ ├── capabilities/ ← shell:default, shell:allow-spawn
|
||||
│ │ └── Cargo.toml ← tauri-plugin-shell
|
||||
│ └── .env.tauri ← VITE_API_URL=http://localhost:3001
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
### Nächste Schritte
|
||||
|
||||
1. **PostgreSQL-Support** (P6) — pg-Treiber für Drizzle, docker-compose mit Postgres
|
||||
2. **Capacitor Mobile** (P8) — Android-App mit embedded SQLite via WASM
|
||||
3. **Sticky Footer Bug** — Footer klebt nicht am unteren Rand bei kurzen Seiten
|
||||
4. **PUT /api/cast/:id** um adult-Felder erweitern (height, weight etc. aktuell nicht updatbar)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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;"]
|
||||
@@ -1,20 +1,263 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||

|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
# Omnyx - Media Discovery Platform
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||

|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/e9b611d4-7585-400f-9a12-81904b28ce1c
|
||||
A modern web application for browsing, managing, and discovering media across multiple categories. Omnyx provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR.
|
||||
|
||||
## Run Locally
|
||||
## Features
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
- **Multi-Category Media Management**: Browse and organize Anime, Movies, TV Series, Music, Books, Consoles, Games, and Adult content
|
||||
- **Detailed Media Views**: Rich information display including descriptions, genres, studios, cast, and ratings
|
||||
- **Cast & Staff Directory**: Explore actors, directors, and other staff across your media library
|
||||
- **Search & Filter**: Quickly find media by title, year, genre, or studio
|
||||
- **Custom Media**: Add your own media entries manually
|
||||
- **External Importers**: Import libraries from popular media managers:
|
||||
- **Playnite**: Import your game library with playtime, achievements, and metadata
|
||||
- **StashAPP**: Import adult content with scene and performer details
|
||||
- **XBVR**: Import VR content with scene information
|
||||
- **Category Toggles**: Enable/disable categories to customize your view
|
||||
- **Responsive Design**: Beautiful UI built with Tailwind CSS and shadcn/ui components
|
||||
|
||||
## Tech Stack
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
- **Frontend Framework**: React 19 with TypeScript
|
||||
- **Build Tool**: Vite 6
|
||||
- **Styling**: Tailwind CSS 4
|
||||
- **UI Components**: shadcn/ui, Base UI
|
||||
- **Icons**: Lucide React
|
||||
- **Animations**: Motion (Framer Motion)
|
||||
- **Fonts**: Geist Variable Font
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (v18 or higher)
|
||||
- npm or yarn
|
||||
- Backend API server (configured via environment variables)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configure environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and configure your settings (see [Configuration](#configuration) below).
|
||||
|
||||
4. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:3000`
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a `.env` file based on `.env.example`:
|
||||
|
||||
```env
|
||||
# App URL (auto-injected by AI Studio in production)
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# XBVR Importer
|
||||
VITE_XBVR_URL="http://localhost:9999"
|
||||
|
||||
# StashAPP Importer
|
||||
VITE_STASHAPP_URL="http://localhost:9999"
|
||||
VITE_STASHAPP_API_KEY="your-api-key"
|
||||
|
||||
# Playnite Importer
|
||||
VITE_PLAYNITE_IP="localhost"
|
||||
VITE_PLAYNITE_PORT="19821"
|
||||
VITE_PLAYNITE_API_TOKEN="your-api-token"
|
||||
```
|
||||
|
||||
### Importer Configuration
|
||||
|
||||
#### Playnite
|
||||
1. Install the [Playnite Bridge extension](https://github.com/JosefNemec/PlayniteExtensions) in Playnite
|
||||
2. Enable the HTTP API server in Playnite settings
|
||||
3. Generate an API token in the Playnite Bridge settings
|
||||
4. Configure the IP, port, and token in your `.env` file
|
||||
5. See [playnite_skill.md](playnite_skill.md) for detailed API documentation
|
||||
|
||||
#### StashAPP
|
||||
1. Ensure StashAPP is running and accessible
|
||||
2. Generate an API key in StashAPP settings
|
||||
3. Configure the URL and API key in your `.env` file
|
||||
|
||||
#### XBVR
|
||||
1. Ensure XBVR is running and accessible
|
||||
2. Configure the URL in your `.env` file
|
||||
|
||||
## Desktop App (Tauri) — Vollständig offline nutzbar
|
||||
|
||||
Omnyx ist eine einzige Desktop-App — der Nutzer installiert sie und bekommt ein Fenster mit der Benutzeroberfläche. Backend und Frontend sind in einer Anwendung vereint, **kein externer Server nötig**.
|
||||
|
||||
> ⚠️ **Wichtig:** Es entstehen *zwei* verschiedene `.exe`-Dateien (Tauri-Shell + Backend-Sidecar), aber **der Nutzer startet nur die Omnyx-App**. Das Backend läuft automatisch unsichtbar im Hintergrund.
|
||||
|
||||
### Architektur (Entwickler-Perspektive)
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────┐
|
||||
│ Omnyx Desktop App │
|
||||
│ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ Tauri-WebView │ ← Nutzer sieht │
|
||||
│ │ (React-Frontend) │ DAS │
|
||||
│ │ │ │
|
||||
│ │ API → localhost:3001│ │
|
||||
│ └────────┬────────────┘ │
|
||||
│ │ (unsichtbar) │
|
||||
│ ┌────────▼────────────┐ │
|
||||
│ │ Bun-Sidecar │ ← läuft im │
|
||||
│ │ (Backend, Port 3001)│ Hintergrund │
|
||||
│ │ │ │
|
||||
│ │ SQLite-Datenbank │ │
|
||||
│ │ data/omnyx.db │ │
|
||||
│ └─────────────────────┘ │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Für den **Endnutzer** sieht es so aus: Doppelklick auf "Omnyx" → ein Fenster öffnet sich → fertig.
|
||||
|
||||
### Voraussetzungen (zum Bauen)
|
||||
|
||||
- [Bun](https://bun.sh/) ≥ 1.3 (Backend-Kompilierung)
|
||||
- [Rust](https://rustup.rs/) ≥ 1.77 (Tauri-Build)
|
||||
- Node.js ≥ 18
|
||||
|
||||
### One-Click-Build
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run desktop:build
|
||||
```
|
||||
|
||||
Macht alles automatisch: Backend kompilieren → Binary kopieren → Tauri-App bauen → Installer erzeugen.
|
||||
|
||||
### Schritt für Schritt
|
||||
|
||||
#### 1. Backend-Binary kompilieren
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
bun install
|
||||
bun run compile:tauri
|
||||
```
|
||||
|
||||
Erzeugt `backend/dist-exe/omnyx-backend.exe` (~50 MB Single-File) und kopiert es automatisch in `frontend/src-tauri/binaries/`.
|
||||
|
||||
Der Dateiname muss dem [Tauri-Target-Triple](https://v2.tauri.app/develop/sidecars/#target-triples) entsprechen:
|
||||
- Windows: `omnyx-backend-x86_64-pc-windows-msvc.exe`
|
||||
- Linux: `omnyx-backend-x86_64-unknown-linux-gnu`
|
||||
|
||||
#### 2. Desktop-App bauen (Produktion)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
Erzeugt Installer in `src-tauri/target/release/bundle/`:
|
||||
- `nsis\Omnyx_0.1.0_x64-setup.exe` — Windows-Installer
|
||||
- `msi\Omnyx_0.1.0_x64_en-US.msi` — MSI-Paket
|
||||
|
||||
#### 3. Desktop-App testen (Dev-Modus)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
Startet ein natives Fenster, Vite-Dev-Server (Port 3000) und Backend-Sidecar (Port 3001) — mit Hot-Reload.
|
||||
|
||||
### Konfiguration
|
||||
|
||||
Für Tauri wird automatisch die Datei `.env.tauri` verwendet (via `--mode tauri`):
|
||||
|
||||
```env
|
||||
# Lokaler Backend-Port
|
||||
VITE_API_URL=http://localhost:3001
|
||||
# Externe Importer (optional — nur bei Netzwerk-Zugriff)
|
||||
VITE_XBVR_URL=http://localhost:4080
|
||||
VITE_STASHAPP_URL=http://localhost:10001
|
||||
```
|
||||
|
||||
Alle Datenbanken und Konfigurationen liegen **lokal** — kein Internet oder Server erforderlich.
|
||||
|
||||
## Usage
|
||||
|
||||
### Browsing Media
|
||||
- Navigate between categories using the header navigation
|
||||
- Toggle categories on/off using the category buttons
|
||||
- Search for media using the search bar
|
||||
- Click on any media item to view detailed information
|
||||
|
||||
### Managing Media
|
||||
- **Add Media**: Click the "+" button to add custom media entries
|
||||
- **Import**: Use the import feature to bring in libraries from Playnite, StashAPP, or XBVR
|
||||
- **View Cast**: Browse the cast directory to see all staff across your library
|
||||
|
||||
### Importing from External Sources
|
||||
1. Click the import button in the header
|
||||
2. Select the importer you want to use
|
||||
3. Follow the on-screen instructions to configure and run the import
|
||||
4. Progress will be displayed in real-time
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
- `npm run tauri` - Run Tauri desktop app
|
||||
- `npm run lint` - Run TypeScript type checking
|
||||
- `npm run clean` - Remove build artifacts
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # React components
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ ├── AddMediaView.tsx
|
||||
│ ├── BrowseView.tsx
|
||||
│ ├── CastDetailView.tsx
|
||||
│ ├── CastView.tsx
|
||||
│ ├── DetailView.tsx
|
||||
│ └── ImporterView.tsx
|
||||
├── lib/ # Utility functions
|
||||
│ ├── playniteImporter.ts
|
||||
│ ├── stashappImporter.ts
|
||||
│ └── utils.ts
|
||||
├── App.tsx # Main application component
|
||||
├── api.ts # API client functions
|
||||
├── data.ts # Mock data
|
||||
└── types.ts # TypeScript type definitions
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
For detailed API documentation, see [api.md](api.md).
|
||||
|
||||
## License
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
## Support
|
||||
|
||||
For issues, questions, or contributions, please refer to the project repository.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Jane Smith",
|
||||
"photo": "https://example.com/jane-smith.jpg",
|
||||
"bio": "Adult film actress and model",
|
||||
"birthDate": "1998-03-20",
|
||||
"birthPlace": "Miami, Florida",
|
||||
"occupations": ["Actress", "Model"],
|
||||
"adult_specifics": {
|
||||
"bust_size": "36",
|
||||
"cup_size": "DD",
|
||||
"waist_size": "26",
|
||||
"hip_size": "38",
|
||||
"height": "170",
|
||||
"weight": "58",
|
||||
"hair_color": "Brunette",
|
||||
"eye_color": "Green",
|
||||
"ethnicity": "Latina",
|
||||
"tattoos": "Lower back",
|
||||
"piercings": "None",
|
||||
"measurements": "36-26-38",
|
||||
"shoe_size": "8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Thriller",
|
||||
"year": 1982,
|
||||
"poster": "https://example.com/thriller-cover.jpg",
|
||||
"description": "Sixth studio album by Michael Jackson",
|
||||
"rating": 9.0,
|
||||
"category": "Music",
|
||||
"type": "Album",
|
||||
"status": "Released",
|
||||
"genres": ["Pop", "Funk", "Rock"],
|
||||
"tags": ["Classic", "Best-selling"],
|
||||
"studios": ["Epic Records"],
|
||||
"staff": [],
|
||||
"tracks": [
|
||||
{
|
||||
"track_number": 1,
|
||||
"title": "Wanna Be Startin' Somethin'",
|
||||
"duration": "6:03",
|
||||
"artist": "Michael Jackson"
|
||||
},
|
||||
{
|
||||
"track_number": 2,
|
||||
"title": "Baby Be Mine",
|
||||
"duration": "4:20",
|
||||
"artist": "Michael Jackson"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Tom Hardy",
|
||||
"photo": "https://example.com/tom.jpg",
|
||||
"bio": "English actor known for versatile roles",
|
||||
"birthDate": "1977-09-15",
|
||||
"birthPlace": "Hammersmith, London, England",
|
||||
"occupations": ["Actor", "Producer", "Writer"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"season": 1,
|
||||
"episode_number": 3,
|
||||
"title": "...And the Bag's in the River",
|
||||
"description": "Walter and Jesse deal with the aftermath.",
|
||||
"air_date": "2008-02-03",
|
||||
"duration": 47,
|
||||
"thumbnail": "https://example.com/ep3.jpg"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"title": "The Matrix",
|
||||
"year": 1999,
|
||||
"poster": "https://example.com/matrix-poster.jpg",
|
||||
"banner": "https://example.com/matrix-banner.jpg",
|
||||
"description": "A computer hacker learns about the true nature of reality.",
|
||||
"rating": 8.7,
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"status": "Released",
|
||||
"aspectRatio": "2.39:1",
|
||||
"runtime": 136,
|
||||
"director": "The Wachowskis",
|
||||
"writer": "The Wachowskis",
|
||||
"releaseDate": "1999-03-31",
|
||||
"genres": ["Sci-Fi", "Action"],
|
||||
"tags": ["Cyberpunk", "AI", "Simulation"],
|
||||
"studios": ["Warner Bros."],
|
||||
"staff": [
|
||||
{
|
||||
"name": "Keanu Reeves",
|
||||
"photo": "https://example.com/keanu.jpg",
|
||||
"bio": "Canadian actor",
|
||||
"birthDate": "1964-09-02",
|
||||
"birthPlace": "Beirut, Lebanon",
|
||||
"role": "Actor",
|
||||
"characterName": "Neo",
|
||||
"characterImage": null,
|
||||
"occupations": ["Actor"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"track_number": 3,
|
||||
"title": "On the Run",
|
||||
"duration": "3:35",
|
||||
"artist": "Pink Floyd"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"title": "Stranger Things",
|
||||
"year": 2016,
|
||||
"poster": "https://example.com/st-poster.jpg",
|
||||
"description": "When a young boy disappears, his mother uncovers a mystery.",
|
||||
"rating": 8.7,
|
||||
"category": "TV",
|
||||
"type": "TV",
|
||||
"status": "Ongoing",
|
||||
"runtime": 50,
|
||||
"director": "The Duffer Brothers",
|
||||
"writer": "The Duffer Brothers",
|
||||
"releaseDate": "2016-07-15",
|
||||
"genres": ["Sci-Fi", "Horror", "Drama"],
|
||||
"tags": ["80s", "Supernatural", "Government Conspiracy"],
|
||||
"studios": ["Netflix"],
|
||||
"staff": [],
|
||||
"episodes": [
|
||||
{
|
||||
"season": 1,
|
||||
"episode_number": 1,
|
||||
"title": "Chapter One: The Vanishing of Will Byers",
|
||||
"description": "On his way home from a friend's house, young Will sees something terrifying.",
|
||||
"air_date": "2016-07-15",
|
||||
"duration": 47,
|
||||
"thumbnail": "https://example.com/st-ep1.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Jane Doe",
|
||||
"photo": "https://example.com/jane.jpg",
|
||||
"bio": "Adult film actress",
|
||||
"birthDate": "1995-05-15",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"occupations": ["Actress"],
|
||||
"bust_size": "34",
|
||||
"cup_size": "D",
|
||||
"waist_size": "24",
|
||||
"hip_size": "34",
|
||||
"height": "165",
|
||||
"weight": "52",
|
||||
"hair_color": "Blonde",
|
||||
"eye_color": "Blue",
|
||||
"ethnicity": "Caucasian"
|
||||
}
|
||||
],
|
||||
"total": 25,
|
||||
"page": 1,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 10,
|
||||
"name": "Jane Doe",
|
||||
"photo": "https://example.com/jane.jpg",
|
||||
"bio": "Adult film actress",
|
||||
"birthDate": "1995-05-15",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"occupations": ["Actress"],
|
||||
"filmography": [],
|
||||
"adult_specifics": {
|
||||
"id": 5,
|
||||
"cast_id": 10,
|
||||
"bust_size": "34",
|
||||
"cup_size": "D",
|
||||
"waist_size": "24",
|
||||
"hip_size": "34",
|
||||
"height": "165",
|
||||
"weight": "52",
|
||||
"hair_color": "Blonde",
|
||||
"eye_color": "Blue",
|
||||
"ethnicity": "Caucasian",
|
||||
"tattoos": "None",
|
||||
"piercings": "Ears",
|
||||
"measurements": "34-24-34",
|
||||
"shoe_size": "7"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Leonardo DiCaprio",
|
||||
"photo": "https://example.com/leo.jpg",
|
||||
"bio": "American actor and film producer",
|
||||
"birthDate": "1974-11-11",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00"
|
||||
}
|
||||
],
|
||||
"total": 5,
|
||||
"page": 1,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Dom Cobb"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "The Revenant",
|
||||
"year": 2015,
|
||||
"poster": "https://example.com/revenant.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Hugh Glass"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "Leonardo DiCaprio",
|
||||
"photo": "https://example.com/leo.jpg",
|
||||
"bio": "American actor and film producer",
|
||||
"birthDate": "1974-11-11",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"occupations": ["Actor", "Producer"],
|
||||
"filmography": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Dom Cobb"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "The Revenant",
|
||||
"year": 2015,
|
||||
"poster": "https://example.com/revenant.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Hugh Glass"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"media_id": 2,
|
||||
"season": 1,
|
||||
"episode_number": 1,
|
||||
"title": "Pilot",
|
||||
"description": "Walter White is diagnosed with lung cancer.",
|
||||
"air_date": "2008-01-20",
|
||||
"duration": 49,
|
||||
"thumbnail": "https://example.com/ep1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"media_id": 2,
|
||||
"season": 1,
|
||||
"episode_number": 2,
|
||||
"title": "Cat's in the Bag...",
|
||||
"description": "Walter and Jesse attempt to dispose of the body.",
|
||||
"air_date": "2008-01-27",
|
||||
"duration": 48,
|
||||
"thumbnail": "https://example.com/ep2.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"banner": null,
|
||||
"description": "A thief who steals corporate secrets through dream-sharing technology.",
|
||||
"rating": 8.8,
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"status": "Released",
|
||||
"aspectRatio": "2.39:1",
|
||||
"runtime": 148,
|
||||
"director": "Christopher Nolan",
|
||||
"writer": "Christopher Nolan",
|
||||
"releaseDate": "2010-07-16",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00"
|
||||
}
|
||||
],
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"totalPages": 15
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"banner": null,
|
||||
"description": "A thief who steals corporate secrets through dream-sharing technology.",
|
||||
"rating": 8.8,
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"status": "Released",
|
||||
"aspectRatio": "2.39:1",
|
||||
"runtime": 148,
|
||||
"director": "Christopher Nolan",
|
||||
"writer": "Christopher Nolan",
|
||||
"releaseDate": "2010-07-16",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"genres": ["Sci-Fi", "Action", "Thriller"],
|
||||
"tags": ["Mind-bending", "Dream", "Heist"],
|
||||
"studios": ["Warner Bros.", "Legendary Pictures"],
|
||||
"staff": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Leonardo DiCaprio",
|
||||
"photo": "https://example.com/leo.jpg",
|
||||
"bio": "American actor and film producer",
|
||||
"birthDate": "1974-11-11",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"role": "Actor",
|
||||
"characterName": "Dom Cobb",
|
||||
"characterImage": null,
|
||||
"occupations": ["Actor", "Producer"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"media_id": 3,
|
||||
"track_number": 1,
|
||||
"title": "Speak to Me",
|
||||
"duration": "1:30",
|
||||
"artist": "Pink Floyd"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"media_id": 3,
|
||||
"track_number": 2,
|
||||
"title": "Breathe",
|
||||
"duration": "2:43",
|
||||
"artist": "Pink Floyd"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Jane Smith (Updated)",
|
||||
"bio": "Updated bio",
|
||||
"adult_specifics": {
|
||||
"hair_color": "Red",
|
||||
"weight": "56"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Tom Hardy (Updated)",
|
||||
"bio": "Updated bio description"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Updated Episode Title",
|
||||
"description": "Updated description"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"type": "Game",
|
||||
"title": "1-2-Switch",
|
||||
"playtime": 120,
|
||||
"completionStatus": "Completed",
|
||||
"favorite": true,
|
||||
"communityScore": 55,
|
||||
"userScore": 80,
|
||||
"achievements": [
|
||||
{
|
||||
"name": "First Victory",
|
||||
"description": "Win your first game",
|
||||
"icon": "https://example.com/achievement-icon.png",
|
||||
"unlocked": true,
|
||||
"unlocked_date": "2026-04-09T18:00:00"
|
||||
},
|
||||
{
|
||||
"name": "Master Player",
|
||||
"description": "Win 100 games",
|
||||
"icon": "https://example.com/master-icon.png",
|
||||
"unlocked": true,
|
||||
"unlocked_date": "2026-04-09T20:30:00"
|
||||
},
|
||||
{
|
||||
"name": "Champion",
|
||||
"description": "Win 1000 games",
|
||||
"icon": "https://example.com/champion-icon.png",
|
||||
"unlocked": false,
|
||||
"unlocked_date": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "The Matrix (Updated)",
|
||||
"rating": 8.8,
|
||||
"status": "Released"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Updated Track Title",
|
||||
"duration": "4:00"
|
||||
}
|
||||
@@ -21,5 +21,7 @@
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
"registries": {
|
||||
"@acme": "https://acme.com/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 296 KiB |
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Google AI Studio App</title>
|
||||
<title>Omnyx - Media Discovery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Kyoo - Media Discovery",
|
||||
"name": "Omnyx - Media Discovery",
|
||||
"description": "A polished media discovery and tracking application inspired by modern anime platforms.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
|
||||
@@ -8,13 +8,21 @@
|
||||
"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",
|
||||
"tauri": "tauri",
|
||||
"desktop:build": "bun scripts/build-desktop.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@google/genai": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -24,18 +32,25 @@
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"shadcn": "^4.2.0",
|
||||
"react-router-dom": "^7.14.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": {
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { execSync } from "child_process";
|
||||
import { resolve } from "path";
|
||||
|
||||
const backendDir = resolve(import.meta.dirname, "../../backend");
|
||||
const frontendDir = resolve(import.meta.dirname, "..");
|
||||
|
||||
console.log("\n=== 1/2: Backend kompilieren ===\n");
|
||||
execSync("bun run compile:tauri", { cwd: backendDir, stdio: "inherit" });
|
||||
|
||||
console.log("\n=== 2/2: Tauri Desktop-App bauen ===\n");
|
||||
execSync("npx tauri build", { cwd: frontendDir, stdio: "inherit" });
|
||||
|
||||
console.log("\n✅ Fertig! Installer liegt in src-tauri/target/release/bundle/\n");
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-spawn",
|
||||
"shell:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,111 @@
|
||||
use std::process::{Command, Stdio};
|
||||
use std::io::Read;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tauri::Manager;
|
||||
|
||||
fn find_backend() -> Result<std::path::PathBuf, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| format!("current_exe: {}", e))?;
|
||||
let exe_dir = exe.parent().ok_or("no parent dir")?;
|
||||
|
||||
let target_name = "omnyx-backend";
|
||||
let triple_name = format!("{}-x86_64-pc-windows-msvc.exe", target_name);
|
||||
let short_name = format!("{}.exe", target_name);
|
||||
|
||||
// Try paths relative to the executable
|
||||
let candidates = [
|
||||
// Dev: app.exe is in src-tauri/target/debug/, binary in src-tauri/binaries/
|
||||
exe_dir.parent().and_then(|p| p.parent()).and_then(|p| p.parent()).map(|p| p.join("binaries").join(&triple_name)),
|
||||
// Dev: via src-tauri/target/
|
||||
exe_dir.parent().and_then(|p| p.parent()).map(|p| p.join("binaries").join(&triple_name)),
|
||||
// Production: binary renamed by Tauri (no triple)
|
||||
Some(exe_dir.join(&short_name)),
|
||||
// Production: binary with triple in same dir
|
||||
Some(exe_dir.join(&triple_name)),
|
||||
// Production: binary in ./binaries/ relative to app.exe
|
||||
Some(exe_dir.join("binaries").join(&triple_name)),
|
||||
Some(exe_dir.join("binaries").join(&short_name)),
|
||||
];
|
||||
|
||||
for path in candidates.iter().flatten() {
|
||||
if path.exists() {
|
||||
return Ok(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Backend binary not found (searched from {:?})", exe))
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)
|
||||
.setup(|app| {
|
||||
let exe_dir = app.path().resource_dir().unwrap_or_else(|_| std::env::current_exe().map(|p| p.parent().unwrap().to_path_buf()).unwrap_or_default());
|
||||
let exe_path_str = exe_dir.to_string_lossy().to_lowercase();
|
||||
|
||||
// Portable mode: nur wenn auf Laufwerk C: UND in Program Files → APPDATA
|
||||
// Alles andere (D:, E:, USB, etc.) → portable (data neben der Exe)
|
||||
let on_c_drive = exe_path_str.starts_with("c:");
|
||||
let in_program_files = exe_path_str.contains("program files") || exe_path_str.contains("programme");
|
||||
let data_dir = if on_c_drive && in_program_files {
|
||||
std::env::var("APPDATA")
|
||||
.map(|a| format!("{}\\Omnyx", a))
|
||||
.unwrap_or_else(|_| format!("{}\\data", exe_dir.to_string_lossy()))
|
||||
} else {
|
||||
format!("{}\\data", exe_dir.to_string_lossy())
|
||||
};
|
||||
|
||||
match find_backend() {
|
||||
Ok(bin_path) => {
|
||||
log::info!("Starting backend: {:?}", bin_path);
|
||||
log::info!("Data dir: {}", data_dir);
|
||||
|
||||
match Command::new(&bin_path)
|
||||
.env("DATA_DIR", &data_dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => {
|
||||
log::info!("Backend started (PID: {})", child.id());
|
||||
|
||||
let mut stdout = child.stdout.unwrap();
|
||||
let mut stderr = child.stderr.unwrap();
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0u8; 4096];
|
||||
while let Ok(n) = stdout.read(&mut buf) {
|
||||
if n == 0 { break; }
|
||||
log::info!("[backend] {}", String::from_utf8_lossy(&buf[..n]).trim_end());
|
||||
}
|
||||
});
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0u8; 4096];
|
||||
while let Ok(n) = stderr.read(&mut buf) {
|
||||
if n == 0 { break; }
|
||||
log::warn!("[backend:err] {}", String::from_utf8_lossy(&buf[..n]).trim_end());
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to spawn {:?}: {}", bin_path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Omnyx",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.omnyx.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:3000",
|
||||
"beforeDevCommand": "npm run dev -- --mode tauri",
|
||||
"beforeBuildCommand": "npm run build -- --mode tauri"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Omnyx - Media Discovery",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"externalBin": [
|
||||
"binaries/omnyx-backend"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,98 +5,318 @@
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { LayoutGroup } from 'motion/react';
|
||||
import Header from './components/Header';
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
|
||||
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 } from './types';
|
||||
import { fetchMediaFromLocalJson, fetchMediaById } from './api';
|
||||
import { Media, Staff, MediaCategory, UserSettings } from './types';
|
||||
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
|
||||
import { waitForBackend } from './lib/api/waitForBackend';
|
||||
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';
|
||||
|
||||
export default function App() {
|
||||
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail'>('browse');
|
||||
const [activeCategory, setActiveCategory] = useState<MediaCategory>('Anime');
|
||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
||||
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
||||
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
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();
|
||||
|
||||
// Load adult media on component mount
|
||||
|
||||
// Set category from URL path on mount or location change
|
||||
useEffect(() => {
|
||||
const loadAdultMedia = async () => {
|
||||
const pathParts = location.pathname.split('/').filter(Boolean);
|
||||
if (pathParts.length === 1 && PATH_TO_CATEGORY[pathParts[0]]) {
|
||||
const category = PATH_TO_CATEGORY[pathParts[0]];
|
||||
if (enabledCategories.includes(category)) {
|
||||
setActiveCategory(category);
|
||||
}
|
||||
}
|
||||
}, [location.pathname, enabledCategories, setActiveCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettingsFromApi = async () => {
|
||||
try {
|
||||
const media = await fetchMediaFromLocalJson();
|
||||
// Add category to adult media
|
||||
const categorizedMedia = media.map(m => ({ ...m, category: 'Adult' as MediaCategory }));
|
||||
setAdultMedia(categorizedMedia);
|
||||
const loadedSettings = await fetchSettings();
|
||||
if (loadedSettings) {
|
||||
setSettings(loadedSettings);
|
||||
setEnabledCategories(loadedSettings.enabledCategories);
|
||||
// Sync theme with theme context
|
||||
setTheme(loadedSettings.theme);
|
||||
|
||||
// Set custom page title
|
||||
if (loadedSettings.pageTitle) {
|
||||
document.title = loadedSettings.pageTitle;
|
||||
}
|
||||
|
||||
// Set custom favicon
|
||||
if (loadedSettings.favicon) {
|
||||
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
if (!faviconLink) {
|
||||
faviconLink = document.createElement('link');
|
||||
faviconLink.rel = 'icon';
|
||||
document.head.appendChild(faviconLink);
|
||||
}
|
||||
faviconLink.href = loadedSettings.favicon;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load adult media:', error);
|
||||
console.error('Failed to load settings from API:', error);
|
||||
}
|
||||
};
|
||||
loadAdultMedia();
|
||||
}, []);
|
||||
|
||||
const toggleCategory = (category: MediaCategory) => {
|
||||
setEnabledCategories(prev => {
|
||||
const isEnabling = !prev.includes(category);
|
||||
const newList = isEnabling
|
||||
? [...prev, category]
|
||||
: prev.filter(c => c !== category);
|
||||
loadSettingsFromApi();
|
||||
}, [setTheme]);
|
||||
|
||||
// Apply custom colors when settings change
|
||||
useEffect(() => {
|
||||
if (settings?.customColors) {
|
||||
const root = document.documentElement;
|
||||
const colors = settings.customColors;
|
||||
|
||||
// If we disable the current active category, switch to another enabled one
|
||||
if (!isEnabling && activeCategory === category) {
|
||||
const nextCategory = newList.find(c => c !== category) || 'Anime';
|
||||
setActiveCategory(nextCategory as MediaCategory);
|
||||
if (colors.primary) root.style.setProperty('--color-primary', colors.primary);
|
||||
if (colors.secondary) root.style.setProperty('--color-secondary', colors.secondary);
|
||||
if (colors.background) root.style.setProperty('--color-background', colors.background);
|
||||
if (colors.surface) root.style.setProperty('--color-surface', colors.surface);
|
||||
if (colors.text) root.style.setProperty('--color-text', colors.text);
|
||||
if (colors.muted) root.style.setProperty('--color-muted', colors.muted);
|
||||
if (colors.border) root.style.setProperty('--color-border', colors.border);
|
||||
}
|
||||
}, [settings?.customColors]);
|
||||
|
||||
const reloadSettings = async () => {
|
||||
try {
|
||||
const loadedSettings = await fetchSettings();
|
||||
if (loadedSettings) {
|
||||
setSettings(loadedSettings);
|
||||
setEnabledCategories(loadedSettings.enabledCategories);
|
||||
// Sync theme with theme context
|
||||
setTheme(loadedSettings.theme);
|
||||
|
||||
// Set custom page title
|
||||
if (loadedSettings.pageTitle) {
|
||||
document.title = loadedSettings.pageTitle;
|
||||
}
|
||||
|
||||
// Set custom favicon
|
||||
if (loadedSettings.favicon) {
|
||||
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
if (!faviconLink) {
|
||||
faviconLink = document.createElement('link');
|
||||
faviconLink.rel = 'icon';
|
||||
document.head.appendChild(faviconLink);
|
||||
}
|
||||
faviconLink.href = loadedSettings.favicon;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reload settings from API:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadMediaFromApi = async () => {
|
||||
setMediaLoading(true);
|
||||
try {
|
||||
const media = await fetchAllMedia();
|
||||
setApiMedia(media);
|
||||
} catch (error) {
|
||||
console.error('Failed to load media from API:', error);
|
||||
} finally {
|
||||
setMediaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Only load media if not on cast routes
|
||||
if (!location.pathname.startsWith('/cast')) {
|
||||
loadMediaFromApi();
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggleCategory = async (category: MediaCategory) => {
|
||||
const isEnabling = !enabledCategories.includes(category);
|
||||
const newList = isEnabling
|
||||
? [...enabledCategories, category]
|
||||
: enabledCategories.filter(c => c !== category);
|
||||
|
||||
// If we disable the current active category, switch to another enabled one
|
||||
if (!isEnabling && activeCategory === category) {
|
||||
const nextCategory = newList.find(c => c !== category) || 'Anime';
|
||||
setActiveCategory(nextCategory as MediaCategory);
|
||||
}
|
||||
|
||||
setEnabledCategories(newList);
|
||||
|
||||
// Save to API
|
||||
const baseSettings = settings || DEFAULT_SETTINGS;
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
enabledCategories: newList,
|
||||
};
|
||||
updateSettings(updatedSettings).then(saved => {
|
||||
if (saved) {
|
||||
setSettings(saved);
|
||||
}
|
||||
return newList;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: MediaCategory) => {
|
||||
setActiveCategory(category);
|
||||
setCurrentView('browse');
|
||||
navigate(`/${CATEGORY_PATHS[category]}`);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const allMedia = useMemo(() => {
|
||||
// Merge mock media, adult media, detail media and custom media
|
||||
const list = [...MOCK_MEDIA, ...adultMedia, ...customMedia];
|
||||
const handleAddMediaView = () => {
|
||||
navigate('/add');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleImporterView = () => {
|
||||
navigate('/import');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// 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[] = [];
|
||||
|
||||
if (apiMedia.length > 0) {
|
||||
// API has data, use it
|
||||
list = [...apiMedia];
|
||||
} else {
|
||||
// API is empty, use mock data as fallback
|
||||
list = [...MOCK_MEDIA];
|
||||
}
|
||||
|
||||
// Add custom media and detail media
|
||||
list = [...list, ...customMedia];
|
||||
if (!list.find(m => m.id === DETAIL_MEDIA.id)) {
|
||||
list.push(DETAIL_MEDIA);
|
||||
}
|
||||
// Filter by active category AND ensure it's enabled
|
||||
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
|
||||
}, [activeCategory, enabledCategories, customMedia, adultMedia]);
|
||||
|
||||
// Filter by enabled categories only (all enabled categories, not just active)
|
||||
return list.filter(m => enabledCategories.includes(m.category));
|
||||
}, [enabledCategories, customMedia, apiMedia]);
|
||||
|
||||
const handleAddMedia = (newMedia: Media) => {
|
||||
setCustomMedia(prev => [...prev, newMedia]);
|
||||
const allMedia = useMemo(() => {
|
||||
// Filter by active category AND ensure it's enabled
|
||||
return allEnabledMedia.filter(m => m.category === activeCategory);
|
||||
}, [activeCategory, allEnabledMedia]);
|
||||
|
||||
const handleAddMedia = async () => {
|
||||
// Reload all media from API to get the newly added item
|
||||
try {
|
||||
const media = await fetchAllMedia();
|
||||
setApiMedia(media);
|
||||
} catch (error) {
|
||||
console.error('Failed to reload media from API:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGridItemSizeChange = async (size: number) => {
|
||||
const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories };
|
||||
const updatedSettings: UserSettings = {
|
||||
...baseSettings,
|
||||
gridItemSize: size,
|
||||
};
|
||||
updateSettings(updatedSettings).then(saved => {
|
||||
if (saved) {
|
||||
setSettings(saved);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const allStaff = useMemo(() => {
|
||||
const staff: Staff[] = [];
|
||||
// Use all available media (mock + adult + custom + detail) but filter by enabled categories
|
||||
const baseList = [...MOCK_MEDIA, ...adultMedia, ...customMedia];
|
||||
if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) {
|
||||
baseList.push(DETAIL_MEDIA);
|
||||
}
|
||||
const staffIds = new Set<string>(); // Track unique staff to avoid duplicates
|
||||
|
||||
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, adultMedia]);
|
||||
}, [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();
|
||||
@@ -127,17 +347,17 @@ export default function App() {
|
||||
// For non-adult media, use the original media
|
||||
setSelectedMedia(media);
|
||||
}
|
||||
setCurrentView('detail');
|
||||
navigate(`/media/${media.id}`);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentView('browse');
|
||||
navigate('/');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleCastClick = () => {
|
||||
setCurrentView('cast');
|
||||
navigate('/cast');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
@@ -151,85 +371,254 @@ export default function App() {
|
||||
occupations: ['Voice Actor', 'Singer', 'Narrator']
|
||||
};
|
||||
setSelectedPerson(enrichedPerson);
|
||||
setCurrentView('castDetail');
|
||||
navigate(`/cast/${person.id}`);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
if (currentView !== 'browse' && currentView !== 'cast') {
|
||||
setCurrentView('browse');
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (query) {
|
||||
params.set('search', query);
|
||||
} else {
|
||||
params.delete('search');
|
||||
}
|
||||
setSearchParams(params);
|
||||
navigate('/browse');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
|
||||
<Header
|
||||
onBrowse={handleBack}
|
||||
onCast={handleCastClick}
|
||||
onSearch={handleSearch}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
enabledCategories={enabledCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
transparent={currentView === 'detail' || currentView === 'castDetail'}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<LayoutGroup>
|
||||
{currentView === 'browse' ? (
|
||||
<BrowseView
|
||||
mediaList={filteredMedia}
|
||||
onMediaClick={handleMediaClick}
|
||||
onAddMedia={handleAddMedia}
|
||||
activeCategory={activeCategory}
|
||||
/>
|
||||
) : currentView === 'cast' ? (
|
||||
<CastView
|
||||
staffList={allStaff}
|
||||
onPersonClick={handlePersonClick}
|
||||
/>
|
||||
) : currentView === 'castDetail' ? (
|
||||
selectedPerson && (
|
||||
<CastDetailView
|
||||
person={selectedPerson}
|
||||
onBack={handleCastClick}
|
||||
onMediaClick={(id) => {
|
||||
const media = allMedia.find(m => m.id === id);
|
||||
if (media) handleMediaClick(media);
|
||||
}}
|
||||
relatedMedia={allMedia.filter(m => m.staff?.some(s => s.id === selectedPerson.id))}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
selectedMedia && (
|
||||
<DetailView
|
||||
media={selectedMedia}
|
||||
onBack={handleBack}
|
||||
onPersonClick={handlePersonClick}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [backendReady, setBackendReady] = useState(false);
|
||||
const [backendFailed, setBackendFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
waitForBackend().then(ready => {
|
||||
if (ready) setBackendReady(true);
|
||||
else setBackendFailed(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (backendFailed) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-16 h-16 mx-auto rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<span className="text-2xl">!</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground">Backend nicht erreichbar</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Der Backend-Dienst auf Port 3001 antwortet nicht. Bitte starten Sie die App neu.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setBackendFailed(false); waitForBackend().then(r => r ? setBackendReady(true) : setBackendFailed(true)); }}
|
||||
className="px-4 py-2 bg-[#e8466c] text-white rounded-lg hover:bg-[#d13d60]"
|
||||
>
|
||||
Neu verbinden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!backendReady) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-12 h-12 border-4 border-[#e8466c]/30 border-t-[#e8466c] rounded-full animate-spin mx-auto" />
|
||||
<p className="text-muted-foreground">Backend wird gestartet...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,290 +1,49 @@
|
||||
import { Media, Staff } from './types';
|
||||
// Re-export all API functions for backward compatibility
|
||||
export * from './lib/api/mediaApi';
|
||||
export * from './lib/api/castApi';
|
||||
export * from './lib/api/settingsApi';
|
||||
export * from './lib/api/converters';
|
||||
export * from './lib/api/types';
|
||||
|
||||
const BASE_URL = 'http://192.168.1.102:57000';
|
||||
|
||||
function normalizeUrl(url: string | null): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
// Remove leading slash if present and add base URL
|
||||
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
|
||||
return `${BASE_URL}/${cleanPath}`;
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
items: ApiMediaItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
overview: string;
|
||||
poster_url: string;
|
||||
poster_aspect_ratio: string | null;
|
||||
backdrop_url: string | null;
|
||||
backdrop_aspect_ratio: string | null;
|
||||
rating: string;
|
||||
runtime_minutes: number;
|
||||
release_date: string;
|
||||
director: string | null;
|
||||
writer: string | null;
|
||||
cast: string | null;
|
||||
genre: string | null;
|
||||
metadata: string;
|
||||
actors?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail_path: string | null;
|
||||
metadata?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ApiMetadata {
|
||||
xbvr_id: number;
|
||||
xbvr_url: string | null;
|
||||
cast: string[];
|
||||
actors: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail_path: string | null;
|
||||
}>;
|
||||
tags: string[];
|
||||
is_available: boolean;
|
||||
is_watched: boolean;
|
||||
watch_count: number;
|
||||
video_length: number;
|
||||
video_width: number | null;
|
||||
video_height: number | null;
|
||||
video_codec: string | null;
|
||||
file_path: string | null;
|
||||
cover_url: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
let metadata: ApiMetadata;
|
||||
// Legacy functions for compatibility
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
try {
|
||||
metadata = JSON.parse(apiItem.metadata);
|
||||
} catch (e) {
|
||||
metadata = {
|
||||
xbvr_id: 0,
|
||||
xbvr_url: null,
|
||||
cast: [],
|
||||
actors: [],
|
||||
tags: [],
|
||||
is_available: false,
|
||||
is_watched: false,
|
||||
watch_count: 0,
|
||||
video_length: 0,
|
||||
video_width: null,
|
||||
video_height: null,
|
||||
video_codec: null,
|
||||
file_path: null,
|
||||
cover_url: apiItem.poster_url,
|
||||
};
|
||||
}
|
||||
|
||||
// Use actors from the main item if available, otherwise from metadata
|
||||
const actors = apiItem.actors || metadata.actors || [];
|
||||
const staff: Staff[] = actors.map((actor, index) => ({
|
||||
id: `actor-${actor.id}`,
|
||||
name: actor.name,
|
||||
role: 'Actor',
|
||||
photo: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
|
||||
characterName: actor.name,
|
||||
characterImage: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
|
||||
}));
|
||||
|
||||
|
||||
// Determine aspect ratio from poster_aspect_ratio or default to 2/3
|
||||
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
if (apiItem.poster_aspect_ratio) {
|
||||
const ratio = apiItem.poster_aspect_ratio.toLowerCase();
|
||||
if (ratio.includes('16:9') || ratio.includes('1.78')) {
|
||||
aspectRatio = '16/9';
|
||||
} else if (ratio.includes('1:1') || ratio.includes('1.00')) {
|
||||
aspectRatio = '1/1';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiItem.id.toString() || undefined,
|
||||
title: apiItem.title || undefined,
|
||||
year: apiItem.release_date ? new Date(apiItem.release_date).getFullYear().toString() : 'Unknown',
|
||||
poster: normalizeUrl(apiItem.poster_url) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
|
||||
banner: normalizeUrl(apiItem.backdrop_url) || undefined,
|
||||
description: apiItem.overview || undefined,
|
||||
rating: apiItem.rating ? parseFloat(apiItem.rating) : undefined,
|
||||
genres: metadata.tags || [],
|
||||
tags: metadata.tags || [],
|
||||
studios: apiItem.director ? [apiItem.director] : undefined,
|
||||
type: 'Movie',
|
||||
status: 'completed',
|
||||
staff: staff.length > 0 ? staff : undefined,
|
||||
runtime: apiItem.runtime_minutes,
|
||||
director: apiItem.director || undefined,
|
||||
writer: apiItem.writer || undefined,
|
||||
releaseDate: apiItem.release_date || undefined,
|
||||
aspectRatio: aspectRatio
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMediaFromApi(apiUrl: string = `${BASE_URL}/api/adult`): Promise<Media[]> {
|
||||
console.error('Error fetching');
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
const tagSet = new Set<string>();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
media.forEach(item => {
|
||||
item.tags?.forEach(tag => tagSet.add(tag));
|
||||
item.genres?.forEach(genre => tagSet.add(genre));
|
||||
});
|
||||
|
||||
return Array.from(tagSet).sort();
|
||||
} catch (error) {
|
||||
console.error('Error fetching media from API:', error);
|
||||
console.error('Error fetching all tags:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
|
||||
export async function fetchMediaByTag(tag: string) {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching media from local JSON:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaById(id: number): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
const item = data.data.items.find(item => item.id === id);
|
||||
return item ? convertApiToMedia(item) : null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
return data.data.items
|
||||
.filter(item => item.actors?.some(actor => actor.name.toLowerCase().includes(actorName.toLowerCase())))
|
||||
.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by actor:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
return data.data.items
|
||||
.filter(item => {
|
||||
try {
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
return metadata.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase()));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
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())) ||
|
||||
item.genres?.some(g => g.toLowerCase().includes(tag.toLowerCase()))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by tag:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllActors(): Promise<Array<{id: number, name: string, thumbnail_path: string | null}>> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
const actorMap = new Map();
|
||||
data.data.items.forEach(item => {
|
||||
item.actors?.forEach(actor => {
|
||||
if (!actorMap.has(actor.id)) {
|
||||
actorMap.set(actor.id, {
|
||||
id: actor.id,
|
||||
name: actor.name,
|
||||
thumbnail_path: actor.thumbnail_path
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(actorMap.values());
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching all actors:', error);
|
||||
return [];
|
||||
}
|
||||
export async function fetchMediaFromApi(apiUrl?: string) {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
const tagSet = new Set<string>();
|
||||
data.data.items.forEach(item => {
|
||||
try {
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
metadata.tags?.forEach((tag: string) => tagSet.add(tag));
|
||||
} catch {
|
||||
// Ignore metadata parsing errors
|
||||
}
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching all tags:', error);
|
||||
return [];
|
||||
}
|
||||
export async function fetchMediaFromLocalJson() {
|
||||
const { fetchAllMedia } = await import('./lib/api/mediaApi');
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
import { MediaCategory } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createMedia, type CreateMediaInput } from '@/api';
|
||||
import { ArrowLeft, Film, Calendar, Star, User, BookOpen, Music as MusicIcon, Gamepad2, Monitor, Hash, Tag, Users, FileText, Globe, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AddMediaViewProps {
|
||||
activeCategory: MediaCategory;
|
||||
enabledCategories: MediaCategory[];
|
||||
onAddComplete: () => void;
|
||||
}
|
||||
|
||||
export default function AddMediaView({ activeCategory, enabledCategories, onAddComplete }: AddMediaViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [newMedia, setNewMedia] = useState({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
banner: '',
|
||||
description: '',
|
||||
rating: '',
|
||||
category: activeCategory,
|
||||
type: 'Movie' as string,
|
||||
status: 'Released' as string,
|
||||
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1',
|
||||
runtime: '',
|
||||
director: '',
|
||||
writer: '',
|
||||
releaseDate: '',
|
||||
source: '' as string,
|
||||
genres: '' as string,
|
||||
tags: '' as string,
|
||||
studios: '' as string
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [staff, setStaff] = useState<Array<{ name: string; role: string; characterName?: string; photo?: string }>>([]);
|
||||
|
||||
const addStaffMember = () => {
|
||||
const nameInput = document.getElementById('staffName') as HTMLInputElement;
|
||||
const roleInput = document.getElementById('staffRole') as HTMLInputElement;
|
||||
const characterInput = document.getElementById('staffCharacter') as HTMLInputElement;
|
||||
const photoInput = document.getElementById('staffPhoto') as HTMLInputElement;
|
||||
|
||||
if (nameInput?.value && roleInput?.value) {
|
||||
setStaff(prev => [...prev, {
|
||||
name: nameInput.value,
|
||||
role: roleInput.value,
|
||||
characterName: characterInput?.value || undefined,
|
||||
photo: photoInput?.value || undefined
|
||||
}]);
|
||||
|
||||
// Clear the form
|
||||
if (nameInput) nameInput.value = '';
|
||||
if (roleInput) roleInput.value = '';
|
||||
if (characterInput) characterInput.value = '';
|
||||
if (photoInput) photoInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Update category, default aspect ratio, and default type when activeCategory changes
|
||||
useEffect(() => {
|
||||
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
let defaultType = 'Movie';
|
||||
|
||||
if (activeCategory === 'Music') {
|
||||
defaultAspect = '1/1';
|
||||
defaultType = 'Album';
|
||||
} else if (activeCategory === 'Games') {
|
||||
defaultAspect = '16/9';
|
||||
defaultType = 'Game';
|
||||
} else if (activeCategory === 'Adult') {
|
||||
defaultAspect = '16/9';
|
||||
defaultType = 'Movie';
|
||||
} else if (activeCategory === 'Anime') {
|
||||
defaultType = 'TV';
|
||||
} else if (activeCategory === 'TV Series') {
|
||||
defaultType = 'TV';
|
||||
} else if (activeCategory === 'Books') {
|
||||
defaultType = 'Hardcover';
|
||||
} else if (activeCategory === 'Consoles') {
|
||||
defaultType = 'Console';
|
||||
}
|
||||
|
||||
setNewMedia(prev => ({
|
||||
...prev,
|
||||
category: activeCategory,
|
||||
aspectRatio: defaultAspect,
|
||||
type: defaultType
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMedia.title || !newMedia.poster) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitStatus('idle');
|
||||
setErrorMessage('');
|
||||
|
||||
// Convert category from plural to singular to match API format
|
||||
const categoryMap: Record<string, string> = {
|
||||
'Anime': 'Anime',
|
||||
'Movies': 'Movie',
|
||||
'TV Series': 'TV',
|
||||
'Music': 'Music',
|
||||
'Books': 'Book',
|
||||
'Consoles': 'Console',
|
||||
'Games': 'Game',
|
||||
'Adult': 'Adult'
|
||||
};
|
||||
|
||||
const mediaInput: CreateMediaInput = {
|
||||
title: newMedia.title,
|
||||
year: parseInt(newMedia.year) || new Date().getFullYear(),
|
||||
poster: newMedia.poster,
|
||||
banner: newMedia.banner || null,
|
||||
description: newMedia.description || null,
|
||||
rating: newMedia.rating ? parseFloat(newMedia.rating) : null,
|
||||
category: categoryMap[newMedia.category] || newMedia.category,
|
||||
type: newMedia.type,
|
||||
status: newMedia.status,
|
||||
aspectRatio: newMedia.aspectRatio,
|
||||
runtime: newMedia.runtime ? parseInt(newMedia.runtime) : null,
|
||||
director: newMedia.director || null,
|
||||
writer: newMedia.writer || null,
|
||||
releaseDate: newMedia.releaseDate || null,
|
||||
source: newMedia.source || null,
|
||||
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
|
||||
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
|
||||
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : [],
|
||||
staff: staff.length > 0 ? staff.map(s => ({
|
||||
name: s.name,
|
||||
role: s.role,
|
||||
characterName: s.characterName || null,
|
||||
photo: s.photo || null
|
||||
})) : undefined
|
||||
};
|
||||
|
||||
try {
|
||||
const createdMedia = await createMedia(mediaInput);
|
||||
|
||||
if (createdMedia) {
|
||||
setSubmitStatus('success');
|
||||
onAddComplete();
|
||||
|
||||
// Reset form after successful submission
|
||||
setNewMedia({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
banner: '',
|
||||
description: '',
|
||||
rating: '',
|
||||
category: activeCategory,
|
||||
type: 'Movie',
|
||||
status: 'Released',
|
||||
aspectRatio: '2/3',
|
||||
runtime: '',
|
||||
director: '',
|
||||
writer: '',
|
||||
releaseDate: '',
|
||||
source: '',
|
||||
genres: '',
|
||||
tags: '',
|
||||
studios: ''
|
||||
});
|
||||
setStaff([]);
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to add media. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: MediaCategory) => {
|
||||
const icons: Record<MediaCategory, any> = {
|
||||
'Anime': <Film size={18} />,
|
||||
'Movies': <Film size={18} />,
|
||||
'TV Series': <Film size={18} />,
|
||||
'Music': <MusicIcon size={18} />,
|
||||
'Books': <BookOpen size={18} />,
|
||||
'Games': <Gamepad2 size={18} />,
|
||||
'Consoles': <Monitor size={18} />,
|
||||
'Adult': <Star size={18} />
|
||||
};
|
||||
return icons[category] || <Film size={18} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/')}
|
||||
className="mb-6 gap-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-xl transition-all duration-300"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Back to Browse
|
||||
</Button>
|
||||
|
||||
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#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-500/10 border border-green-500/30 rounded-xl backdrop-blur-sm">
|
||||
<p className="text-green-500 font-bold">✓ Successfully added to library!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitStatus === 'error' && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl backdrop-blur-sm">
|
||||
<p className="text-red-500 font-bold">✗ Error: {errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Info Card */}
|
||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newMedia.title}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="e.g. Mob Psycho 100"
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#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>
|
||||
</>
|
||||
) : newMedia.category === 'Books' ? (
|
||||
<>
|
||||
<option value="Hardcover">Hardcover</option>
|
||||
<option value="E-book">E-book</option>
|
||||
</>
|
||||
) : newMedia.category === 'Games' ? (
|
||||
<>
|
||||
<option value="Game">Game</option>
|
||||
</>
|
||||
) : newMedia.category === 'Consoles' ? (
|
||||
<>
|
||||
<option value="Console">Console</option>
|
||||
</>
|
||||
) : newMedia.category === 'TV Series' || newMedia.category === 'Anime' ? (
|
||||
<>
|
||||
<option value="TV">TV</option>
|
||||
<option value="OVA">OVA</option>
|
||||
<option value="ONA">ONA</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="Movie">Movie</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status" className="text-xs font-black text-muted-foreground uppercase tracking-wider">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
value={newMedia.status}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#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>
|
||||
|
||||
{/* 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="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-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-xs font-black text-muted-foreground uppercase tracking-wider">Release Date</Label>
|
||||
<Input
|
||||
id="releaseDate"
|
||||
type="date"
|
||||
value={newMedia.releaseDate}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
|
||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#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>
|
||||
)}
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,419 +1,468 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { Media, MediaCategory, Staff } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import MediaListItem from './MediaListItem';
|
||||
import { Filter, LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Plus } 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination';
|
||||
|
||||
interface BrowseViewProps {
|
||||
mediaList: Media[];
|
||||
onMediaClick: (media: Media) => void;
|
||||
onAddMedia: (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, onAddMedia, activeCategory }: 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(12);
|
||||
const [sortBy, setSortBy] = useState<string>('default');
|
||||
|
||||
// Add Media Dialog State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [newMedia, setNewMedia] = useState({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: activeCategory as MediaCategory,
|
||||
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1'
|
||||
});
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
|
||||
|
||||
// Update category and default aspect ratio when activeCategory changes
|
||||
// Sync itemsPerPage with prop when API settings are loaded
|
||||
useEffect(() => {
|
||||
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
if (activeCategory === 'Music') defaultAspect = '1/1';
|
||||
if (activeCategory === 'Games' || activeCategory === 'Adult') defaultAspect = '16/9';
|
||||
|
||||
setNewMedia(prev => ({
|
||||
...prev,
|
||||
category: activeCategory,
|
||||
aspectRatio: defaultAspect
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
if (initialItemsPerPage) {
|
||||
setItemsPerPage(initialItemsPerPage);
|
||||
}
|
||||
}, [initialItemsPerPage]);
|
||||
|
||||
const handleAddSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMedia.title || !newMedia.poster) return;
|
||||
// Sync gridItemSize with prop when API settings are loaded
|
||||
useEffect(() => {
|
||||
if (initialGridItemSize !== undefined) {
|
||||
setGridItemSize(initialGridItemSize);
|
||||
}
|
||||
}, [initialGridItemSize]);
|
||||
|
||||
onAddMedia({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
title: newMedia.title,
|
||||
year: newMedia.year || new Date().getFullYear().toString(),
|
||||
poster: newMedia.poster,
|
||||
category: newMedia.category,
|
||||
aspectRatio: newMedia.aspectRatio,
|
||||
status: 'planned'
|
||||
});
|
||||
|
||||
setNewMedia({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: activeCategory,
|
||||
aspectRatio: '2/3'
|
||||
});
|
||||
setIsAddDialogOpen(false);
|
||||
};
|
||||
|
||||
// Filter states
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
|
||||
|
||||
// Extract unique values for filters
|
||||
const allTypes = useMemo(() => Array.from(new Set(mediaList.map(m => m.type).filter(Boolean))), [mediaList]);
|
||||
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
||||
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
|
||||
const filteredMedia = useMemo(() => {
|
||||
return mediaList.filter(media => {
|
||||
if (selectedType && media.type !== selectedType) return false;
|
||||
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
|
||||
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
|
||||
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
|
||||
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
|
||||
if (selectedCategory && !media.series?.includes(selectedCategory)) return false;
|
||||
if (selectedSource && media.source !== selectedSource) return false;
|
||||
return true;
|
||||
});
|
||||
}, [mediaList, selectedType, selectedGenre, selectedStudio]);
|
||||
}, [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">
|
||||
{/* Type Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedType ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<Filter size={16} />
|
||||
{selectedType || 'Media Type'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setSelectedType(null)}>All Types</DropdownMenuItem>
|
||||
{allTypes.map(type => (
|
||||
<DropdownMenuItem key={type} onClick={() => setSelectedType(type!)}>{type}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Genre Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("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 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>
|
||||
)}
|
||||
|
||||
{/* Studio Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
Studios
|
||||
{/* 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>
|
||||
</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>
|
||||
|
||||
{(selectedType || selectedGenre || selectedStudio) && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-zinc-400 font-bold"
|
||||
onClick={() => {
|
||||
setSelectedType(null);
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</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">
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black rounded-full px-6 h-11 shadow-lg shadow-[#6d28d9]/20 gap-2">
|
||||
<Plus size={20} />
|
||||
ADD NEW
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
|
||||
<form onSubmit={handleAddSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-black text-zinc-900">Add New Media</DialogTitle>
|
||||
<DialogDescription className="text-zinc-500 font-medium">
|
||||
Manually add a new item to your {activeCategory} library.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-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]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category" className="text-sm font-black text-zinc-700">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={newMedia.category}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label>
|
||||
<select
|
||||
id="aspectRatio"
|
||||
value={newMedia.aspectRatio}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="2/3">2:3 (Standard Poster - Anime/Movies)</option>
|
||||
<option value="16/9">16:9 (Wide Thumbnail - Games/Adult)</option>
|
||||
<option value="1/1">1:1 (Square - Music)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="poster" className="text-sm font-black text-zinc-700">Poster URL</Label>
|
||||
<Input
|
||||
id="poster"
|
||||
value={newMedia.poster}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/* 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>
|
||||
<DialogFooter>
|
||||
<Button type="submit" className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20">
|
||||
SAVE TO LIBRARY
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="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"
|
||||
)}
|
||||
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">
|
||||
<Filter 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"
|
||||
>
|
||||
{[8, 12, 16, 24, 48].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>
|
||||
)}
|
||||
|
||||
@@ -1,202 +1,494 @@
|
||||
import { Staff, Media } from '@/types';
|
||||
import { motion } from 'motion/react';
|
||||
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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;
|
||||
onBack: () => void;
|
||||
onMediaClick: (mediaId: string) => void;
|
||||
relatedMedia: Media[];
|
||||
}
|
||||
|
||||
export default function CastDetailView({ person, onBack, onMediaClick, relatedMedia }: CastDetailViewProps) {
|
||||
export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [sortBy, setSortBy] = useState<'year' | 'title' | 'role'>('role');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const handleMediaClick = (mediaId: string) => {
|
||||
navigate(`/media/${mediaId}`);
|
||||
};
|
||||
|
||||
const sortedFilmography = [...(person.filmography || [])].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
if (sortBy === 'year') {
|
||||
comparison = (a.year || 0) - (b.year || 0);
|
||||
} else if (sortBy === 'title') {
|
||||
comparison = (a.title || '').localeCompare(b.title || '');
|
||||
} else if (sortBy === 'role') {
|
||||
comparison = (a.role || '').localeCompare(b.role || '');
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
// 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"
|
||||
onClick={onBack}
|
||||
className="absolute top-24 left-6 bg-white/20 hover:bg-white/40 text-white rounded-full backdrop-blur-md"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
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>
|
||||
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>
|
||||
|
||||
<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>
|
||||
{/* Ethnicity - only if present */}
|
||||
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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} />
|
||||
{/* 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>
|
||||
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>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Measurements (Bust-Waist-Hip) */}
|
||||
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
|
||||
<>
|
||||
<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 && <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>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{(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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Bio & Roles */}
|
||||
<div className="lg:col-span-2 space-y-12">
|
||||
<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 || `${person.name} is a talented ${person.role} known for their work in various media productions. They have brought numerous characters to life with their unique performances.`}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
{relatedMedia.map(media => {
|
||||
const character = media.staff?.find(s => s.id === person.id);
|
||||
if (!character) return null;
|
||||
return (
|
||||
<div
|
||||
key={`${media.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={character.characterImage}
|
||||
alt={character.characterName}
|
||||
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">{character.characterName}</h4>
|
||||
<button
|
||||
onClick={() => onMediaClick(media.id)}
|
||||
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left"
|
||||
>
|
||||
in {media.title}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{relatedMedia.map(media => (
|
||||
<div
|
||||
key={media.id}
|
||||
onClick={() => onMediaClick(media.id)}
|
||||
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={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
{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>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
|
||||
{media.title}
|
||||
</h4>
|
||||
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-1">
|
||||
{media.year}
|
||||
</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">
|
||||
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-zinc-200">
|
||||
{person.role}
|
||||
</Badge>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,211 @@
|
||||
import { Staff } from '@/types';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Staff, MediaCategory } from '@/types';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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';
|
||||
|
||||
interface CastViewProps {
|
||||
staffList: Staff[];
|
||||
onPersonClick: (person: Staff) => void;
|
||||
enabledCategories: MediaCategory[];
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
export default function CastView({ staffList, onPersonClick }: CastViewProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'name' | 'role'>('name');
|
||||
export default function CastView({ onPersonClick, enabledCategories, itemsPerPage: initialItemsPerPage = 12 }: CastViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [staffList, setStaffList] = useState<Staff[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState(() => {
|
||||
return localStorage.getItem('castSearchQuery') || '';
|
||||
});
|
||||
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height' | 'roleCount'>(() => {
|
||||
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height' | 'roleCount') || 'roleCount';
|
||||
});
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
|
||||
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
|
||||
});
|
||||
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
|
||||
const saved = localStorage.getItem('castFilterOccupation');
|
||||
return saved && saved !== '' ? saved : 'all';
|
||||
});
|
||||
const [filterMediaType, setFilterMediaType] = useState<string>(() => {
|
||||
const saved = localStorage.getItem('castFilterMediaType');
|
||||
return saved && saved !== '' ? saved : 'all';
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||
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(() => {
|
||||
localStorage.setItem('castSearchQuery', searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castSortBy', sortBy);
|
||||
}, [sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castSortOrder', sortOrder);
|
||||
}, [sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castFilterOccupation', filterOccupation);
|
||||
}, [filterOccupation]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castFilterMediaType', filterMediaType);
|
||||
}, [filterMediaType]);
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSortBy('roleCount');
|
||||
setSortOrder('desc');
|
||||
setFilterOccupation('all');
|
||||
setFilterMediaType('all');
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || (filterOccupation && filterOccupation !== 'all') || (filterMediaType && filterMediaType !== 'all') || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
||||
|
||||
useEffect(() => {
|
||||
const loadCast = async () => {
|
||||
try {
|
||||
const cast = await fetchAllCast();
|
||||
setStaffList(cast);
|
||||
} catch (error) {
|
||||
console.error('Failed to load cast:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadCast();
|
||||
}, []);
|
||||
|
||||
const filteredStaff = useMemo(() => {
|
||||
let list = staffList.filter(s =>
|
||||
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.role.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.mediaTitle?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
let list = staffList.filter(s => {
|
||||
// Filter by enabled categories based on media_types
|
||||
if (s.media_types && s.media_types.length > 0) {
|
||||
const hasEnabledMediaType = s.media_types.some(type => {
|
||||
const category = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
return enabledCategories.includes(category as MediaCategory);
|
||||
});
|
||||
if (!hasEnabledMediaType) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by occupation
|
||||
if (filterOccupation && filterOccupation !== 'all' && !s.occupations?.includes(filterOccupation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return list.sort((a, b) => a[sortBy].localeCompare(b[sortBy]));
|
||||
}, [staffList, searchQuery, sortBy]);
|
||||
// Filter by media type
|
||||
if (filterMediaType && filterMediaType !== 'all' && !s.media_types?.includes(filterMediaType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.role.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.mediaTitle?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
// Sort
|
||||
list.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortBy === 'name' || sortBy === 'role') {
|
||||
comparison = a[sortBy].localeCompare(b[sortBy]);
|
||||
} else if (sortBy === 'birthDate') {
|
||||
const dateA = a.birthDate ? new Date(a.birthDate).getTime() : 0;
|
||||
const dateB = b.birthDate ? new Date(b.birthDate).getTime() : 0;
|
||||
comparison = dateA - dateB;
|
||||
} else if (sortBy === 'height') {
|
||||
const heightA = a.height || 0;
|
||||
const heightB = b.height || 0;
|
||||
comparison = heightA - heightB;
|
||||
} else if (sortBy === 'roleCount') {
|
||||
const roleCountA = a.filmography?.length || 0;
|
||||
const roleCountB = b.filmography?.length || 0;
|
||||
comparison = roleCountA - roleCountB;
|
||||
}
|
||||
|
||||
return sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
|
||||
return list;
|
||||
}, [staffList, searchQuery, sortBy, sortOrder, filterOccupation, filterMediaType, enabledCategories]);
|
||||
|
||||
// Get unique occupations and media types for filters
|
||||
const uniqueOccupations = useMemo(() => {
|
||||
const occupations = new Set<string>();
|
||||
staffList.forEach(s => s.occupations?.forEach(o => occupations.add(o)));
|
||||
return Array.from(occupations).sort();
|
||||
}, [staffList]);
|
||||
|
||||
const uniqueMediaTypes = useMemo(() => {
|
||||
const mediaTypes = new Set<string>();
|
||||
staffList.forEach(s => s.media_types?.forEach(m => mediaTypes.add(m)));
|
||||
return Array.from(mediaTypes).sort();
|
||||
}, [staffList]);
|
||||
|
||||
// Reset to first page when filters or sorting change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, sortBy, itemsPerPage]);
|
||||
}, [searchQuery, sortBy, sortOrder, filterOccupation, filterMediaType, itemsPerPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredStaff.length / itemsPerPage);
|
||||
|
||||
@@ -39,152 +214,602 @@ export default function CastView({ staffList, onPersonClick }: CastViewProps) {
|
||||
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>
|
||||
|
||||
<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="outline"
|
||||
size="icon"
|
||||
className="rounded-full h-11 w-11 border-zinc-200"
|
||||
onClick={() => setSortBy(prev => prev === 'name' ? 'role' : 'name')}
|
||||
>
|
||||
<ArrowUpDown size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</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}-${person.mediaId}`}
|
||||
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>
|
||||
|
||||
<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.characterImage}
|
||||
alt={person.characterName}
|
||||
className="w-full h-full object-contain"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest leading-none mb-1">Character</p>
|
||||
<p className="text-xs font-bold text-zinc-700 truncate">{person.characterName}</p>
|
||||
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">in {person.mediaTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</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"
|
||||
>
|
||||
{[8, 12, 16, 24, 48].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-zinc-200"
|
||||
{/* 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"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* End of scrollable content area */}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +1,419 @@
|
||||
import { Media, Staff } from '@/types';
|
||||
import {
|
||||
Play,
|
||||
Bookmark,
|
||||
MoreHorizontal,
|
||||
Star,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
ListFilter
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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;
|
||||
onBack: () => void;
|
||||
allMedia: Media[];
|
||||
onPersonClick: (person: Staff) => void;
|
||||
}
|
||||
|
||||
export default function DetailView({ media, onBack, onPersonClick }: DetailViewProps) {
|
||||
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={onBack}
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Search, User, X } from 'lucide-react';
|
||||
import { Search, User, X, Plus, Download, Settings, Menu } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, NavLink, useLocation } from 'react-router-dom';
|
||||
import { MediaCategory } from '@/types';
|
||||
import LibrarySettings from './LibrarySettings';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
interface HeaderProps {
|
||||
onBrowse: () => void;
|
||||
onCast: () => void;
|
||||
onSearch: (query: string) => void;
|
||||
activeCategory: MediaCategory;
|
||||
onCategoryChange: (category: MediaCategory) => void;
|
||||
@@ -15,18 +14,41 @@ interface HeaderProps {
|
||||
transparent?: boolean;
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
onBrowse,
|
||||
onCast,
|
||||
onSearch,
|
||||
export default function Header({
|
||||
onSearch,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
enabledCategories,
|
||||
onToggleCategory,
|
||||
transparent
|
||||
transparent
|
||||
}: 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;
|
||||
@@ -43,77 +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">
|
||||
<div
|
||||
className="text-2xl font-black text-white cursor-pointer flex items-center gap-1"
|
||||
onClick={onBrowse}
|
||||
<Link
|
||||
to="/"
|
||||
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
|
||||
</div>
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
|
||||
omnyx
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className={cn(
|
||||
"md:hidden p-2 rounded-lg transition-all duration-300 hover:bg-white/10",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{enabledCategories.map(cat => (
|
||||
<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" />
|
||||
<button
|
||||
onClick={onCast}
|
||||
className="text-sm font-bold text-white/60 hover:text-white transition-colors uppercase tracking-wider"
|
||||
<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-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
|
||||
</button>
|
||||
</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>
|
||||
<LibrarySettings
|
||||
enabledCategories={enabledCategories}
|
||||
onToggleCategory={onToggleCategory}
|
||||
/>
|
||||
<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"
|
||||
<Link
|
||||
to="/add"
|
||||
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={18} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/import"
|
||||
className={cn(
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Download size={18} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
className={cn(
|
||||
"p-2.5 rounded-xl transition-all duration-300 hover:scale-110",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "text-white/90 hover:text-white hover:bg-white/10"
|
||||
: "text-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</Link>
|
||||
<button className={cn(
|
||||
"w-9 h-9 rounded-xl overflow-hidden border-2 transition-all duration-300 hover:scale-110 hover:shadow-lg",
|
||||
(transparent && !scrolled) || !transparent
|
||||
? "border-white/30 hover:border-white/50"
|
||||
: "border-border hover:border-[#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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 variant="ghost" size="icon" className="text-white/90 hover:text-white transition-colors">
|
||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-all duration-300 hover:scale-110">
|
||||
<Settings size={20} />
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MediaCategory, UserSettings, CustomColors } from '@/types';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { 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.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];
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
];
|
||||
|
||||
interface SettingsViewProps {
|
||||
onSettingsSaved?: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||
const 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,
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
|
||||
// Page Settings State
|
||||
const [pageTitle, setPageTitle] = useState<string>('');
|
||||
const [favicon, setFavicon] = useState<string>('');
|
||||
const [customColors, setCustomColors] = useState<CustomColors>({});
|
||||
const [faviconPreview, setFaviconPreview] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const loadedSettings = await fetchSettings();
|
||||
if (loadedSettings) {
|
||||
setSettings(loadedSettings);
|
||||
setPageTitle(loadedSettings.pageTitle || '');
|
||||
setFavicon(loadedSettings.favicon || '');
|
||||
setCustomColors(loadedSettings.customColors || {});
|
||||
setFaviconPreview(loadedSettings.favicon || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveStatus('idle');
|
||||
try {
|
||||
const updatedSettings: UserSettings = {
|
||||
...settings,
|
||||
pageTitle: pageTitle || undefined,
|
||||
favicon: favicon || undefined,
|
||||
customColors: Object.keys(customColors).length > 0 ? customColors : undefined,
|
||||
};
|
||||
const savedSettings = await updateSettings(updatedSettings);
|
||||
if (savedSettings) {
|
||||
setSettings(savedSettings);
|
||||
setSaveStatus('success');
|
||||
// Sync theme with theme context
|
||||
setTheme(savedSettings.theme);
|
||||
onSettingsSaved?.();
|
||||
} else {
|
||||
setSaveStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
setSaveStatus('error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (category: MediaCategory) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
enabledCategories: prev.enabledCategories.includes(category)
|
||||
? prev.enabledCategories.filter(c => c !== category)
|
||||
: [...prev.enabledCategories, category]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFaviconUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setFavicon(base64);
|
||||
setFaviconPreview(base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFavicon = () => {
|
||||
setFavicon('');
|
||||
setFaviconPreview('');
|
||||
};
|
||||
|
||||
const handleColorChange = (colorKey: keyof CustomColors, value: string) => {
|
||||
setCustomColors(prev => ({
|
||||
...prev,
|
||||
[colorKey]: value || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-muted-foreground font-medium">Loading settings...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const enabledCount = settings.enabledCategories.length;
|
||||
const totalCategories = 8;
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<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 */}
|
||||
<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>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 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>
|
||||
</TabsContent>
|
||||
|
||||
{/* Content Settings */}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 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>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||