Compare commits
10 Commits
63c5d0a7c0
..
app
| Author | SHA1 | Date | |
|---|---|---|---|
| 15fe7670c8 | |||
| d61472f069 | |||
| 83617f75e4 | |||
| 901b342871 | |||
| b0cb8ca0a2 | |||
| 4605b251be | |||
| 073c8a6c5d | |||
| 9a72ba3064 | |||
| 34bb4a27be | |||
| e5cdd6b383 |
@@ -3,8 +3,10 @@
|
|||||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
APP_URL="MY_APP_URL"
|
APP_URL="MY_APP_URL"
|
||||||
|
|
||||||
# Backend API URL
|
# Backend API URL (Omnyx Backend)
|
||||||
VITE_API_URL="http://192.168.1.102:6400"
|
# Default: http://localhost:3001 for local dev
|
||||||
|
# Change this if backend runs on different host/port
|
||||||
|
VITE_API_URL="http://localhost:3001"
|
||||||
|
|
||||||
# Importer Configurations
|
# Importer Configurations
|
||||||
# XBVR Importer
|
# XBVR Importer
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ coverage/
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
/docs
|
/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)
|
||||||
@@ -21,9 +21,10 @@ This is a modern frontend project template based on React 18, TypeScript, and Vi
|
|||||||
- **State Management**: Zustand / Redux Toolkit
|
- **State Management**: Zustand / Redux Toolkit
|
||||||
- **Routing**: React Router v6
|
- **Routing**: React Router v6
|
||||||
- **UI Components**: Ant Design / Material-UI
|
- **UI Components**: Ant Design / Material-UI
|
||||||
- **Styling**: Tailwind CSS / Styled-components
|
- **Styling**: Tailwind CSS 4 with shadcn/ui component library
|
||||||
- **Testing Framework**: Vitest + React Testing Library
|
- **Testing Framework**: Vitest + React Testing Library
|
||||||
- **Code Quality**: ESLint + Prettier + Husky
|
- **Code Quality**: ESLint + Prettier + Husky
|
||||||
|
- **UI Components**: Complete shadcn/ui component set (New York style) with Lucide icons
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -315,6 +316,54 @@ export default defineConfig({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
## Common Issues
|
||||||
|
|
||||||
### Issue 1: Vite Development Server Slow Startup
|
### Issue 1: Vite Development Server Slow Startup
|
||||||
@@ -329,6 +378,23 @@ export default defineConfig({
|
|||||||
- Check tsconfig.json configuration
|
- Check tsconfig.json configuration
|
||||||
- Use `npm run type-check` for type checking
|
- 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
|
## Reference Resources
|
||||||
|
|
||||||
- [React Official Documentation](https://react.dev/)
|
- [React Official Documentation](https://react.dev/)
|
||||||
|
|||||||
@@ -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,6 +1,10 @@
|
|||||||
# Kyoo - Media Discovery Platform
|

|
||||||
|
|
||||||
A modern web application for browsing, managing, and discovering media across multiple categories. Kyoo provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR.
|
# Omnyx - Media Discovery Platform
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A modern web application for browsing, managing, and discovering media across multiple categories. Omnyx provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -98,6 +102,104 @@ VITE_PLAYNITE_API_TOKEN="your-api-token"
|
|||||||
1. Ensure XBVR is running and accessible
|
1. Ensure XBVR is running and accessible
|
||||||
2. Configure the URL in your `.env` file
|
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
|
## Usage
|
||||||
|
|
||||||
### Browsing Media
|
### Browsing Media
|
||||||
@@ -122,6 +224,7 @@ VITE_PLAYNITE_API_TOKEN="your-api-token"
|
|||||||
- `npm run dev` - Start development server
|
- `npm run dev` - Start development server
|
||||||
- `npm run build` - Build for production
|
- `npm run build` - Build for production
|
||||||
- `npm run preview` - Preview production build
|
- `npm run preview` - Preview production build
|
||||||
|
- `npm run tauri` - Run Tauri desktop app
|
||||||
- `npm run lint` - Run TypeScript type checking
|
- `npm run lint` - Run TypeScript type checking
|
||||||
- `npm run clean` - Remove build artifacts
|
- `npm run clean` - Remove build artifacts
|
||||||
|
|
||||||
|
|||||||
@@ -21,5 +21,7 @@
|
|||||||
},
|
},
|
||||||
"menuColor": "default",
|
"menuColor": "default",
|
||||||
"menuAccent": "subtle",
|
"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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>My Google AI Studio App</title>
|
<title>Omnyx - Media Discovery</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Kyoo - Media Discovery",
|
"name": "Omnyx - Media Discovery",
|
||||||
"description": "A polished media discovery and tracking application inspired by modern anime platforms.",
|
"description": "A polished media discovery and tracking application inspired by modern anime platforms.",
|
||||||
"requestFramePermissions": []
|
"requestFramePermissions": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,16 @@
|
|||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"docs": "typedoc",
|
"docs": "typedoc",
|
||||||
"docs:serve": "typedoc && npx serve docs"
|
"docs:serve": "typedoc && npx serve docs",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"desktop:build": "bun scripts/build-desktop.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -30,18 +33,19 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"shadcn": "^4.2.0",
|
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@vitest/ui": "^4.1.4",
|
"@vitest/ui": "^4.1.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
|
"shadcn": "^4.5.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typedoc": "^0.28.19",
|
"typedoc": "^0.28.19",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { LayoutGroup } from 'motion/react';
|
import { LayoutGroup } from 'motion/react';
|
||||||
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
|
||||||
import Sidebar from './components/Sidebar';
|
import AppSidebar from './components/sidebar/AppSidebar';
|
||||||
|
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||||
import BrowseView from './components/BrowseView';
|
import BrowseView from './components/BrowseView';
|
||||||
import DashboardView from './components/DashboardView';
|
import DashboardView from './components/DashboardView';
|
||||||
import DetailView from './components/DetailView';
|
import DetailView from './components/DetailView';
|
||||||
@@ -22,7 +23,11 @@ import CategoryBrowseRoute from './components/routes/CategoryBrowseRoute';
|
|||||||
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
||||||
import { Media, Staff, MediaCategory, UserSettings } from './types';
|
import { Media, Staff, MediaCategory, UserSettings } from './types';
|
||||||
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
|
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
|
||||||
|
import { waitForBackend } from './lib/api/waitForBackend';
|
||||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
|
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 { CATEGORY_PATHS, PATH_TO_CATEGORY, DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from './constants';
|
||||||
import { useAppStore } from './store/appStore';
|
import { useAppStore } from './store/appStore';
|
||||||
|
|
||||||
@@ -77,6 +82,22 @@ function AppContent() {
|
|||||||
setEnabledCategories(loadedSettings.enabledCategories);
|
setEnabledCategories(loadedSettings.enabledCategories);
|
||||||
// Sync theme with theme context
|
// Sync theme with theme context
|
||||||
setTheme(loadedSettings.theme);
|
setTheme(loadedSettings.theme);
|
||||||
|
|
||||||
|
// Set custom page title
|
||||||
|
if (loadedSettings.pageTitle) {
|
||||||
|
document.title = loadedSettings.pageTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set custom favicon
|
||||||
|
if (loadedSettings.favicon) {
|
||||||
|
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||||
|
if (!faviconLink) {
|
||||||
|
faviconLink = document.createElement('link');
|
||||||
|
faviconLink.rel = 'icon';
|
||||||
|
document.head.appendChild(faviconLink);
|
||||||
|
}
|
||||||
|
faviconLink.href = loadedSettings.favicon;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings from API:', error);
|
console.error('Failed to load settings from API:', error);
|
||||||
@@ -86,6 +107,22 @@ function AppContent() {
|
|||||||
loadSettingsFromApi();
|
loadSettingsFromApi();
|
||||||
}, [setTheme]);
|
}, [setTheme]);
|
||||||
|
|
||||||
|
// Apply custom colors when settings change
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.customColors) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const colors = settings.customColors;
|
||||||
|
|
||||||
|
if (colors.primary) root.style.setProperty('--color-primary', colors.primary);
|
||||||
|
if (colors.secondary) root.style.setProperty('--color-secondary', colors.secondary);
|
||||||
|
if (colors.background) root.style.setProperty('--color-background', colors.background);
|
||||||
|
if (colors.surface) root.style.setProperty('--color-surface', colors.surface);
|
||||||
|
if (colors.text) root.style.setProperty('--color-text', colors.text);
|
||||||
|
if (colors.muted) root.style.setProperty('--color-muted', colors.muted);
|
||||||
|
if (colors.border) root.style.setProperty('--color-border', colors.border);
|
||||||
|
}
|
||||||
|
}, [settings?.customColors]);
|
||||||
|
|
||||||
const reloadSettings = async () => {
|
const reloadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const loadedSettings = await fetchSettings();
|
const loadedSettings = await fetchSettings();
|
||||||
@@ -94,6 +131,22 @@ function AppContent() {
|
|||||||
setEnabledCategories(loadedSettings.enabledCategories);
|
setEnabledCategories(loadedSettings.enabledCategories);
|
||||||
// Sync theme with theme context
|
// Sync theme with theme context
|
||||||
setTheme(loadedSettings.theme);
|
setTheme(loadedSettings.theme);
|
||||||
|
|
||||||
|
// Set custom page title
|
||||||
|
if (loadedSettings.pageTitle) {
|
||||||
|
document.title = loadedSettings.pageTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set custom favicon
|
||||||
|
if (loadedSettings.favicon) {
|
||||||
|
let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||||
|
if (!faviconLink) {
|
||||||
|
faviconLink = document.createElement('link');
|
||||||
|
faviconLink.rel = 'icon';
|
||||||
|
document.head.appendChild(faviconLink);
|
||||||
|
}
|
||||||
|
faviconLink.href = loadedSettings.favicon;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reload settings from API:', error);
|
console.error('Failed to reload settings from API:', error);
|
||||||
@@ -162,7 +215,8 @@ function AppContent() {
|
|||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const allMedia = useMemo(() => {
|
// All media from enabled categories (for cross-category search)
|
||||||
|
const allEnabledMedia = useMemo(() => {
|
||||||
// Use API data if available, otherwise fall back to mock data
|
// Use API data if available, otherwise fall back to mock data
|
||||||
let list: Media[] = [];
|
let list: Media[] = [];
|
||||||
|
|
||||||
@@ -180,9 +234,14 @@ function AppContent() {
|
|||||||
list.push(DETAIL_MEDIA);
|
list.push(DETAIL_MEDIA);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by enabled categories only (all enabled categories, not just active)
|
||||||
|
return list.filter(m => enabledCategories.includes(m.category));
|
||||||
|
}, [enabledCategories, customMedia, apiMedia]);
|
||||||
|
|
||||||
|
const allMedia = useMemo(() => {
|
||||||
// Filter by active category AND ensure it's enabled
|
// Filter by active category AND ensure it's enabled
|
||||||
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
|
return allEnabledMedia.filter(m => m.category === activeCategory);
|
||||||
}, [activeCategory, enabledCategories, customMedia, apiMedia]);
|
}, [activeCategory, allEnabledMedia]);
|
||||||
|
|
||||||
const handleAddMedia = async () => {
|
const handleAddMedia = async () => {
|
||||||
// Reload all media from API to get the newly added item
|
// Reload all media from API to get the newly added item
|
||||||
@@ -209,37 +268,55 @@ function AppContent() {
|
|||||||
|
|
||||||
const allStaff = useMemo(() => {
|
const allStaff = useMemo(() => {
|
||||||
const staff: Staff[] = [];
|
const staff: Staff[] = [];
|
||||||
// Use API data if available, otherwise fall back to mock data
|
const staffIds = new Set<string>(); // Track unique staff to avoid duplicates
|
||||||
let baseList: Media[] = [];
|
|
||||||
|
|
||||||
if (apiMedia.length > 0) {
|
// Use allEnabledMedia which already has enabled categories filtered
|
||||||
// API has data, use it
|
allEnabledMedia.forEach(media => {
|
||||||
baseList = [...apiMedia];
|
|
||||||
} else {
|
|
||||||
// API is empty, use mock data as fallback
|
|
||||||
baseList = [...MOCK_MEDIA];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add custom media and detail media
|
|
||||||
baseList = [...baseList, ...customMedia];
|
|
||||||
if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) {
|
|
||||||
baseList.push(DETAIL_MEDIA);
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabledMedia = baseList.filter(m => enabledCategories.includes(m.category));
|
|
||||||
|
|
||||||
enabledMedia.forEach(media => {
|
|
||||||
media.staff?.forEach(s => {
|
media.staff?.forEach(s => {
|
||||||
staff.push({
|
// Avoid duplicate staff entries
|
||||||
...s,
|
if (!staffIds.has(s.id)) {
|
||||||
mediaId: media.id,
|
staffIds.add(s.id);
|
||||||
mediaTitle: media.title
|
staff.push({
|
||||||
});
|
...s,
|
||||||
|
mediaId: media.id,
|
||||||
|
mediaTitle: media.title
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return staff;
|
return staff;
|
||||||
}, [enabledCategories, customMedia, apiMedia]);
|
}, [allEnabledMedia]);
|
||||||
|
|
||||||
|
// Search across all enabled media (all categories)
|
||||||
|
const searchResultsMedia = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return [];
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return allEnabledMedia.filter(media =>
|
||||||
|
media.title.toLowerCase().includes(query) ||
|
||||||
|
media.year.toLowerCase().includes(query) ||
|
||||||
|
media.genres?.some(g => g.toLowerCase().includes(query)) ||
|
||||||
|
media.studios?.some(s => s.toLowerCase().includes(query)) ||
|
||||||
|
media.description?.toLowerCase().includes(query) ||
|
||||||
|
media.tags?.some(t => t.toLowerCase().includes(query)) ||
|
||||||
|
media.developers?.some(d => d.toLowerCase().includes(query)) ||
|
||||||
|
media.platforms?.some(p => p.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
}, [allEnabledMedia, searchQuery]);
|
||||||
|
|
||||||
|
// Search cast members
|
||||||
|
const searchResultsCast = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return [];
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return allStaff.filter(staff =>
|
||||||
|
staff.name.toLowerCase().includes(query) ||
|
||||||
|
staff.role.toLowerCase().includes(query) ||
|
||||||
|
staff.bio?.toLowerCase().includes(query) ||
|
||||||
|
staff.occupations?.some(o => o.toLowerCase().includes(query)) ||
|
||||||
|
staff.characterName?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [allStaff, searchQuery]);
|
||||||
|
|
||||||
|
// Legacy filteredMedia for backward compatibility (searches within current category)
|
||||||
const filteredMedia = useMemo(() => {
|
const filteredMedia = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return allMedia;
|
if (!searchQuery.trim()) return allMedia;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
@@ -310,99 +387,233 @@ function AppContent() {
|
|||||||
navigate('/browse');
|
navigate('/browse');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// Calculate media counts for sidebar (all categories)
|
||||||
<div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9] flex">
|
const mediaCounts = useMemo(() => {
|
||||||
<Sidebar
|
const counts: Record<string, number> = {};
|
||||||
enabledCategories={enabledCategories}
|
// Count all enabled categories using allEnabledMedia
|
||||||
onToggleCategory={toggleCategory}
|
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]);
|
||||||
|
|
||||||
<main className="flex-1 lg:ml-72 flex flex-col">
|
// Calculate active filter based on current URL
|
||||||
<LayoutGroup>
|
const activeFilter = useMemo(() => {
|
||||||
<Routes>
|
const path = location.pathname;
|
||||||
<Route path="/" element={
|
// Map routes to filter IDs
|
||||||
<DashboardView
|
const routeMap: Record<string, string> = {
|
||||||
mediaList={apiMedia.length > 0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))}
|
'/anime': 'anime',
|
||||||
onMediaClick={handleMediaClick}
|
'/movies': 'movies',
|
||||||
loading={mediaLoading}
|
'/tv-series': 'tv-series',
|
||||||
/>
|
'/music': 'music',
|
||||||
} />
|
'/books': 'books',
|
||||||
<Route path="/browse" element={
|
'/adult': 'adult',
|
||||||
<BrowseView
|
'/consoles': 'consoles',
|
||||||
mediaList={filteredMedia}
|
'/games': 'games',
|
||||||
onMediaClick={handleMediaClick}
|
};
|
||||||
activeCategory={activeCategory}
|
if (routeMap[path]) return routeMap[path];
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
if (searchParams.get('favorites') === 'true') return 'favorites';
|
||||||
gridItemSize={settings?.gridItemSize}
|
return undefined;
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
}, [location.pathname, searchParams]);
|
||||||
loading={mediaLoading}
|
|
||||||
/>
|
return (
|
||||||
} />
|
<div className="min-h-screen bg-background font-sans selection:bg-[#e8466c]/20 selection:text-[#e8466c] flex">
|
||||||
<Route path="/:category" element={
|
<SidebarProvider defaultOpen={true}>
|
||||||
<CategoryBrowseRoute
|
<AppSidebar
|
||||||
mediaList={filteredMedia}
|
enabledCategories={enabledCategories}
|
||||||
onMediaClick={handleMediaClick}
|
onToggleCategory={toggleCategory}
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
pageTitle={settings?.pageTitle || 'MediaVault'}
|
||||||
gridItemSize={settings?.gridItemSize}
|
mediaCounts={mediaCounts}
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
activeFilter={activeFilter}
|
||||||
loading={mediaLoading}
|
/>
|
||||||
/>
|
|
||||||
} />
|
<main className="flex-1 flex flex-col relative">
|
||||||
<Route path="/media/:id" element={
|
{/* Header with Search and Add Media */}
|
||||||
<MediaDetailRoute
|
<header className="sticky top-0 z-30 bg-background/80 backdrop-blur-xl border-b border-border px-6 py-4">
|
||||||
allMedia={allMedia}
|
<div className="flex items-center justify-between gap-4 max-w-[1920px] mx-auto">
|
||||||
onPersonClick={handlePersonClick}
|
{/* Search Bar */}
|
||||||
/>
|
<div className="flex-1 max-w-xl">
|
||||||
} />
|
<div className="relative">
|
||||||
<Route path="/cast" element={
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
<CastView
|
<Input
|
||||||
onPersonClick={handlePersonClick}
|
type="text"
|
||||||
enabledCategories={enabledCategories}
|
placeholder="Search library..."
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
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"
|
||||||
<Route path="/cast/:id" element={
|
/>
|
||||||
<CastDetailRoute />
|
</div>
|
||||||
} />
|
</div>
|
||||||
<Route path="/add" element={
|
|
||||||
<AddMediaView
|
{/* View Toggle and Add Button */}
|
||||||
activeCategory={activeCategory}
|
<div className="flex items-center gap-3">
|
||||||
enabledCategories={enabledCategories}
|
<div className="flex items-center bg-muted/30 rounded-lg p-1 border border-border">
|
||||||
onAddComplete={handleAddMedia}
|
<Button
|
||||||
/>
|
variant="ghost"
|
||||||
} />
|
size="icon"
|
||||||
<Route path="/import" element={
|
className="h-8 w-8 rounded bg-accent text-accent-foreground"
|
||||||
<ImporterView />
|
>
|
||||||
} />
|
<LayoutGrid className="w-4 h-4" />
|
||||||
<Route path="/settings" element={
|
</Button>
|
||||||
<SettingsView onSettingsSaved={reloadSettings} />
|
<Button
|
||||||
} />
|
variant="ghost"
|
||||||
</Routes>
|
size="icon"
|
||||||
</LayoutGroup>
|
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>
|
||||||
|
</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="py-8 px-6 border-t border-border/50 bg-muted/30 backdrop-blur-sm mt-auto">
|
<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="max-w-[1920px] mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2 text-lg font-black text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
<div className="w-5 h-5 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-full" />
|
<span>{mediaCounts.all} total</span>
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">kyoo</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>
|
</div>
|
||||||
<div className="flex items-center gap-6 text-sm font-bold text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Terms</a>
|
© 2026 MediaVault v1.0.0
|
||||||
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Privacy</a>
|
|
||||||
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Contact</a>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
|
||||||
© 2026 Kyoo Media Discovery. All rights reserved.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
</SidebarProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
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 (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
|
|
||||||
<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="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="flex items-center gap-4 mb-8">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] flex items-center justify-center shadow-lg shadow-[#6d28d9]/30">
|
<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)}
|
{getCategoryIcon(activeCategory)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -234,7 +234,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
{/* Basic Info Card */}
|
{/* Basic Info Card */}
|
||||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||||
<FileText size={16} />
|
<FileText size={16} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
|
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
|
||||||
@@ -247,7 +247,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.title}
|
value={newMedia.title}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||||
placeholder="e.g. Mob Psycho 100"
|
placeholder="e.g. Mob Psycho 100"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,7 +259,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.year}
|
value={newMedia.year}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||||
placeholder="2024"
|
placeholder="2024"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -268,7 +268,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
id="category"
|
id="category"
|
||||||
value={newMedia.category}
|
value={newMedia.category}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
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 => (
|
{enabledCategories.map(cat => (
|
||||||
<option key={cat} value={cat}>{cat}</option>
|
<option key={cat} value={cat}>{cat}</option>
|
||||||
@@ -283,7 +283,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
id="type"
|
id="type"
|
||||||
value={newMedia.type}
|
value={newMedia.type}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
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' ? (
|
{newMedia.category === 'Music' ? (
|
||||||
<>
|
<>
|
||||||
@@ -322,7 +322,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
id="status"
|
id="status"
|
||||||
value={newMedia.status}
|
value={newMedia.status}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/50 outline-none"
|
||||||
>
|
>
|
||||||
<option value="Released">Released</option>
|
<option value="Released">Released</option>
|
||||||
<option value="Ongoing">Ongoing</option>
|
<option value="Ongoing">Ongoing</option>
|
||||||
@@ -343,7 +343,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
{/* Media Info Card */}
|
{/* Media Info Card */}
|
||||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||||
<Globe size={16} />
|
<Globe size={16} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black text-foreground">Media Information</h3>
|
<h3 className="text-lg font-black text-foreground">Media Information</h3>
|
||||||
@@ -356,7 +356,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.poster}
|
value={newMedia.poster}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||||
placeholder="https://example.com/poster.jpg"
|
placeholder="https://example.com/poster.jpg"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -367,7 +367,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.banner}
|
value={newMedia.banner}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
||||||
placeholder="https://example.com/banner.jpg"
|
placeholder="https://example.com/banner.jpg"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -377,7 +377,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
id="aspectRatio"
|
id="aspectRatio"
|
||||||
value={newMedia.aspectRatio}
|
value={newMedia.aspectRatio}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
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="2/3">2:3 (Poster)</option>
|
||||||
<option value="16/9">16:9 (Banner)</option>
|
<option value="16/9">16:9 (Banner)</option>
|
||||||
@@ -395,7 +395,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.rating}
|
value={newMedia.rating}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
|
||||||
placeholder="8.5"
|
placeholder="8.5"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,7 +407,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
||||||
placeholder="Enter a description..."
|
placeholder="Enter a description..."
|
||||||
rows={4}
|
rows={4}
|
||||||
className="bg-background border-border/50 rounded-xl p-3 text-sm focus:ring-2 focus:ring-[#6d28d9]/50 outline-none resize-none"
|
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>
|
</div>
|
||||||
@@ -416,7 +416,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
{(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="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="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||||
<Clock size={16} />
|
<Clock size={16} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black text-foreground">Production Details</h3>
|
<h3 className="text-lg font-black text-foreground">Production Details</h3>
|
||||||
@@ -430,7 +430,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.runtime}
|
value={newMedia.runtime}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
|
||||||
placeholder="120"
|
placeholder="120"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -440,7 +440,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
type="date"
|
type="date"
|
||||||
value={newMedia.releaseDate}
|
value={newMedia.releaseDate}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -450,7 +450,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.director}
|
value={newMedia.director}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
||||||
placeholder="Director name"
|
placeholder="Director name"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -460,7 +460,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.writer}
|
value={newMedia.writer}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
||||||
placeholder="Writer name"
|
placeholder="Writer name"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,7 +469,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
{/* Classification Card */}
|
{/* Classification Card */}
|
||||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
<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="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||||
<Tag size={16} />
|
<Tag size={16} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black text-foreground">Classification</h3>
|
<h3 className="text-lg font-black text-foreground">Classification</h3>
|
||||||
@@ -482,7 +482,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.genres}
|
value={newMedia.genres}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
||||||
placeholder="Action, Drama, Sci-Fi"
|
placeholder="Action, Drama, Sci-Fi"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -492,7 +492,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.tags}
|
value={newMedia.tags}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
||||||
placeholder="Classic, Best-selling"
|
placeholder="Classic, Best-selling"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -502,7 +502,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.studios}
|
value={newMedia.studios}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
||||||
placeholder="Studio A, Studio B"
|
placeholder="Studio A, Studio B"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -512,7 +512,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.source}
|
value={newMedia.source}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
|
||||||
placeholder="e.g. username, xbvr, stashapp"
|
placeholder="e.g. username, xbvr, stashapp"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,7 +522,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
{(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="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="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black text-foreground">Cast & Crew</h3>
|
<h3 className="text-lg font-black text-foreground">Cast & Crew</h3>
|
||||||
@@ -574,7 +574,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<Input
|
<Input
|
||||||
id="staffName"
|
id="staffName"
|
||||||
placeholder="Actor name"
|
placeholder="Actor name"
|
||||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -593,7 +593,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<Input
|
<Input
|
||||||
id="staffRole"
|
id="staffRole"
|
||||||
placeholder="e.g. Actor, Director"
|
placeholder="e.g. Actor, Director"
|
||||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -611,7 +611,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<Input
|
<Input
|
||||||
id="staffCharacter"
|
id="staffCharacter"
|
||||||
placeholder="Character name"
|
placeholder="Character name"
|
||||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,14 +620,14 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<Input
|
<Input
|
||||||
id="staffPhoto"
|
id="staffPhoto"
|
||||||
placeholder="https://example.com/photo.jpg"
|
placeholder="https://example.com/photo.jpg"
|
||||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addStaffMember}
|
onClick={addStaffMember}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-border/50 text-sm font-bold hover:border-[#6d28d9]/50 hover:bg-[#6d28d9]/10 rounded-xl transition-all duration-300"
|
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
|
+ Add Cast Member
|
||||||
</Button>
|
</Button>
|
||||||
@@ -640,7 +640,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/30 transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
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'}
|
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { Media, MediaCategory } from '@/types';
|
import { Media, MediaCategory, Staff } from '@/types';
|
||||||
import MediaCard from './MediaCard';
|
import MediaCard from './MediaCard';
|
||||||
import MediaListItem from './MediaListItem';
|
import MediaTable from './MediaTable';
|
||||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react';
|
import MediaFilters from './filters/MediaFilters';
|
||||||
|
import { LayoutGrid, List, User, Users } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import Loading from '@/components/ui/loading';
|
import Loading from '@/components/ui/loading';
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AnimatePresence } from 'motion/react';
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/ui/pagination';
|
||||||
|
|
||||||
interface BrowseViewProps {
|
interface BrowseViewProps {
|
||||||
mediaList: Media[];
|
mediaList: Media[];
|
||||||
@@ -22,13 +26,26 @@ interface BrowseViewProps {
|
|||||||
gridItemSize?: number;
|
gridItemSize?: number;
|
||||||
onGridItemSizeChange?: (size: number) => void;
|
onGridItemSizeChange?: (size: number) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
searchResultsCast?: Staff[];
|
||||||
|
onCastClick?: (person: Staff) => void;
|
||||||
|
searchQuery?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12, gridItemSize: initialGridItemSize = 5, onGridItemSizeChange, loading = false }: BrowseViewProps) {
|
export default function BrowseView({
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
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 [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||||
const [sortBy, setSortBy] = useState<string>('default');
|
|
||||||
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
|
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
|
||||||
|
|
||||||
// Sync itemsPerPage with prop when API settings are loaded
|
// Sync itemsPerPage with prop when API settings are loaded
|
||||||
@@ -53,21 +70,13 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
|||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||||
|
|
||||||
// Extract unique values for filters
|
|
||||||
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
|
||||||
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
|
|
||||||
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
|
|
||||||
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
|
|
||||||
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]);
|
|
||||||
const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
|
|
||||||
|
|
||||||
const filteredMedia = useMemo(() => {
|
const filteredMedia = useMemo(() => {
|
||||||
return mediaList.filter(media => {
|
return mediaList.filter(media => {
|
||||||
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
|
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
|
||||||
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
|
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
|
||||||
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
|
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
|
||||||
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
|
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
|
||||||
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
|
if (selectedCategory && !media.series?.includes(selectedCategory)) return false;
|
||||||
if (selectedSource && media.source !== selectedSource) return false;
|
if (selectedSource && media.source !== selectedSource) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -76,21 +85,9 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
|||||||
// Reset to first page when mediaList or filters change
|
// Reset to first page when mediaList or filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
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 gridColsClass = useMemo(() => {
|
||||||
// Map slider value (1-10) to grid columns
|
|
||||||
const colsMap: Record<number, string> = {
|
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',
|
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',
|
2: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||||
@@ -106,294 +103,366 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
|||||||
return `grid ${colsMap[gridItemSize] || colsMap[5]}`;
|
return `grid ${colsMap[gridItemSize] || colsMap[5]}`;
|
||||||
}, [gridItemSize]);
|
}, [gridItemSize]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredMedia.length / itemsPerPage);
|
||||||
|
|
||||||
const paginatedMedia = useMemo(() => {
|
const paginatedMedia = useMemo(() => {
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
return sortedMedia.slice(startIndex, startIndex + itemsPerPage);
|
return filteredMedia.slice(startIndex, startIndex + itemsPerPage);
|
||||||
}, [sortedMedia, currentPage, itemsPerPage]);
|
}, [filteredMedia, currentPage, itemsPerPage]);
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
const handleClearAll = () => {
|
||||||
setCurrentPage((prev) => Math.max(prev - 1, 1));
|
setSelectedGenre(null);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
setSelectedStudio(null);
|
||||||
|
setSelectedPlatform(null);
|
||||||
|
setSelectedDeveloper(null);
|
||||||
|
setSelectedCategory(null);
|
||||||
|
setSelectedSource(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextPage = () => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
|
setCurrentPage(page);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
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 (
|
return (
|
||||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
<div className="flex flex-col h-[calc(100vh-4rem-4rem)] w-full">
|
||||||
{/* Filters Bar */}
|
{/* Sticky Header - Filter + Results Summary + Count */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
|
<div className="px-6 pt-4 pb-4 bg-background border-b border-white/10 shrink-0 z-10">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{/* Filters Bar */}
|
||||||
{/* Genre Filter */}
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||||
<DropdownMenu>
|
<MediaFilters
|
||||||
<DropdownMenuTrigger asChild>
|
mediaList={mediaList}
|
||||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
activeCategory={activeCategory}
|
||||||
<Star size={16} />
|
selectedGenre={selectedGenre}
|
||||||
{selectedGenre || 'Genres'}
|
selectedStudio={selectedStudio}
|
||||||
</button>
|
selectedPlatform={selectedPlatform}
|
||||||
</DropdownMenuTrigger>
|
selectedDeveloper={selectedDeveloper}
|
||||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
selectedCategory={selectedCategory}
|
||||||
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
|
selectedSource={selectedSource}
|
||||||
{allGenres.sort().map(genre => (
|
onGenreChange={setSelectedGenre}
|
||||||
<DropdownMenuItem key={genre} onClick={() => setSelectedGenre(genre)}>{genre}</DropdownMenuItem>
|
onStudioChange={setSelectedStudio}
|
||||||
))}
|
onPlatformChange={setSelectedPlatform}
|
||||||
</DropdownMenuContent>
|
onDeveloperChange={setSelectedDeveloper}
|
||||||
</DropdownMenu>
|
onCategoryChange={setSelectedCategory}
|
||||||
|
onSourceChange={setSelectedSource}
|
||||||
|
onClearAll={handleClearAll}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Studio Filter */}
|
<div className="flex items-center gap-3">
|
||||||
<DropdownMenu>
|
{/* Grid item size slider - only show in grid mode */}
|
||||||
<DropdownMenuTrigger asChild>
|
{viewMode === 'grid' && (
|
||||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
<div className="flex items-center gap-3 bg-[#1a1d26] rounded-xl px-4 py-2 border border-white/10">
|
||||||
Studios
|
<span className="text-xs font-bold text-gray-500">Size</span>
|
||||||
</button>
|
<input
|
||||||
</DropdownMenuTrigger>
|
type="range"
|
||||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
min="1"
|
||||||
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
|
max="10"
|
||||||
{allStudios.sort().map(studio => (
|
value={gridItemSize}
|
||||||
<DropdownMenuItem key={studio} onClick={() => setSelectedStudio(studio)}>{studio}</DropdownMenuItem>
|
onChange={(e) => {
|
||||||
))}
|
const newSize = Number(e.target.value);
|
||||||
</DropdownMenuContent>
|
setGridItemSize(newSize);
|
||||||
</DropdownMenu>
|
onGridItemSizeChange?.(newSize);
|
||||||
|
}}
|
||||||
|
className="w-24 h-2 bg-[#0d0f14] rounded-lg appearance-none cursor-pointer accent-[#e8466c]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-bold text-[#e8466c] w-5 text-center">{gridItemSize}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Platform Filter - Only for Games */}
|
{/* View Toggle */}
|
||||||
{activeCategory === 'Games' && (
|
<div className="flex items-center bg-[#1a1d26] rounded-xl p-1 border border-white/10">
|
||||||
<DropdownMenu>
|
<Button
|
||||||
<DropdownMenuTrigger asChild>
|
variant="ghost"
|
||||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
size="icon"
|
||||||
<Monitor size={16} />
|
className={cn(
|
||||||
{selectedPlatform || 'Platforms'}
|
"h-8 w-8 transition-all rounded-lg",
|
||||||
</button>
|
viewMode === 'grid' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
||||||
</DropdownMenuTrigger>
|
)}
|
||||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
onClick={() => setViewMode('grid')}
|
||||||
<DropdownMenuItem onClick={() => setSelectedPlatform(null)}>All Platforms</DropdownMenuItem>
|
>
|
||||||
{allPlatforms.sort().map(platform => (
|
<LayoutGrid size={16} />
|
||||||
<DropdownMenuItem key={platform} onClick={() => setSelectedPlatform(platform)}>{platform}</DropdownMenuItem>
|
</Button>
|
||||||
))}
|
<Button
|
||||||
</DropdownMenuContent>
|
variant="ghost"
|
||||||
</DropdownMenu>
|
size="icon"
|
||||||
)}
|
className={cn(
|
||||||
|
"h-8 w-8 transition-all rounded-lg",
|
||||||
{/* Developer Filter - Only for Games */}
|
viewMode === 'list' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
||||||
{activeCategory === 'Games' && (
|
)}
|
||||||
<DropdownMenu>
|
onClick={() => setViewMode('list')}
|
||||||
<DropdownMenuTrigger asChild>
|
>
|
||||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
<List size={16} />
|
||||||
<Users size={16} />
|
</Button>
|
||||||
{selectedDeveloper || 'Developers'}
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
|
||||||
<DropdownMenuItem onClick={() => setSelectedDeveloper(null)}>All Developers</DropdownMenuItem>
|
|
||||||
{allDevelopers.sort().map(developer => (
|
|
||||||
<DropdownMenuItem key={developer} onClick={() => setSelectedDeveloper(developer)}>{developer}</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Category Filter - Only for Games */}
|
|
||||||
{activeCategory === 'Games' && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
|
||||||
<FolderTree size={16} />
|
|
||||||
{selectedCategory || 'Categories'}
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
|
||||||
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>All Categories</DropdownMenuItem>
|
|
||||||
{allCategories.sort().map(category => (
|
|
||||||
<DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Source Filter */}
|
|
||||||
{allSources.length > 0 && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
|
||||||
<Tag size={16} />
|
|
||||||
{selectedSource || 'Source'}
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
|
||||||
<DropdownMenuItem onClick={() => setSelectedSource(null)}>All Sources</DropdownMenuItem>
|
|
||||||
{allSources.sort().map(source => (
|
|
||||||
<DropdownMenuItem key={source} onClick={() => setSelectedSource(source)}>{source}</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory || selectedSource) && (
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground font-bold hover:text-[#6d28d9] transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedGenre(null);
|
|
||||||
setSelectedStudio(null);
|
|
||||||
setSelectedPlatform(null);
|
|
||||||
setSelectedDeveloper(null);
|
|
||||||
setSelectedCategory(null);
|
|
||||||
setSelectedSource(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear Filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{/* Search Results Summary */}
|
||||||
{/* Grid item size slider */}
|
{hasSearchResults && (
|
||||||
<div className="flex items-center gap-3 bg-muted/50 backdrop-blur-sm rounded-xl px-4 py-2.5 border border-border/50">
|
<div className="flex items-center gap-4 mb-4 p-3 bg-[#1a1d26] rounded-lg border border-white/10">
|
||||||
<span className="text-xs font-bold text-muted-foreground">Size</span>
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<span className="text-sm text-gray-400">Search results for:</span>
|
||||||
type="range"
|
<Badge variant="secondary" className="bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30">
|
||||||
min="1"
|
"{searchQuery}"
|
||||||
max="10"
|
</Badge>
|
||||||
value={gridItemSize}
|
</div>
|
||||||
onChange={(e) => {
|
<div className="flex items-center gap-4 ml-auto">
|
||||||
const newSize = Number(e.target.value);
|
{hasMediaResults && (
|
||||||
setGridItemSize(newSize);
|
<div className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||||
onGridItemSizeChange?.(newSize);
|
<LayoutGrid size={14} />
|
||||||
}}
|
<span>{mediaList.length} media</span>
|
||||||
className="w-24 h-2 bg-background rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
|
</div>
|
||||||
/>
|
|
||||||
<span className="text-xs font-bold text-[#6d28d9] w-5 text-center">{gridItemSize}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 text-muted-foreground font-bold backdrop-blur-sm border-border/50">
|
|
||||||
<ArrowUpDown size={16} />
|
|
||||||
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
|
|
||||||
</button>
|
|
||||||
</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-muted/50 backdrop-blur-sm rounded-xl p-1 border border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-8 transition-all rounded-lg",
|
|
||||||
viewMode === 'grid' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
|
|
||||||
)}
|
)}
|
||||||
onClick={() => setViewMode('grid')}
|
{hasCastResults && (
|
||||||
>
|
<div className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||||
<LayoutGrid size={16} />
|
<Users size={14} />
|
||||||
</Button>
|
<span>{searchResultsCast.length} cast</span>
|
||||||
<Button
|
</div>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-8 transition-all rounded-lg",
|
|
||||||
viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
|
|
||||||
)}
|
)}
|
||||||
onClick={() => setViewMode('list')}
|
</div>
|
||||||
>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content - inside scrollable area */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loading message="Loading media..." />
|
<Loading message="Loading media..." />
|
||||||
) : mediaList.length === 0 ? (
|
) : mediaList.length === 0 && !hasCastResults ? (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-20 text-gray-500">
|
||||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
<div className="w-16 h-16 bg-[#1a1d26] rounded-full flex items-center justify-center mb-4">
|
||||||
<Search size={32} />
|
<span className="text-2xl">📁</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">No results found</p>
|
<p className="text-lg font-bold text-gray-300">No results found</p>
|
||||||
<p className="text-sm">Try adjusting your search or filters</p>
|
<p className="text-sm">Try adjusting your search or filters</p>
|
||||||
</div>
|
</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'
|
{hasSearchResults && (
|
||||||
? cn(gridColsClass, "gap-x-4 gap-y-8")
|
<div className="flex items-center gap-2 mb-4">
|
||||||
: "flex flex-col gap-2"
|
<LayoutGrid size={18} className="text-[#e8466c]" />
|
||||||
)}>
|
<h3 className="text-lg font-bold text-white">Media Results</h3>
|
||||||
<AnimatePresence mode="popLayout">
|
<Badge variant="secondary" className="bg-[#1a1d26] text-gray-400">
|
||||||
{paginatedMedia.map((media) => (
|
{mediaList.length}
|
||||||
viewMode === 'grid' ? (
|
</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
|
<MediaCard
|
||||||
key={media.id}
|
key={media.id}
|
||||||
media={media}
|
media={media}
|
||||||
onClick={onMediaClick}
|
onClick={onMediaClick}
|
||||||
|
showBadge={true}
|
||||||
|
showFavorite={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
))}
|
||||||
<MediaListItem
|
</div>
|
||||||
key={media.id}
|
)}
|
||||||
media={media}
|
</>
|
||||||
onClick={onMediaClick}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* End of scrollable content area */}
|
||||||
{mediaList.length > 0 && (
|
</div>
|
||||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border pt-8">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm text-muted-foreground font-medium">Items per page:</span>
|
|
||||||
<select
|
|
||||||
value={itemsPerPage}
|
|
||||||
onChange={(e) => {
|
|
||||||
setItemsPerPage(Number(e.target.value));
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className="bg-muted border-none rounded-md px-2 py-1 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
|
||||||
>
|
|
||||||
{[12, 20, 36, 48, 60].map(size => (
|
|
||||||
<option key={size} value={size}>{size}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
{/* Sticky Pagination Controls */}
|
||||||
<Button
|
{filteredMedia.length > 0 && (
|
||||||
variant="outline"
|
<div className="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
|
||||||
size="sm"
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
onClick={handlePrevPage}
|
<div className="flex items-center gap-4">
|
||||||
disabled={currentPage === 1}
|
<span className="text-sm text-gray-500 font-medium">Items per page:</span>
|
||||||
className="gap-2 font-bold border-border"
|
<select
|
||||||
>
|
value={itemsPerPage}
|
||||||
<ChevronLeft size={16} />
|
onChange={(e) => {
|
||||||
Previous
|
setItemsPerPage(Number(e.target.value));
|
||||||
</Button>
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
<div className="flex items-center gap-2">
|
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"
|
||||||
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
|
>
|
||||||
<span className="text-sm text-muted-foreground font-medium">of</span>
|
{[12, 20, 36, 48, 60, 100].map(size => (
|
||||||
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Pagination>
|
||||||
variant="outline"
|
<PaginationContent>
|
||||||
size="sm"
|
<PaginationItem>
|
||||||
onClick={handleNextPage}
|
<PaginationPrevious
|
||||||
disabled={currentPage === totalPages || totalPages === 0}
|
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
|
||||||
className="gap-2 font-bold border-border"
|
className={cn(
|
||||||
>
|
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||||
Next
|
currentPage === 1 && "pointer-events-none opacity-50"
|
||||||
<ChevronRight size={16} />
|
)}
|
||||||
</Button>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { Staff, Media } from '@/types';
|
import { Staff, Media } from '@/types';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye, ChevronDown, ListFilter } from 'lucide-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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface CastDetailViewProps {
|
interface CastDetailViewProps {
|
||||||
person: Staff;
|
person: Staff;
|
||||||
@@ -31,51 +46,64 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
}
|
}
|
||||||
return sortOrder === 'asc' ? comparison : -comparison;
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background pb-20">
|
<div className="min-h-screen bg-background pb-16">
|
||||||
{/* Hero Section */}
|
{/* Compact Hero Section */}
|
||||||
<div className="relative h-[50vh] md:h-[60vh] overflow-hidden bg-zinc-900">
|
<div className="relative h-[35vh] md:h-[40vh] overflow-hidden bg-zinc-900">
|
||||||
<img
|
<img
|
||||||
src={person.photo}
|
src={person.photo}
|
||||||
alt={person.name}
|
alt={person.name}
|
||||||
className="w-full h-full object-cover opacity-40 blur-xl scale-110"
|
className="w-full h-full object-cover opacity-30 blur-xl scale-110"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" />
|
||||||
|
|
||||||
<div className="absolute inset-0 flex items-end px-6 pb-12">
|
<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-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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="h-48 md:h-72 rounded-2xl overflow-hidden border-4 border-background shadow-2xl shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<img
|
<Avatar className="h-32 md:h-40 w-auto aspect-[3/4] rounded-none border-3 border-background shadow-2xl">
|
||||||
src={person.photo}
|
<AvatarImage
|
||||||
alt={person.name}
|
src={person.photo}
|
||||||
className="w-full h-full object-cover"
|
alt={person.name}
|
||||||
referrerPolicy="no-referrer"
|
className="object-cover"
|
||||||
/>
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="rounded-none text-3xl">
|
||||||
|
<User className="h-12 w-12" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
</motion.div>
|
</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
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
>
|
>
|
||||||
<h1 className="text-5xl md:text-7xl font-black text-foreground mb-4 drop-shadow-sm">
|
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-3 tracking-tight">
|
||||||
{person.name}
|
{person.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-wrap justify-center md:justify-start gap-3">
|
<div className="flex flex-wrap justify-center md:justify-start gap-2">
|
||||||
{person.occupations?.map(occ => (
|
{person.occupations?.map(occ => (
|
||||||
<Badge key={occ} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 font-bold px-4 py-1.5 backdrop-blur-sm">
|
<Badge key={occ} variant="secondary" className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 font-medium px-3 py-1 text-xs">
|
||||||
{occ}
|
{occ}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{person.filmography && person.filmography.length > 0 && (
|
{person.filmography && person.filmography.length > 0 && (
|
||||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold px-4 py-1.5">
|
<Badge variant="outline" className="border-[#e8466c]/30 text-[#e8466c] font-medium px-3 py-1 text-xs">
|
||||||
{person.filmography.length} Role{person.filmography.length !== 1 ? 's' : ''}
|
<Star className="w-3 h-3 mr-1" />
|
||||||
|
{person.filmography.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -88,285 +116,379 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="absolute top-24 left-6 bg-white/30 hover:bg-white/50 text-white rounded-2xl backdrop-blur-md transition-all duration-300 hover:scale-110 border border-white/20"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Section */}
|
{/* Content Section */}
|
||||||
<div className="max-w-[1920px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
|
<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 */}
|
{/* Sidebar Info - Modern shadcn Design */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-4 lg:col-span-1">
|
||||||
<div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
|
{/* Personal Info Card */}
|
||||||
<h3 className="text-2xl font-black text-foreground">Personal Info</h3>
|
<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>
|
||||||
|
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 />
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Birth Place */}
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
<div className="flex items-center gap-2.5">
|
||||||
<Calendar size={20} />
|
<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>
|
</div>
|
||||||
<div>
|
<span className="text-sm font-medium truncate max-w-[140px]" title={person.birthPlace || undefined}>
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Date</p>
|
{person.birthPlace || '—'}
|
||||||
<p className="font-bold text-foreground">{person.birthDate || 'Unknown'}</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Separator />
|
||||||
|
|
||||||
<div className="flex items-start gap-4">
|
{/* Known For */}
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||||
<MapPin size={20} />
|
<div className="flex items-center gap-2.5">
|
||||||
</div>
|
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
|
||||||
<div>
|
<Briefcase size={14} />
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Place</p>
|
</div>
|
||||||
<p className="font-bold text-foreground">{person.birthPlace || 'Unknown'}</p>
|
<span className="text-xs text-muted-foreground">Role</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
|
||||||
<Briefcase size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Known For</p>
|
|
||||||
<p className="font-bold text-foreground">{person.role}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-xs font-normal bg-[#e8466c]/10 text-[#e8466c] border-none">
|
||||||
|
{person.role}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ethnicity - only if present */}
|
||||||
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
|
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
|
||||||
<div className="flex items-start gap-4">
|
<>
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
<Separator />
|
||||||
<User size={20} />
|
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
|
||||||
|
<User size={14} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">Ethnicity</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium truncate max-w-[140px]">
|
||||||
|
{person.adult_specifics?.ethnicity || person.ethnicity}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</>
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Ethnicity</p>
|
|
||||||
<p className="font-bold text-foreground">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
|
{/* Measurements Card - Only if data exists */}
|
||||||
<h3 className="text-2xl font-black text-foreground">Measurements</h3>
|
{(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) && (
|
||||||
<div className="space-y-4">
|
<Card className="border-border/60 overflow-hidden">
|
||||||
|
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||||
<div className="flex items-start gap-4">
|
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
<div className="w-5 h-5 rounded bg-[#e8466c]/10 flex items-center justify-center">
|
||||||
<Ruler size={20} />
|
<Ruler size={12} className="text-[#e8466c]" />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Height</p>
|
|
||||||
<p className="font-bold text-foreground">{person.adult_specifics?.height || person.height} cm</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
Measurements
|
||||||
|
</CardTitle>
|
||||||
{(person.weight || person.adult_specifics?.weight) && (
|
</CardHeader>
|
||||||
<div className="flex items-start gap-4">
|
<CardContent className="p-0">
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
{/* Height & Weight Grid */}
|
||||||
<Ruler size={20} />
|
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight) && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 divide-x divide-border">
|
||||||
|
{(person.adult_specifics?.height || person.height) && (
|
||||||
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Height</p>
|
||||||
|
<p className="text-lg font-semibold text-foreground">
|
||||||
|
{person.adult_specifics?.height || person.height}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground ml-0.5">cm</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(person.adult_specifics?.weight || person.weight) && (
|
||||||
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Weight</p>
|
||||||
|
<p className="text-lg font-semibold text-foreground">
|
||||||
|
{person.adult_specifics?.weight || person.weight}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground ml-0.5">kg</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<Separator />
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Weight</p>
|
</>
|
||||||
<p className="font-bold text-foreground">{person.adult_specifics?.weight || person.weight} kg</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Measurements (Bust-Waist-Hip) */}
|
||||||
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
|
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
|
||||||
<div className="flex items-start gap-4">
|
<>
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||||
<Ruler size={20} />
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1.5">Figure</p>
|
||||||
</div>
|
<p className="text-sm font-medium font-mono tracking-wide">
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Measurements</p>
|
|
||||||
<p className="font-bold text-foreground">
|
|
||||||
{person.adult_specifics?.measurements || (
|
{person.adult_specifics?.measurements || (
|
||||||
<>
|
<>
|
||||||
{person.bust_size && `${person.bust_size}`}
|
{person.bust_size && <span className="inline-flex items-center gap-0.5">{person.bust_size}{person.cup_size && <span className="text-xs text-muted-foreground">{person.cup_size}</span>}</span>}
|
||||||
{person.cup_size && person.cup_size}
|
{(person.bust_size || person.cup_size) && person.waist_size && <span className="text-muted-foreground mx-1">—</span>}
|
||||||
{person.bust_size || person.cup_size ? '-' : ''}
|
{person.waist_size && <span>{person.waist_size}</span>}
|
||||||
{person.waist_size && `${person.waist_size}`}
|
{person.hip_size && <span className="text-muted-foreground mx-1">—</span>}
|
||||||
{person.waist_size ? '-' : ''}
|
{person.hip_size && <span>{person.hip_size}</span>}
|
||||||
{person.hip_size && `${person.hip_size}`}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Separator />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(person.hair_color || person.adult_specifics?.hair_color) && (
|
{/* Hair & Eyes Grid */}
|
||||||
<div className="flex items-start gap-4">
|
<div className="grid grid-cols-2 divide-x divide-border">
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
{(person.hair_color || person.adult_specifics?.hair_color) && (
|
||||||
<Palette size={20} />
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Palette size={12} className="text-[#e8466c]" />
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Hair</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{person.adult_specifics?.hair_color || person.hair_color}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Hair Color</p>
|
{(person.eye_color || person.adult_specifics?.eye_color) && (
|
||||||
<p className="font-bold text-foreground">{person.adult_specifics?.hair_color || person.hair_color}</p>
|
<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>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{(person.eye_color || person.adult_specifics?.eye_color) && (
|
{/* Tattoos & Piercings */}
|
||||||
<div className="flex items-start gap-4">
|
{(person.adult_specifics?.tattoos || person.adult_specifics?.piercings) && (
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
<>
|
||||||
<Eye size={20} />
|
<Separator />
|
||||||
</div>
|
<div className="grid grid-cols-2 divide-x divide-border">
|
||||||
<div>
|
{person.adult_specifics?.tattoos && (
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Eye Color</p>
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||||
<p className="font-bold text-foreground">{person.adult_specifics?.eye_color || person.eye_color}</p>
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Tattoos</p>
|
||||||
</div>
|
<p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.tattoos}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{person.adult_specifics?.piercings && (
|
||||||
{person.adult_specifics?.tattoos && (
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||||
<div className="flex items-start gap-4">
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Piercings</p>
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
<p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.piercings}</p>
|
||||||
<Palette size={20} />
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Tattoos</p>
|
|
||||||
<p className="font-bold text-foreground">{person.adult_specifics.tattoos}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{person.adult_specifics?.piercings && (
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
|
||||||
<Palette size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Piercings</p>
|
|
||||||
<p className="font-bold text-foreground">{person.adult_specifics.piercings}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Bio & Roles */}
|
|
||||||
<div className="lg:col-span-2 space-y-12">
|
|
||||||
{person.bio && (
|
|
||||||
<section>
|
|
||||||
<h2 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3">
|
|
||||||
Biography
|
|
||||||
</h2>
|
|
||||||
<p className="text-foreground leading-relaxed text-lg">
|
|
||||||
{person.bio}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{person.filmography && person.filmography.length > 0 && (
|
|
||||||
<section>
|
|
||||||
<h2 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3">
|
|
||||||
<User className="text-[#6d28d9]" />
|
|
||||||
Characters
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
||||||
{person.filmography.map(item => (
|
|
||||||
<div
|
|
||||||
key={`${item.id}-char`}
|
|
||||||
className="flex items-center gap-4 p-5 rounded-2xl bg-muted/50 border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg transition-all duration-300"
|
|
||||||
>
|
|
||||||
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-background">
|
|
||||||
<img
|
|
||||||
src={item.poster || person.photo}
|
|
||||||
alt={item.title}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Character</p>
|
|
||||||
<h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4>
|
|
||||||
<button
|
|
||||||
onClick={() => handleMediaClick(item.id.toString())}
|
|
||||||
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left transition-colors"
|
|
||||||
>
|
|
||||||
in {item.title}
|
|
||||||
</button>
|
|
||||||
{item.category && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] font-bold mt-2 bg-muted text-muted-foreground border-none">
|
|
||||||
{item.category}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
))}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</section>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{person.filmography && person.filmography.length > 0 && (
|
{/* Main Bio & Roles - Wider */}
|
||||||
<section>
|
<div className="lg:col-span-3">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<Tabs defaultValue={person.bio ? 'bio' : 'filmography'} className="w-full">
|
||||||
<h2 className="text-3xl font-black text-foreground flex items-center gap-3">
|
<TabsList className="mb-4 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto">
|
||||||
<Film className="text-[#6d28d9]" />
|
{person.bio && (
|
||||||
Filmography
|
<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">
|
||||||
</h2>
|
<BookOpen size={14} />
|
||||||
<div className="flex items-center gap-2">
|
Biography
|
||||||
<Button
|
</TabsTrigger>
|
||||||
variant="outline"
|
)}
|
||||||
size="sm"
|
{person.filmography && person.filmography.length > 0 && (
|
||||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
<>
|
||||||
className="rounded-xl border-border hover:border-[#6d28d9]/50 transition-all duration-300"
|
<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} />
|
||||||
<ListFilter size={16} />
|
Characters
|
||||||
</Button>
|
</TabsTrigger>
|
||||||
<select
|
<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">
|
||||||
value={sortBy}
|
<Film size={14} />
|
||||||
onChange={(e) => setSortBy(e.target.value as 'year' | 'title' | 'role')}
|
Filmography
|
||||||
className="bg-muted/50 backdrop-blur-sm border border-border/50 rounded-xl px-4 py-2 text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-[#6d28d9]/50"
|
</TabsTrigger>
|
||||||
>
|
</>
|
||||||
<option value="year">Year</option>
|
)}
|
||||||
<option value="title">Title</option>
|
</TabsList>
|
||||||
<option value="role">Role</option>
|
|
||||||
</select>
|
{person.bio && (
|
||||||
</div>
|
<TabsContent value="bio" className="mt-0">
|
||||||
</div>
|
<Card className="border-border/60">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<CardHeader className="pb-3">
|
||||||
{sortedFilmography.map(item => (
|
<CardTitle className="text-base font-semibold">Biography</CardTitle>
|
||||||
<div
|
</CardHeader>
|
||||||
key={item.id}
|
<CardContent className="pt-0">
|
||||||
onClick={() => handleMediaClick(item.id.toString())}
|
<p className="text-foreground leading-relaxed text-sm">
|
||||||
className="group flex items-center gap-4 p-4 rounded-2xl bg-card border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300 cursor-pointer"
|
{person.bio}
|
||||||
>
|
</p>
|
||||||
<div className="w-16 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border border-border/30">
|
</CardContent>
|
||||||
<img
|
</Card>
|
||||||
src={item.poster || person.photo}
|
</TabsContent>
|
||||||
alt={item.title}
|
)}
|
||||||
className="w-full h-full object-cover"
|
|
||||||
referrerPolicy="no-referrer"
|
{person.filmography && person.filmography.length > 0 && (
|
||||||
/>
|
<>
|
||||||
</div>
|
<TabsContent value="characters" className="mt-0">
|
||||||
<div className="min-w-0">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<h4 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
|
<AnimatePresence mode="popLayout">
|
||||||
{item.title}
|
{person.filmography.map((item, index) => (
|
||||||
</h4>
|
<motion.div
|
||||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1">
|
key={`${item.id}-char`}
|
||||||
{item.year || 'Unknown'}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
</p>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="flex items-center gap-2">
|
transition={{ delay: index * 0.03 }}
|
||||||
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-border/50">
|
>
|
||||||
{item.role}
|
<Card
|
||||||
</Badge>
|
className="hover:border-[#e8466c]/30 hover:shadow-md transition-all duration-200 cursor-pointer group border-border/60"
|
||||||
{item.category && (
|
onClick={() => handleMediaClick(item.id.toString())}
|
||||||
<Badge variant="secondary" className="text-[10px] font-bold py-0 h-5 bg-muted text-muted-foreground border-none">
|
>
|
||||||
{item.category}
|
<CardContent className="p-3 flex items-center gap-3">
|
||||||
</Badge>
|
<div className="w-14 h-14 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
|
||||||
)}
|
<img
|
||||||
</div>
|
src={item.poster || person.photo}
|
||||||
|
alt={item.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Character</p>
|
||||||
|
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#e8466c] transition-colors">
|
||||||
|
{item.characterName || item.role}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-[#e8466c] truncate">{item.title}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="filmography" className="mt-0">
|
||||||
|
{/* Sort Toolbar */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{person.filmography.length} {person.filmography.length === 1 ? 'title' : 'titles'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2.5 rounded-lg text-xs border-border/60"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} className="mr-1.5" />
|
||||||
|
{sortOrder === 'asc' ? <ArrowUpAZ size={14} className="mr-1.5" /> : <ArrowDownAZ size={14} className="mr-1.5" />}
|
||||||
|
{sortOptions.find(o => o.value === sortBy)?.label}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
|
||||||
|
Sort by
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{sortOptions.map(option => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
if (sortBy === option.value) {
|
||||||
|
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortBy(option.value);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<option.icon size={14} />
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
{sortBy === option.value && (
|
||||||
|
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#e8466c]" /> : <ArrowDownAZ size={14} className="text-[#e8466c]" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
{/* Filmography Grid */}
|
||||||
</section>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,49 @@
|
|||||||
import { Staff, MediaCategory } from '@/types';
|
import { Staff, MediaCategory } from '@/types';
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from 'lucide-react';
|
import {
|
||||||
|
Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter,
|
||||||
|
LayoutGrid, Table2, Eye, Calendar, Star, ArrowUpAZ, ArrowDownAZ,
|
||||||
|
Briefcase, Film, Users, ChevronUp, ChevronDown
|
||||||
|
} from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { 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 Loading from '@/components/ui/loading';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -30,14 +69,19 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
|
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
|
||||||
});
|
});
|
||||||
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
|
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
|
||||||
return localStorage.getItem('castFilterOccupation') || '';
|
const saved = localStorage.getItem('castFilterOccupation');
|
||||||
|
return saved && saved !== '' ? saved : 'all';
|
||||||
});
|
});
|
||||||
const [filterMediaType, setFilterMediaType] = useState<string>(() => {
|
const [filterMediaType, setFilterMediaType] = useState<string>(() => {
|
||||||
return localStorage.getItem('castFilterMediaType') || '';
|
const saved = localStorage.getItem('castFilterMediaType');
|
||||||
|
return saved && saved !== '' ? saved : 'all';
|
||||||
});
|
});
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [viewMode, setViewMode] = useState<'grid' | 'table'>(() => {
|
||||||
|
return (localStorage.getItem('castViewMode') as 'grid' | 'table') || 'grid';
|
||||||
|
});
|
||||||
|
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
||||||
|
|
||||||
// Sync itemsPerPage with prop when API settings are loaded
|
// Sync itemsPerPage with prop when API settings are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,11 +115,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSortBy('roleCount');
|
setSortBy('roleCount');
|
||||||
setSortOrder('desc');
|
setSortOrder('desc');
|
||||||
setFilterOccupation('');
|
setFilterOccupation('all');
|
||||||
setFilterMediaType('');
|
setFilterMediaType('all');
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
const hasActiveFilters = searchQuery || (filterOccupation && filterOccupation !== 'all') || (filterMediaType && filterMediaType !== 'all') || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCast = async () => {
|
const loadCast = async () => {
|
||||||
@@ -93,11 +137,6 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
|
|
||||||
const filteredStaff = useMemo(() => {
|
const filteredStaff = useMemo(() => {
|
||||||
let list = staffList.filter(s => {
|
let list = staffList.filter(s => {
|
||||||
// Hide actors without linked media
|
|
||||||
if (!s.filmography || s.filmography.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by enabled categories based on media_types
|
// Filter by enabled categories based on media_types
|
||||||
if (s.media_types && s.media_types.length > 0) {
|
if (s.media_types && s.media_types.length > 0) {
|
||||||
const hasEnabledMediaType = s.media_types.some(type => {
|
const hasEnabledMediaType = s.media_types.some(type => {
|
||||||
@@ -110,12 +149,12 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter by occupation
|
// Filter by occupation
|
||||||
if (filterOccupation && !s.occupations?.includes(filterOccupation)) {
|
if (filterOccupation && filterOccupation !== 'all' && !s.occupations?.includes(filterOccupation)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by media type
|
// Filter by media type
|
||||||
if (filterMediaType && !s.media_types?.includes(filterMediaType)) {
|
if (filterMediaType && filterMediaType !== 'all' && !s.media_types?.includes(filterMediaType)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,270 +214,602 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
return filteredStaff.slice(startIndex, startIndex + itemsPerPage);
|
return filteredStaff.slice(startIndex, startIndex + itemsPerPage);
|
||||||
}, [filteredStaff, currentPage, itemsPerPage]);
|
}, [filteredStaff, currentPage, itemsPerPage]);
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage((prev) => Math.max(prev - 1, 1));
|
setCurrentPage(page);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
const scrollContainer = document.getElementById('cast-scroll-container');
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextPage = () => {
|
// Generate pagination items with ellipsis
|
||||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
|
const getPaginationItems = () => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
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 (
|
return (
|
||||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
<TooltipProvider>
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
|
<div className="flex flex-col h-[calc(100vh-4rem-4rem)] w-full">
|
||||||
<div>
|
{/* Sticky Header - Filters */}
|
||||||
<h1 className="text-5xl font-black text-foreground mb-3 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
<div className="px-6 pt-4 pb-4 bg-background border-b border-white/10 shrink-0 z-10">
|
||||||
Cast & Staff
|
{/* Compact Toolbar - Like MediaFilters */}
|
||||||
</h1>
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-muted-foreground font-medium text-lg">Discover the people behind your favorite media</p>
|
{/* Top Row: Search, View Toggle, Count */}
|
||||||
</div>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-[320px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||||
|
<Input
|
||||||
|
placeholder="Search cast..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 h-9 bg-muted/50 border-none rounded-lg text-sm focus-visible:ring-[#e8466c]/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{/* View Toggle */}
|
||||||
<div className="relative">
|
<ToggleGroup
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
|
type="single"
|
||||||
<Input
|
value={viewMode}
|
||||||
placeholder="Search cast..."
|
onValueChange={(value: string | string[]) => {
|
||||||
value={searchQuery}
|
const v = Array.isArray(value) ? value[0] : value;
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
if (v === 'grid' || v === 'table') {
|
||||||
className="pl-10 w-full md:w-[300px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-11"
|
setViewMode(v);
|
||||||
/>
|
}
|
||||||
</div>
|
}}
|
||||||
<Button
|
className="bg-muted/50 p-0.5 rounded-lg"
|
||||||
variant={showFilters ? 'default' : 'outline'}
|
|
||||||
size="icon"
|
|
||||||
className={`rounded-xl h-11 w-11 transition-all duration-300 ${showFilters ? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white border-[#6d28d9]' : 'border-border hover:border-[#6d28d9]/50'}`}
|
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
|
||||||
>
|
|
||||||
<Filter size={20} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="rounded-xl h-11 w-11 border-border hover:border-[#6d28d9]/50 transition-all duration-300"
|
|
||||||
onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
|
|
||||||
>
|
|
||||||
<ArrowUpDown size={20} />
|
|
||||||
</Button>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="rounded-xl h-11 w-11 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-300"
|
|
||||||
onClick={handleResetFilters}
|
|
||||||
title="Reset filters"
|
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<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">
|
||||||
</Button>
|
<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>
|
|
||||||
|
|
||||||
{showFilters && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 mb-6 border border-border/50"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-bold text-foreground mb-2 block">Sort By</label>
|
|
||||||
<select
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(e) => setSortBy(e.target.value as any)}
|
|
||||||
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
|
||||||
>
|
|
||||||
<option value="name">Name</option>
|
|
||||||
<option value="role">Role</option>
|
|
||||||
<option value="birthDate">Birth Date</option>
|
|
||||||
<option value="height">Height</option>
|
|
||||||
<option value="roleCount">Role Count</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-bold text-foreground mb-2 block">Occupation</label>
|
|
||||||
<select
|
|
||||||
value={filterOccupation}
|
|
||||||
onChange={(e) => setFilterOccupation(e.target.value)}
|
|
||||||
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
|
||||||
>
|
|
||||||
<option value="">All Occupations</option>
|
|
||||||
{uniqueOccupations.map(occ => (
|
|
||||||
<option key={occ} value={occ}>{occ}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-bold text-foreground mb-2 block">Media Type</label>
|
|
||||||
<select
|
|
||||||
value={filterMediaType}
|
|
||||||
onChange={(e) => setFilterMediaType(e.target.value)}
|
|
||||||
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
|
||||||
>
|
|
||||||
<option value="">All Media Types</option>
|
|
||||||
{uniqueMediaTypes.map(type => (
|
|
||||||
<option key={type} value={type}>{type}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
|
||||||
{searchQuery && (
|
|
||||||
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
|
||||||
Search: {searchQuery}
|
|
||||||
<button onClick={() => setSearchQuery('')} className="hover:text-foreground">
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{filterOccupation && (
|
|
||||||
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
|
||||||
Occupation: {filterOccupation}
|
|
||||||
<button onClick={() => setFilterOccupation('')} className="hover:text-foreground">
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{filterMediaType && (
|
|
||||||
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
|
||||||
Media Type: {filterMediaType}
|
|
||||||
<button onClick={() => setFilterMediaType('')} className="hover:text-foreground">
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{(sortBy !== 'name' || sortOrder !== 'asc') && (
|
|
||||||
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
|
||||||
Sort: {sortBy} ({sortOrder})
|
|
||||||
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-foreground">
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<Loading message="Loading cast..." />
|
|
||||||
) : filteredStaff.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
|
|
||||||
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-sm border border-border/50">
|
|
||||||
<User size={40} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xl font-bold">No cast members found</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{paginatedStaff.map((person) => (
|
|
||||||
<motion.div
|
|
||||||
key={person.id}
|
|
||||||
layout
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
|
||||||
className="group bg-card rounded-2xl p-5 shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 hover:shadow-[#6d28d9]/10 transition-all duration-300 cursor-pointer"
|
|
||||||
onClick={() => onPersonClick(person)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
|
||||||
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-border/50 group-hover:border-[#6d28d9] transition-colors duration-300">
|
|
||||||
<img
|
|
||||||
src={person.photo}
|
|
||||||
alt={person.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
|
|
||||||
{person.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
|
||||||
{person.role}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{person.filmography && person.filmography.length > 0 && (
|
|
||||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold text-[10px] px-2 py-0.5 shrink-0">
|
|
||||||
{person.filmography.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{person.filmography && person.filmography.length > 0 && (
|
{/* Scrollable Content Area */}
|
||||||
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-3 flex items-center gap-3 border border-border/30">
|
<div id="cast-scroll-container" className="flex-1 overflow-y-auto px-6 pt-4 pb-20">
|
||||||
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background border border-border/30">
|
{/* Content Area */}
|
||||||
<img
|
{loading ? (
|
||||||
src={person.filmography[0].poster || person.photo}
|
<Loading message="Loading cast..." />
|
||||||
alt={person.filmography[0].title}
|
) : filteredStaff.length === 0 ? (
|
||||||
className="w-full h-full object-cover"
|
<Card className="border-dashed">
|
||||||
referrerPolicy="no-referrer"
|
<CardContent className="flex flex-col items-center justify-center py-32 text-muted-foreground">
|
||||||
/>
|
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6">
|
||||||
|
<User size={40} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold">No cast members found</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : viewMode === 'grid' ? (
|
||||||
|
/* Grid View - Modern Cards */
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{paginatedStaff.map((person) => (
|
||||||
|
<motion.div
|
||||||
|
key={person.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className="group cursor-pointer overflow-hidden hover:shadow-xl hover:border-[#e8466c]/30 transition-all duration-300 border-border/60"
|
||||||
|
onClick={() => onPersonClick(person)}
|
||||||
|
>
|
||||||
|
{/* Card Header with Avatar and Info */}
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Avatar className="h-12 w-12 rounded-lg border-2 border-border/50 group-hover:border-[#e8466c] transition-colors duration-300 shadow-sm">
|
||||||
|
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" className="object-cover" />
|
||||||
|
<AvatarFallback className="rounded-lg bg-muted">
|
||||||
|
<User className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold text-foreground truncate group-hover:text-[#e8466c] transition-colors duration-300 text-sm leading-tight">
|
||||||
|
{person.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||||
|
{person.role}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1.5">
|
||||||
|
{person.filmography && person.filmography.length > 0 && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 bg-muted">
|
||||||
|
<Star className="w-2.5 h-2.5 mr-0.5" />
|
||||||
|
{person.filmography.length}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{person.filmography.length} roles</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{person.birthDate && (
|
||||||
|
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
|
||||||
|
<Calendar className="w-2.5 h-2.5" />
|
||||||
|
{new Date(person.birthDate).getFullYear()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest leading-none mb-1">Latest Role</p>
|
|
||||||
<p className="text-xs font-bold text-foreground truncate">{person.filmography[0].title}</p>
|
|
||||||
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">{person.filmography[0].role}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* Latest Role Section */}
|
||||||
{filteredStaff.length > 0 && (
|
{person.filmography && person.filmography.length > 0 && (
|
||||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border/50 pt-8">
|
<div className="px-3 pb-3">
|
||||||
<div className="flex items-center gap-4">
|
<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">
|
||||||
<span className="text-sm text-muted-foreground font-medium">Items per page:</span>
|
<div className="w-8 h-11 rounded overflow-hidden shrink-0 bg-background border border-border/40">
|
||||||
<select
|
<img
|
||||||
value={itemsPerPage}
|
src={person.filmography[0].poster || person.photo}
|
||||||
onChange={(e) => {
|
alt={person.filmography[0].title}
|
||||||
setItemsPerPage(Number(e.target.value));
|
className="w-full h-full object-cover"
|
||||||
}}
|
referrerPolicy="no-referrer"
|
||||||
className="bg-muted/50 backdrop-blur-sm border-none rounded-xl px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
/>
|
||||||
>
|
</div>
|
||||||
{[12, 20, 36, 48, 60].map(size => (
|
<div className="min-w-0 flex-1">
|
||||||
<option key={size} value={size}>{size}</option>
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide leading-none">Latest</p>
|
||||||
|
<p className="text-[11px] font-medium text-foreground truncate">{person.filmography[0].title}</p>
|
||||||
|
<p className="text-[10px] text-[#e8466c] truncate">{person.filmography[0].role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</select>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Table View */
|
||||||
|
<Table className="w-full table-fixed">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent border-border/60 bg-muted/30">
|
||||||
|
<TableHead className="w-14 rounded-tl-lg"></TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer hover:text-[#e8466c] transition-colors"
|
||||||
|
onClick={() => handleSort('name')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Name
|
||||||
|
{sortBy === 'name' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer hover:text-[#e8466c] transition-colors"
|
||||||
|
onClick={() => handleSort('role')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Role
|
||||||
|
{sortBy === 'role' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">Latest Work</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="hidden sm:table-cell cursor-pointer hover:text-[#e8466c] transition-colors text-right"
|
||||||
|
onClick={() => handleSort('roleCount')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
Roles
|
||||||
|
{sortBy === 'roleCount' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-10 rounded-tr-lg"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{paginatedStaff.map((person) => (
|
||||||
|
<motion.tr
|
||||||
|
key={person.id}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className={cn(
|
||||||
|
"group cursor-pointer border-border/40 transition-colors",
|
||||||
|
hoveredRow === person.id ? "bg-muted/60" : "hover:bg-muted/40"
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setHoveredRow(person.id)}
|
||||||
|
onMouseLeave={() => setHoveredRow(null)}
|
||||||
|
onClick={() => onPersonClick(person)}
|
||||||
|
>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<Avatar className="h-10 w-10 rounded-lg border border-border/50">
|
||||||
|
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" />
|
||||||
|
<AvatarFallback className="rounded-lg bg-muted">
|
||||||
|
<User className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="group-hover:text-[#e8466c] transition-colors">{person.name}</span>
|
||||||
|
{person.birthDate && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(person.birthDate).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary" className="font-normal bg-muted/80 text-muted-foreground">
|
||||||
|
{person.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
{person.filmography && person.filmography.length > 0 ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-10 rounded overflow-hidden shrink-0 bg-muted">
|
||||||
|
<img
|
||||||
|
src={person.filmography[0].poster || person.photo}
|
||||||
|
alt={person.filmography[0].title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm truncate">{person.filmography[0].title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{person.filmography[0].role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell text-right">
|
||||||
|
{person.filmography ? (
|
||||||
|
<Badge variant="outline" className="font-medium">
|
||||||
|
{person.filmography.length}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPersonClick(person);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
{/* End of scrollable content area */}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handlePrevPage}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300"
|
|
||||||
>
|
|
||||||
<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-muted-foreground font-medium">of</span>
|
|
||||||
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleNextPage}
|
|
||||||
disabled={currentPage === totalPages || totalPages === 0}
|
|
||||||
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Sticky Pagination Controls */}
|
||||||
|
{filteredStaff.length > 0 && (
|
||||||
|
<div className="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-500 font-medium">Items per page:</span>
|
||||||
|
<select
|
||||||
|
value={itemsPerPage}
|
||||||
|
onChange={(e) => {
|
||||||
|
setItemsPerPage(Number(e.target.value));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="bg-[#1a1d26] border border-white/10 rounded-md px-2 py-1 text-sm font-medium text-gray-300 focus:ring-2 focus:ring-[#e8466c] outline-none"
|
||||||
|
>
|
||||||
|
{[12, 20, 36, 48, 60, 100].map(size => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
|
||||||
|
className={cn(
|
||||||
|
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||||
|
currentPage === 1 && "pointer-events-none opacity-50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{getPaginationItems().map((item, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{item === 'ellipsis-start' || item === 'ellipsis-end' ? (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={currentPage === item}
|
||||||
|
onClick={() => handlePageChange(item as number)}
|
||||||
|
className={cn(
|
||||||
|
"border-white/10",
|
||||||
|
currentPage === item
|
||||||
|
? "bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30"
|
||||||
|
: "bg-transparent text-gray-300 hover:bg-white/5 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
|
||||||
|
className={cn(
|
||||||
|
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||||
|
(currentPage === totalPages || totalPages === 0) && "pointer-events-none opacity-50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import { Media, MediaCategory } from '@/types';
|
import { Media, MediaCategory } from '@/types';
|
||||||
import MediaCard from './MediaCard';
|
import MediaCard from './MediaCard';
|
||||||
import { Film, Tv, Music, Book, Gamepad2, Users, Star, TrendingUp, Clock, Hash, Play, Award } from 'lucide-react';
|
import {
|
||||||
|
Film,
|
||||||
|
Tv,
|
||||||
|
Gamepad2,
|
||||||
|
Users,
|
||||||
|
Heart,
|
||||||
|
FolderKanban,
|
||||||
|
Database,
|
||||||
|
Sparkles,
|
||||||
|
Clock,
|
||||||
|
ChevronRight,
|
||||||
|
Eye
|
||||||
|
} from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import Loading from '@/components/ui/loading';
|
import Loading from '@/components/ui/loading';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface DashboardViewProps {
|
interface DashboardViewProps {
|
||||||
mediaList: Media[];
|
mediaList: Media[];
|
||||||
@@ -12,253 +25,214 @@ interface DashboardViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardView({ mediaList, onMediaClick, loading = false }: DashboardViewProps) {
|
export default function DashboardView({ mediaList, onMediaClick, loading = false }: DashboardViewProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Calculate statistics
|
// Calculate statistics
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const totalMedia = mediaList.length;
|
|
||||||
const categories = mediaList.reduce((acc, media) => {
|
const categories = mediaList.reduce((acc, media) => {
|
||||||
acc[media.category] = (acc[media.category] || 0) + 1;
|
acc[media.category] = (acc[media.category] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<MediaCategory, number>);
|
}, {} as Record<MediaCategory, number>);
|
||||||
|
|
||||||
const totalRating = mediaList.reduce((sum, media) => sum + (media.rating || 0), 0);
|
const favoritesCount = mediaList.filter(m => m.rating && m.rating >= 8).length;
|
||||||
const avgRating = totalRating > 0 ? (totalRating / mediaList.filter(m => m.rating).length).toFixed(1) : '0.0';
|
|
||||||
|
|
||||||
const totalPlaytime = mediaList.reduce((sum, media) => sum + (media.playtime || 0), 0);
|
|
||||||
const totalPlayCount = mediaList.reduce((sum, media) => sum + (media.playCount || 0), 0);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalMedia,
|
movies: categories['Movies'] || 0,
|
||||||
categories,
|
series: categories['TV Series'] || 0,
|
||||||
avgRating,
|
games: categories['Games'] || 0,
|
||||||
totalPlaytime,
|
adult: categories['Adult'] || 0,
|
||||||
totalPlayCount
|
actors: new Set(mediaList.flatMap(m => m.staff?.map(s => s.id) || [])).size,
|
||||||
|
collections: 3, // Placeholder
|
||||||
|
favorites: favoritesCount
|
||||||
};
|
};
|
||||||
}, [mediaList]);
|
}, [mediaList]);
|
||||||
|
|
||||||
// Get recently added media (sorted by some indicator - using index as proxy)
|
// Get recently added media
|
||||||
const recentMedia = useMemo(() => {
|
const recentMedia = useMemo(() => {
|
||||||
return [...mediaList].slice(0, 8);
|
return [...mediaList].slice(0, 10);
|
||||||
}, [mediaList]);
|
}, [mediaList]);
|
||||||
|
|
||||||
// Get top rated media
|
// Get favorites
|
||||||
const topRatedMedia = useMemo(() => {
|
const favoritesMedia = useMemo(() => {
|
||||||
return [...mediaList]
|
return [...mediaList]
|
||||||
.filter(m => m.rating && m.rating > 0)
|
.filter(m => m.rating && m.rating >= 8)
|
||||||
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}, [mediaList]);
|
}, [mediaList]);
|
||||||
|
|
||||||
// Get most played media
|
// Category card config
|
||||||
const mostPlayedMedia = useMemo(() => {
|
const categoryCards = [
|
||||||
return [...mediaList]
|
{
|
||||||
.filter(m => m.playCount && m.playCount > 0)
|
key: 'movies',
|
||||||
.sort((a, b) => (b.playCount || 0) - (a.playCount || 0))
|
label: 'MOVIES',
|
||||||
.slice(0, 8);
|
count: stats.movies,
|
||||||
}, [mediaList]);
|
icon: Film,
|
||||||
|
color: 'from-blue-500/20 to-blue-600/10',
|
||||||
// Category icons mapping
|
iconBg: 'bg-blue-500/20',
|
||||||
const categoryIcons: Record<MediaCategory, any> = {
|
path: '/movies'
|
||||||
'Anime': Tv,
|
},
|
||||||
'Movies': Film,
|
{
|
||||||
'TV Series': Tv,
|
key: 'series',
|
||||||
'Music': Music,
|
label: 'SERIES',
|
||||||
'Books': Book,
|
count: stats.series,
|
||||||
'Games': Gamepad2,
|
icon: Tv,
|
||||||
'Consoles': Gamepad2,
|
color: 'from-green-500/20 to-green-600/10',
|
||||||
'Adult': Users
|
iconBg: 'bg-green-500/20',
|
||||||
};
|
path: '/tv-series'
|
||||||
|
},
|
||||||
// Category colors
|
{
|
||||||
const categoryColors: Record<MediaCategory, string> = {
|
key: 'games',
|
||||||
'Anime': 'bg-purple-500/10 text-purple-500 border-purple-500/20',
|
label: 'GAMES',
|
||||||
'Movies': 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
count: stats.games,
|
||||||
'TV Series': 'bg-green-500/10 text-green-500 border-green-500/20',
|
icon: Gamepad2,
|
||||||
'Music': 'bg-pink-500/10 text-pink-500 border-pink-500/20',
|
color: 'from-purple-500/20 to-purple-600/10',
|
||||||
'Books': 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
|
iconBg: 'bg-purple-500/20',
|
||||||
'Games': 'bg-red-500/10 text-red-500 border-red-500/20',
|
path: '/games'
|
||||||
'Consoles': 'bg-orange-500/10 text-orange-500 border-orange-500/20',
|
},
|
||||||
'Adult': 'bg-gray-500/10 text-gray-500 border-gray-500/20'
|
{
|
||||||
};
|
key: 'adult',
|
||||||
|
label: 'ADULT',
|
||||||
const formatPlaytime = (minutes: number) => {
|
count: stats.adult,
|
||||||
if (minutes < 60) return `${minutes}m`;
|
icon: Eye,
|
||||||
const hours = Math.floor(minutes / 60);
|
color: 'from-rose-500/20 to-rose-600/10',
|
||||||
const mins = minutes % 60;
|
iconBg: 'bg-rose-500/20',
|
||||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
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) {
|
if (loading) {
|
||||||
return <Loading message="Loading dashboard..." />;
|
return <Loading message="Loading dashboard..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
<div className="pt-6 pb-20 px-6 max-w-[1920px] mx-auto">
|
||||||
{/* Header */}
|
{/* Welcome Header */}
|
||||||
<div className="mb-10">
|
<motion.div
|
||||||
<h1 className="text-5xl font-black text-foreground mb-3 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
Dashboard
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</h1>
|
className="mb-8"
|
||||||
<p className="text-muted-foreground font-medium text-lg">Overview of your media collection</p>
|
>
|
||||||
</div>
|
<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 */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
|
<motion.div
|
||||||
<motion.div
|
initial={{ opacity: 0, y: 20 }}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
transition={{ delay: 0.1 }}
|
||||||
transition={{ delay: 0.1 }}
|
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-8"
|
||||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-[#6d28d9]/10 to-[#8b5cf6]/5 border border-[#6d28d9]/20 hover:border-[#6d28d9]/40 transition-all duration-300 hover:shadow-lg hover:shadow-[#6d28d9]/10"
|
>
|
||||||
>
|
{categoryCards.map((card, index) => {
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-[#6d28d9]/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
const Icon = card.icon;
|
||||||
<div className="relative">
|
return (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<motion.div
|
||||||
<Hash className="w-10 h-10 text-[#6d28d9]" />
|
key={card.key}
|
||||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
|
initial={{ opacity: 0, y: 20 }}
|
||||||
</div>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="text-4xl font-black text-foreground">{stats.totalMedia}</div>
|
transition={{ delay: 0.1 + index * 0.05 }}
|
||||||
<div className="text-sm text-muted-foreground font-medium mt-1">Media Items</div>
|
onClick={() => navigate(card.path)}
|
||||||
</div>
|
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`}
|
||||||
</motion.div>
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
<motion.div
|
<div>
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">{card.label}</p>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<p className="text-3xl font-bold text-foreground">{card.count}</p>
|
||||||
transition={{ delay: 0.2 }}
|
</div>
|
||||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-yellow-500/10 to-amber-500/5 border border-yellow-500/20 hover:border-yellow-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-yellow-500/10"
|
<div className={`w-10 h-10 rounded-lg ${card.iconBg} flex items-center justify-center`}>
|
||||||
>
|
<Icon className="w-5 h-5 text-white" />
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-yellow-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
</div>
|
||||||
<div className="relative">
|
</div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
</motion.div>
|
||||||
<Star className="w-10 h-10 text-yellow-500" />
|
);
|
||||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Average</span>
|
})}
|
||||||
</div>
|
</motion.div>
|
||||||
<div className="text-4xl font-black text-foreground">{stats.avgRating}</div>
|
|
||||||
<div className="text-sm text-muted-foreground font-medium mt-1">Rating</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
|
{/* Favorites Section */}
|
||||||
|
{favoritesMedia.length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.3 }}
|
transition={{ delay: 0.3 }}
|
||||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-green-500/10 to-emerald-500/5 border border-green-500/20 hover:border-green-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-green-500/10"
|
className="mb-8"
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-green-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
<div
|
||||||
<div className="relative">
|
onClick={() => navigate('/browse?favorites=true')}
|
||||||
<div className="flex items-center justify-between mb-4">
|
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"
|
||||||
<Play className="w-10 h-10 text-green-500" />
|
>
|
||||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-4xl font-black text-foreground">{stats.totalPlayCount}</div>
|
<div className="w-12 h-12 rounded-xl bg-[#e8466c]/20 flex items-center justify-center">
|
||||||
<div className="text-sm text-muted-foreground font-medium mt-1">Play Count</div>
|
<Heart className="w-6 h-6 text-[#e8466c]" />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-[#e8466c] uppercase tracking-wider">FAVORITES</p>
|
||||||
<motion.div
|
<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>
|
||||||
initial={{ opacity: 0, y: 20 }}
|
</div>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.4 }}
|
|
||||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-blue-500/10 to-cyan-500/5 border border-blue-500/20 hover:border-blue-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/10"
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<Clock className="w-10 h-10 text-blue-500" />
|
|
||||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-4xl font-black text-foreground">{formatPlaytime(stats.totalPlaytime)}</div>
|
|
||||||
<div className="text-sm text-muted-foreground font-medium mt-1">Playtime</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category Breakdown */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
className="relative overflow-hidden rounded-2xl p-8 bg-gradient-to-br from-muted/50 to-muted/30 border border-border mb-10"
|
|
||||||
>
|
|
||||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
|
||||||
<TrendingUp className="w-6 h-6 text-[#6d28d9]" />
|
|
||||||
Category Breakdown
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-4">
|
|
||||||
{(Object.keys(stats.categories) as MediaCategory[]).map((category) => {
|
|
||||||
const Icon = categoryIcons[category];
|
|
||||||
const count = stats.categories[category] || 0;
|
|
||||||
const percentage = stats.totalMedia > 0 ? ((count / stats.totalMedia) * 100).toFixed(1) : '0';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={category}
|
|
||||||
className={`rounded-xl p-5 border backdrop-blur-sm transition-all duration-300 hover:scale-105 hover:shadow-lg ${categoryColors[category]} flex flex-col items-center justify-center gap-2`}
|
|
||||||
>
|
|
||||||
<Icon className="w-7 h-7" />
|
|
||||||
<div className="text-xs font-bold uppercase tracking-wider">{category}</div>
|
|
||||||
<div className="text-3xl font-black">{count}</div>
|
|
||||||
<div className="text-xs font-medium opacity-75">{percentage}%</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<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>
|
||||||
</div>
|
<ChevronRight className="w-5 h-5" />
|
||||||
</motion.div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recent Media */}
|
{/* Recently Added Section */}
|
||||||
{recentMedia.length > 0 && (
|
{recentMedia.length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.6 }}
|
transition={{ delay: 0.4 }}
|
||||||
className="mb-10"
|
className="mb-8"
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<Clock className="w-6 h-6 text-[#6d28d9]" />
|
<div className="flex items-center gap-3">
|
||||||
Recent Additions
|
<Clock className="w-5 h-5 text-[#e8466c]" />
|
||||||
</h2>
|
<h2 className="text-sm font-bold text-foreground uppercase tracking-wider">Recently Added</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
|
</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) => (
|
{recentMedia.map((media) => (
|
||||||
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
|
<MediaCard
|
||||||
))}
|
key={media.id}
|
||||||
</div>
|
media={media}
|
||||||
</motion.div>
|
onClick={onMediaClick}
|
||||||
)}
|
showBadge={true}
|
||||||
|
showFavorite={true}
|
||||||
{/* Top Rated Media */}
|
/>
|
||||||
{topRatedMedia.length > 0 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.7 }}
|
|
||||||
className="mb-10"
|
|
||||||
>
|
|
||||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
|
||||||
<Award className="w-6 h-6 text-[#6d28d9]" />
|
|
||||||
Top Rated
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
|
|
||||||
{topRatedMedia.map((media) => (
|
|
||||||
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Most Played Media */}
|
|
||||||
{mostPlayedMedia.length > 0 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.8 }}
|
|
||||||
className="mb-10"
|
|
||||||
>
|
|
||||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
|
||||||
<Play className="w-6 h-6 text-[#6d28d9]" />
|
|
||||||
Most Played
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
|
|
||||||
{mostPlayedMedia.map((media) => (
|
|
||||||
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -267,10 +241,10 @@ export default function DashboardView({ mediaList, onMediaClick, loading = false
|
|||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{mediaList.length === 0 && (
|
{mediaList.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
|
||||||
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-sm border border-border/50">
|
<div className="w-20 h-20 bg-muted rounded-2xl flex items-center justify-center mb-6 border border-border">
|
||||||
<Hash size={40} />
|
<Database className="w-10 h-10" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-bold">No media found</p>
|
<p className="text-xl font-bold text-foreground">No media found</p>
|
||||||
<p className="text-sm">Start by adding media to your collection</p>
|
<p className="text-sm">Start by adding media to your collection</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,379 +1,419 @@
|
|||||||
import { Media, Staff, Track } from '@/types';
|
import { Media, Staff } from '@/types';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
|
import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
Play,
|
ArrowLeft, Calendar, Clock, Play, Star, Users, Disc, Layers,
|
||||||
Bookmark,
|
Tv, BookOpen, Gamepad2, Film, Music, Package, Heart, Bookmark,
|
||||||
MoreHorizontal,
|
MoreHorizontal, Share2, ExternalLink
|
||||||
Star,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Search,
|
|
||||||
ListFilter,
|
|
||||||
ChevronDown,
|
|
||||||
Calendar,
|
|
||||||
Clock,
|
|
||||||
Eye
|
|
||||||
} from 'lucide-react';
|
} 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 { 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 {
|
interface DetailViewProps {
|
||||||
media: Media;
|
media: Media;
|
||||||
|
allMedia: Media[];
|
||||||
onPersonClick: (person: Staff) => void;
|
onPersonClick: (person: Staff) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
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" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 navigate = useNavigate();
|
||||||
const [castLimit, setCastLimit] = useState(6);
|
const [progress] = useState(media.playCount ? Math.min(100, (media.playCount * 10)) : 0);
|
||||||
const [showAllCast, setShowAllCast] = useState(false);
|
|
||||||
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
|
|
||||||
const [progress, setProgress] = useState(70.8);
|
|
||||||
|
|
||||||
const hasEpisodes = media.episodes && media.episodes.length > 0;
|
const hasEpisodes = media.episodes && media.episodes.length > 0;
|
||||||
const hasTracks = media.tracks && media.tracks.length > 0;
|
const hasTracks = media.tracks && media.tracks.length > 0;
|
||||||
const hasCast = media.staff && media.staff.length > 0;
|
const hasCast = media.staff && media.staff.length > 0;
|
||||||
const tabs = [
|
const hasFranchise = media.category === 'Games' && media.series && media.series.length > 0;
|
||||||
'Overview',
|
|
||||||
...(hasCast ? ['Cast'] : []),
|
|
||||||
'Actions',
|
|
||||||
'History',
|
|
||||||
...(hasEpisodes ? ['Seasons'] : []),
|
|
||||||
...(hasTracks ? ['Tracks'] : []),
|
|
||||||
'Reviews',
|
|
||||||
'Suggestions',
|
|
||||||
'Watch On'
|
|
||||||
];
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(tabs[0]);
|
// Determine default tab based on available content
|
||||||
|
const getDefaultTab = () => {
|
||||||
// Group episodes by season
|
if (hasEpisodes) return 'seasons';
|
||||||
const episodesBySeason = useMemo(() => {
|
if (hasTracks) return 'tracks';
|
||||||
if (!media.episodes) return {};
|
if (hasCast) return 'cast';
|
||||||
const grouped: Record<number, typeof media.episodes> = {};
|
return 'overview';
|
||||||
media.episodes.forEach(episode => {
|
|
||||||
if (!grouped[episode.season]) {
|
|
||||||
grouped[episode.season] = [];
|
|
||||||
}
|
|
||||||
grouped[episode.season].push(episode);
|
|
||||||
});
|
|
||||||
// Sort episodes within each season by episode number
|
|
||||||
Object.keys(grouped).forEach(season => {
|
|
||||||
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
|
|
||||||
});
|
|
||||||
return grouped;
|
|
||||||
}, [media.episodes]);
|
|
||||||
|
|
||||||
// Expand first season by default on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
|
|
||||||
if (seasons.length > 0) {
|
|
||||||
setExpandedSeasons(new Set([seasons[0]]));
|
|
||||||
}
|
|
||||||
}, [episodesBySeason]);
|
|
||||||
|
|
||||||
const toggleSeason = (season: number) => {
|
|
||||||
setExpandedSeasons(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (newSet.has(season)) {
|
|
||||||
newSet.delete(season);
|
|
||||||
} else {
|
|
||||||
newSet.add(season);
|
|
||||||
}
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []);
|
const [activeTab, setActiveTab] = useState(getDefaultTab());
|
||||||
const hasMoreCast = (media.staff?.length || 0) > castLimit;
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<TooltipProvider>
|
||||||
{/* Banner */}
|
<div className="min-h-screen bg-background pb-20">
|
||||||
<div className="relative h-[450px] w-full overflow-hidden">
|
{/* Hero Section - Full height from top behind transparent navbar */}
|
||||||
<img
|
<div className="relative h-[40vh] md:h-[45vh] overflow-hidden bg-zinc-900">
|
||||||
src={media.banner || media.poster}
|
<img
|
||||||
alt={media.title}
|
src={media.banner || media.poster}
|
||||||
className="w-full h-full object-cover"
|
alt={media.title}
|
||||||
referrerPolicy="no-referrer"
|
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/50 to-transparent" />
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/60 to-transparent" />
|
||||||
|
|
||||||
<button
|
{/* Back Button - z-50 to ensure clickable */}
|
||||||
onClick={() => navigate(-1)}
|
<Button
|
||||||
className="absolute top-24 left-6 p-3 bg-black/30 hover:bg-black/50 backdrop-blur-md text-white rounded-2xl transition-all duration-300 hover:scale-110 z-10 border border-white/20 lg:left-80"
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<ChevronLeft size={24} />
|
onClick={() => navigate(-1)}
|
||||||
</button>
|
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"
|
||||||
</div>
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Quick Actions - z-50 to ensure clickable */}
|
||||||
<div className="max-w-[1920px] mx-auto px-6 py-8 pb-24 -mt-32 relative z-10">
|
<div className="absolute top-4 right-4 sm:right-6 z-50 flex items-center gap-2">
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
<Tooltip>
|
||||||
{/* Left Column: Cover Image */}
|
<TooltipTrigger asChild>
|
||||||
<div className="w-full lg:w-[400px] shrink-0">
|
<Button
|
||||||
<motion.div
|
variant="ghost"
|
||||||
layoutId={`media-${media.id}`}
|
size="icon"
|
||||||
className={`rounded-2xl overflow-hidden shadow-2xl bg-card border border-border/50 ${
|
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
|
||||||
media.aspectRatio === '16/9' ? 'aspect-video' :
|
>
|
||||||
media.aspectRatio === '1/1' ? 'aspect-square' :
|
<Heart className="h-4 w-4" />
|
||||||
'aspect-[2/3]'
|
</Button>
|
||||||
}`}
|
</TooltipTrigger>
|
||||||
>
|
<TooltipContent>Add to favorites</TooltipContent>
|
||||||
<img
|
</Tooltip>
|
||||||
src={media.poster}
|
<Tooltip>
|
||||||
alt={media.title}
|
<TooltipTrigger asChild>
|
||||||
className="w-full h-full object-cover"
|
<Button
|
||||||
referrerPolicy="no-referrer"
|
variant="ghost"
|
||||||
/>
|
size="icon"
|
||||||
</motion.div>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Info */}
|
{/* Hero Content - pt-16 to account for navbar + buttons */}
|
||||||
<div className="flex-1">
|
<div className="absolute inset-0 pt-16 flex items-end px-4 sm:px-6 pb-8">
|
||||||
{/* Header with tags */}
|
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-6">
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
{/* Poster */}
|
||||||
<h1 className="text-4xl lg:text-5xl font-black text-foreground">
|
<motion.div
|
||||||
{media.title}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
</h1>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
{media.status && (
|
className="shrink-0"
|
||||||
<Badge className={
|
>
|
||||||
media.status === 'watching' || media.status === 'reading' || media.status === 'listening' || media.status === 'playing'
|
<Avatar className={`h-40 md:h-48 w-auto rounded-none border-4 border-background shadow-2xl ${
|
||||||
? 'bg-green-500/20 text-green-400 border-green-500/30 font-bold'
|
media.aspectRatio === '16/9' ? 'aspect-video' :
|
||||||
: media.status === 'completed'
|
media.aspectRatio === '1/1' ? 'aspect-square' :
|
||||||
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 font-bold'
|
'aspect-[2/3]'
|
||||||
: 'bg-gray-500/20 text-gray-400 border-gray-500/30 font-bold'
|
}`}>
|
||||||
}>
|
<AvatarImage
|
||||||
{media.status.toUpperCase()}
|
src={media.poster}
|
||||||
</Badge>
|
alt={media.title}
|
||||||
)}
|
className="object-cover"
|
||||||
{media.completionStatus && (
|
referrerPolicy="no-referrer"
|
||||||
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30 font-bold">{media.completionStatus.toUpperCase()}</Badge>
|
/>
|
||||||
)}
|
<AvatarFallback className="rounded-none text-3xl bg-muted">
|
||||||
</div>
|
{categoryIcons[media.category] || <Film className="h-12 w-12" />}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Show Details */}
|
{/* Title & Meta */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
|
<div className="flex-1 text-center md:text-left pb-2">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<motion.div
|
||||||
<Calendar size={16} />
|
initial={{ opacity: 0, x: -20 }}
|
||||||
<span>{media.year}</span>
|
animate={{ opacity: 1, x: 0 }}
|
||||||
</div>
|
transition={{ delay: 0.1 }}
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>{media.status ? media.status.charAt(0).toUpperCase() + media.status.slice(1) : 'Unknown'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Clock size={16} />
|
|
||||||
<span>{media.playtime ? `${media.playtime}h` : '12h 30m'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-bold text-foreground">Progress</span>
|
|
||||||
<span className="text-sm font-bold text-[#6d28d9]">{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-gradient-to-r from-[#6d28d9] to-[#8b5cf6] transition-all duration-500"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6 border-b border-border/50 pb-4">
|
|
||||||
{tabs.map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
activeTab === tab
|
|
||||||
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tab}
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mb-3">
|
||||||
</button>
|
{categoryIcons[media.category] && (
|
||||||
))}
|
<Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20">
|
||||||
</div>
|
{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>
|
||||||
|
|
||||||
{/* Genre Tags */}
|
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-3 tracking-tight">
|
||||||
{activeTab === 'Overview' && (
|
{media.title}
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
</h1>
|
||||||
{media.genres?.map(genre => (
|
|
||||||
<Badge key={genre} variant="secondary" className="bg-muted/50 text-foreground hover:bg-muted/80 border border-border/50 px-3 py-1 font-bold text-sm">
|
|
||||||
{genre}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Description */}
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
||||||
{activeTab === 'Overview' && (
|
<div className="flex items-center gap-1.5">
|
||||||
<div
|
<Calendar className="w-4 h-4" />
|
||||||
className="text-foreground leading-relaxed mb-8 max-w-4xl prose prose-sm dark:prose-invert"
|
<span>{media.year}</span>
|
||||||
dangerouslySetInnerHTML={{ __html: media.description || '' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Acting Section - Horizontal Scrollable */}
|
|
||||||
{media.staff && media.staff.length > 0 && activeTab === 'Cast' && (
|
|
||||||
<section className="mt-12">
|
|
||||||
<h2 className="text-2xl font-black text-foreground mb-6">Acting</h2>
|
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
|
||||||
{displayedCast.map(person => (
|
|
||||||
<div
|
|
||||||
key={person.id}
|
|
||||||
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 cursor-pointer group"
|
|
||||||
onClick={() => onPersonClick(person)}
|
|
||||||
>
|
|
||||||
<div className="w-full h-56 rounded-xl overflow-hidden mb-3 border border-border/30">
|
|
||||||
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" referrerPolicy="no-referrer" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
{media.rating && (
|
||||||
{hasMoreCast && (
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<Star className="w-4 h-4 text-amber-500" />
|
||||||
onClick={() => setShowAllCast(!showAllCast)}
|
<span>{media.rating.toFixed(1)}</span>
|
||||||
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<span className="font-bold text-[#6d28d9]">
|
|
||||||
{showAllCast ? 'Show Less' : `+${media.staff!.length - castLimit} more`}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{/* Episodes Section - Only show if episodes data exists and Seasons tab is active */}
|
|
||||||
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
|
|
||||||
<section className="mt-20">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
|
|
||||||
<span className="opacity-40">{media.episodes.length}</span> Episode{media.episodes.length !== 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-muted-foreground">
|
|
||||||
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
|
||||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
|
||||||
<MoreHorizontal size={20} />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
|
||||||
<ListFilter size={20} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{Object.keys(episodesBySeason)
|
|
||||||
.map(Number)
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
.map(season => (
|
|
||||||
<div key={season} className="border border-border/50 rounded-2xl overflow-hidden bg-card/50 backdrop-blur-sm">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSeason(season)}
|
|
||||||
className="w-full flex items-center justify-between p-6 bg-card/50 hover:bg-muted/50 transition-colors duration-300"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h3 className="text-2xl font-black text-foreground">Season {season}</h3>
|
|
||||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold">
|
|
||||||
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
)}
|
||||||
size={24}
|
{media.playtime && (
|
||||||
className={`transition-transform duration-300 text-muted-foreground ${
|
<div className="flex items-center gap-1.5">
|
||||||
expandedSeasons.has(season) ? 'rotate-180' : ''
|
<Clock className="w-4 h-4" />
|
||||||
}`}
|
<span>{media.playtime}h played</span>
|
||||||
/>
|
</div>
|
||||||
</button>
|
)}
|
||||||
{expandedSeasons.has(season) && (
|
{hasEpisodes && (
|
||||||
<div className="p-6 pt-0 space-y-6">
|
<div className="flex items-center gap-1.5">
|
||||||
{episodesBySeason[season].map(episode => (
|
<Tv className="w-4 h-4" />
|
||||||
<div key={episode.id} className="group cursor-pointer">
|
<span>{media.episodes!.length} episodes</span>
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
</div>
|
||||||
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-2xl overflow-hidden shadow-sm relative border border-border/30">
|
)}
|
||||||
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
|
{hasTracks && (
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
|
<div className="flex items-center gap-1.5">
|
||||||
</div>
|
<Disc className="w-4 h-4" />
|
||||||
<div className="flex-1 py-1">
|
<span>{media.tracks!.length} tracks</span>
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
|
|
||||||
E{episode.episode_number} • {episode.title}
|
|
||||||
</h3>
|
|
||||||
<span className="text-xs font-bold text-muted-foreground">{episode.air_date} • {episode.duration}m</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
|
|
||||||
{episode.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator className="mt-6 bg-border/50" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</motion.div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tracks Section - Only show if tracks data exists and Tracks tab is active */}
|
|
||||||
{media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
|
|
||||||
<section className="mt-20">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
|
|
||||||
<span className="opacity-40">{media.tracks.length}</span> Track{media.tracks.length !== 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
|
||||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
|
||||||
<MoreHorizontal size={20} />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
|
||||||
<ListFilter size={20} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Primary Action */}
|
||||||
{media.tracks.map(track => (
|
<motion.div
|
||||||
<div key={track.id} className="group cursor-pointer flex items-center gap-4 p-4 rounded-2xl hover:bg-muted/50 transition-colors duration-300 border border-transparent hover:border-border/30">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="flex-1">
|
transition={{ delay: 0.2 }}
|
||||||
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
|
className="shrink-0"
|
||||||
{track.title}
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<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>
|
||||||
|
Studios
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">{track.artist}</p>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
</div>
|
{media.studios.map(studio => (
|
||||||
<span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span>
|
<Badge key={studio} variant="secondary" className="text-xs">
|
||||||
</div>
|
{studio}
|
||||||
))}
|
</Badge>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default function Header({
|
|||||||
? "bg-transparent"
|
? "bg-transparent"
|
||||||
: transparent && scrolled
|
: transparent && scrolled
|
||||||
? "backdrop-blur-xl bg-background/70 border-b border-border/30"
|
? "backdrop-blur-xl bg-background/70 border-b border-border/30"
|
||||||
: "backdrop-blur-xl bg-gradient-to-r from-[#6d28d9]/90 via-[#8b5cf6]/90 to-[#6d28d9]/90 border-b border-white/10"
|
: "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="flex items-center gap-8">
|
||||||
@@ -87,7 +87,7 @@ export default function Header({
|
|||||||
"w-8 h-8 rounded-xl flex items-center justify-center shadow-lg transition-all duration-300",
|
"w-8 h-8 rounded-xl flex items-center justify-center shadow-lg transition-all duration-300",
|
||||||
(transparent && !scrolled) || !transparent
|
(transparent && !scrolled) || !transparent
|
||||||
? "bg-white/20 backdrop-blur-sm border border-white/30"
|
? "bg-white/20 backdrop-blur-sm border border-white/30"
|
||||||
: "bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] shadow-[#6d28d9]/30"
|
: "bg-gradient-to-br from-[#e8466c] to-[#f47298] shadow-[#e8466c]/30"
|
||||||
)}>
|
)}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-4 h-4 rounded-full",
|
"w-4 h-4 rounded-full",
|
||||||
@@ -95,7 +95,7 @@ export default function Header({
|
|||||||
)} />
|
)} />
|
||||||
</div>
|
</div>
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
|
<span className="bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80">
|
||||||
kyoo
|
omnyx
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
@@ -121,7 +121,7 @@ export default function Header({
|
|||||||
? "text-white bg-white/10"
|
? "text-white bg-white/10"
|
||||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||||
: isActive
|
: isActive
|
||||||
? "text-foreground bg-[#6d28d9]/10"
|
? "text-foreground bg-[#e8466c]/10"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -138,7 +138,7 @@ export default function Header({
|
|||||||
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg",
|
"text-sm font-bold transition-all duration-300 uppercase tracking-wider px-4 py-2 rounded-lg",
|
||||||
(transparent && !scrolled) || !transparent
|
(transparent && !scrolled) || !transparent
|
||||||
? isActive ? "text-white bg-white/10" : "text-white/70 hover:text-white hover:bg-white/5"
|
? isActive ? "text-white bg-white/10" : "text-white/70 hover:text-white hover:bg-white/5"
|
||||||
: isActive ? "text-foreground bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: isActive ? "text-foreground bg-[#e8466c]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
CAST
|
CAST
|
||||||
@@ -215,7 +215,7 @@ export default function Header({
|
|||||||
"w-9 h-9 rounded-xl overflow-hidden border-2 transition-all duration-300 hover:scale-110 hover:shadow-lg",
|
"w-9 h-9 rounded-xl overflow-hidden border-2 transition-all duration-300 hover:scale-110 hover:shadow-lg",
|
||||||
(transparent && !scrolled) || !transparent
|
(transparent && !scrolled) || !transparent
|
||||||
? "border-white/30 hover:border-white/50"
|
? "border-white/30 hover:border-white/50"
|
||||||
: "border-border hover:border-[#6d28d9]/50"
|
: "border-border hover:border-[#e8466c]/50"
|
||||||
)}>
|
)}>
|
||||||
<img
|
<img
|
||||||
src="https://picsum.photos/seed/user/100/100"
|
src="https://picsum.photos/seed/user/100/100"
|
||||||
@@ -237,7 +237,7 @@ export default function Header({
|
|||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className={({ isActive }) => cn(
|
className={({ isActive }) => cn(
|
||||||
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
|
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
|
||||||
isActive ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
isActive ? "text-[#e8466c] bg-[#e8466c]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
@@ -249,7 +249,7 @@ export default function Header({
|
|||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className={({ isActive }) => cn(
|
className={({ isActive }) => cn(
|
||||||
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
|
"text-sm font-bold transition-colors uppercase tracking-wider py-2 px-4 rounded-lg",
|
||||||
isActive ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
isActive ? "text-[#e8466c] bg-[#e8466c]/10" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
CAST
|
CAST
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
|
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
|
||||||
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
|
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
|
||||||
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
|
import { importFromPlaynite, PlayniteConfig, PlayniteImportOptions } from '@/lib/playniteImporter';
|
||||||
import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter';
|
import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter';
|
||||||
import { fetchSettings, updateSettings } from '@/api';
|
import { fetchSettings, updateSettings } from '@/api';
|
||||||
|
|
||||||
@@ -25,6 +25,10 @@ export default function ImporterView() {
|
|||||||
port: import.meta.env.VITE_PLAYNITE_PORT ? parseInt(import.meta.env.VITE_PLAYNITE_PORT) : undefined,
|
port: import.meta.env.VITE_PLAYNITE_PORT ? parseInt(import.meta.env.VITE_PLAYNITE_PORT) : undefined,
|
||||||
updateExisting: true
|
updateExisting: true
|
||||||
});
|
});
|
||||||
|
const [playniteOptions, setPlayniteOptions] = useState<PlayniteImportOptions>({
|
||||||
|
limit: undefined,
|
||||||
|
nameFilter: undefined
|
||||||
|
});
|
||||||
const [jellyfinConfig, setJellyfinConfig] = useState<JellyfinConfig>({
|
const [jellyfinConfig, setJellyfinConfig] = useState<JellyfinConfig>({
|
||||||
url: import.meta.env.VITE_JELLYFIN_URL || '',
|
url: import.meta.env.VITE_JELLYFIN_URL || '',
|
||||||
apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || ''
|
apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || ''
|
||||||
@@ -199,6 +203,7 @@ export default function ImporterView() {
|
|||||||
|
|
||||||
const result = await importFromPlaynite(
|
const result = await importFromPlaynite(
|
||||||
playniteConfig,
|
playniteConfig,
|
||||||
|
playniteOptions,
|
||||||
addLog,
|
addLog,
|
||||||
(progressUpdate) => {
|
(progressUpdate) => {
|
||||||
setProgress(prev => ({ ...prev, ...progressUpdate }));
|
setProgress(prev => ({ ...prev, ...progressUpdate }));
|
||||||
@@ -413,7 +418,7 @@ export default function ImporterView() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleXBVRImport}
|
onClick={handleXBVRImport}
|
||||||
disabled={progress.stage !== 'idle' || !xbvrConfig.url}
|
disabled={progress.stage !== 'idle' || !xbvrConfig.url}
|
||||||
className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-bold"
|
className="w-full bg-[#6d28d9] hover:bg-[#d13d60] text-white font-bold"
|
||||||
>
|
>
|
||||||
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||||
<>
|
<>
|
||||||
@@ -639,6 +644,28 @@ export default function ImporterView() {
|
|||||||
/>
|
/>
|
||||||
<label htmlFor="playnite-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
|
<label htmlFor="playnite-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-muted-foreground mb-1 block">Limit (optional, for testing)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={playniteOptions.limit || ''}
|
||||||
|
onChange={(e) => setPlayniteOptions({ ...playniteOptions, limit: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||||
|
disabled={progress.stage !== 'idle'}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||||
|
placeholder="e.g. 10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-muted-foreground mb-1 block">Name Filter (optional, for testing)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={playniteOptions.nameFilter || ''}
|
||||||
|
onChange={(e) => setPlayniteOptions({ ...playniteOptions, nameFilter: e.target.value || undefined })}
|
||||||
|
disabled={progress.stage !== 'idle'}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
|
||||||
|
placeholder="e.g. Reside"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePlayniteImport}
|
onClick={handlePlayniteImport}
|
||||||
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
|
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
|
||||||
@@ -983,7 +1010,7 @@ export default function ImporterView() {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full transition-all duration-300 ease-out",
|
"h-full transition-all duration-300 ease-out",
|
||||||
progress.stage === 'error' ? "bg-gradient-to-r from-red-500 to-red-600" : "bg-gradient-to-r from-[#6d28d9] to-[#8b5cf6]"
|
progress.stage === 'error' ? "bg-gradient-to-r from-red-500 to-red-600" : "bg-gradient-to-r from-[#6d28d9] to-[#f47298]"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${getProgressPercentage()}%` }}
|
style={{ width: `${getProgressPercentage()}%` }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ export default function LibrarySettings({ enabledCategories, onToggleCategory }:
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-6 py-6">
|
<div className="grid gap-6 py-6">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-muted/30 border border-border/50 transition-all hover:border-[#6d28d9]/30 hover:bg-muted/50">
|
<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="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/30">
|
<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]}
|
{CATEGORY_ICONS[category]}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,15 +1,78 @@
|
|||||||
import { Media } from '@/types';
|
import React, { useState } from 'react';
|
||||||
|
import { Media, MediaCategory } from '@/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { motion } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { Star } from 'lucide-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 {
|
interface MediaCardProps {
|
||||||
key?: string;
|
key?: string;
|
||||||
media: Media;
|
media: Media;
|
||||||
onClick: (media: Media) => void;
|
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 = {
|
const statusColors = {
|
||||||
watching: 'bg-blue-500',
|
watching: 'bg-blue-500',
|
||||||
completed: 'bg-green-500',
|
completed: 'bg-green-500',
|
||||||
@@ -44,64 +107,446 @@ export default function MediaCard({ media, onClick }: MediaCardProps) {
|
|||||||
'1/1': 'aspect-[1/1]',
|
'1/1': 'aspect-[1/1]',
|
||||||
}[getAspectRatio()];
|
}[getAspectRatio()];
|
||||||
|
|
||||||
return (
|
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
|
<motion.div
|
||||||
layoutId={`media-${media.id}`}
|
layoutId={`media-${media.id}`}
|
||||||
className="group cursor-pointer"
|
className="group cursor-pointer"
|
||||||
onClick={() => onClick(media)}
|
onClick={() => onClick(media)}
|
||||||
whileHover={{ y: -8, scale: 1.02 }}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<Card
|
||||||
"relative rounded-2xl overflow-hidden bg-card transition-all duration-500 shadow-lg group-hover:shadow-2xl group-hover:shadow-[#6d28d9]/20",
|
className={cn(
|
||||||
aspectRatioClass
|
'relative overflow-hidden border-0 bg-muted/50 transition-all duration-300',
|
||||||
)}>
|
aspectRatioClass,
|
||||||
<img
|
isHovered && 'ring-2 ring-primary/20 shadow-xl'
|
||||||
src={media.poster}
|
|
||||||
alt={media.title}
|
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Overlay */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
|
|
||||||
{/* Rating Badge */}
|
|
||||||
{media.rating && (
|
|
||||||
<div className="absolute top-3 right-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-full flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-all duration-500 transform translate-y-[-10px] group-hover:translate-y-0">
|
|
||||||
<Star size={12} className="text-yellow-400 fill-yellow-400" />
|
|
||||||
<span className="text-xs font-bold text-white">{media.rating}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
{media.status && (
|
<div className="absolute inset-0 bg-muted">
|
||||||
<div className={cn(
|
<img
|
||||||
"absolute top-3 left-3 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-lg z-10",
|
src={media.poster}
|
||||||
statusColors[media.status]
|
alt={media.title}
|
||||||
)} />
|
className="h-full w-full object-cover object-center"
|
||||||
)}
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
{/* Glow Effect on Hover */}
|
|
||||||
<div className="absolute inset-0 rounded-2xl ring-2 ring-[#6d28d9]/0 group-hover:ring-[#6d28d9]/50 transition-all duration-500 pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-1.5">
|
|
||||||
<h3 className="text-sm font-bold text-foreground line-clamp-2 group-hover:text-[#6d28d9] transition-colors duration-300">
|
|
||||||
{media.title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
|
||||||
{media.year}
|
|
||||||
</p>
|
|
||||||
{media.genres && media.genres.length > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="text-xs text-muted-foreground/50">•</span>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground/70 line-clamp-1">
|
|
||||||
{media.genres[0]}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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,101 +1,114 @@
|
|||||||
import { Media } from '@/types';
|
import React from 'react';
|
||||||
|
import { Media, MediaCategory } from '@/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Star, Play, Bookmark } from 'lucide-react';
|
import { Star, Heart, Gamepad2, Film, Tv, Eye } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
interface MediaListItemProps {
|
interface MediaListItemProps {
|
||||||
key?: string;
|
key?: string;
|
||||||
media: Media;
|
media: Media;
|
||||||
onClick: (media: Media) => void;
|
onClick: (media: Media) => void;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
onFavoriteToggle?: (media: Media) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MediaListItem({ media, onClick }: MediaListItemProps) {
|
const categoryConfig: Record<MediaCategory, { label: string; color: string; bgColor: string; icon: any }> = {
|
||||||
const statusColors = {
|
'Anime': { label: 'ANIME', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: null },
|
||||||
watching: 'bg-blue-500',
|
'Movies': { label: 'MOVIE', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Film },
|
||||||
completed: 'bg-green-500',
|
'TV Series': { label: 'SERIES', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: Tv },
|
||||||
planned: 'bg-gray-500',
|
'Music': { label: 'MUSIC', color: 'text-pink-400', bgColor: 'bg-pink-500/20', icon: null },
|
||||||
dropped: 'bg-red-500',
|
'Books': { label: 'BOOK', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: null },
|
||||||
reading: 'bg-amber-500',
|
'Games': { label: 'GAME', color: 'text-indigo-400', bgColor: 'bg-indigo-500/20', icon: Gamepad2 },
|
||||||
listening: 'bg-purple-500',
|
'Consoles': { label: 'CONSOLE', color: 'text-orange-400', bgColor: 'bg-orange-500/20', icon: null },
|
||||||
playing: 'bg-indigo-500',
|
'Adult': { label: 'ADULT', color: 'text-rose-400', bgColor: 'bg-rose-500/20', icon: Eye },
|
||||||
'on-hold': 'bg-orange-500',
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const getAspectRatio = () => {
|
export default function MediaListItem({ media, onClick, isFavorite = false, onFavoriteToggle }: MediaListItemProps) {
|
||||||
if (media.aspectRatio) return media.aspectRatio;
|
const categoryInfo = categoryConfig[media.category];
|
||||||
switch (media.category) {
|
const CategoryIcon = categoryInfo?.icon;
|
||||||
case 'Music': return '1/1';
|
|
||||||
case 'Games':
|
|
||||||
case 'Adult': return '16/9';
|
|
||||||
default: return '2/3';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const aspectRatioClass = {
|
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||||
'2/3': 'w-24 h-32',
|
e.stopPropagation();
|
||||||
'16/9': 'w-48 h-27', // 16:9 ratio for w-48 is approx h-27
|
onFavoriteToggle?.(media);
|
||||||
'1/1': 'w-24 h-24',
|
};
|
||||||
}[getAspectRatio()];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
layout
|
layout
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0 }}
|
||||||
className="group flex items-center gap-6 p-5 rounded-xl hover:bg-muted/50 transition-all duration-300 cursor-pointer border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg hover:shadow-[#6d28d9]/10"
|
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)}
|
onClick={() => onClick(media)}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
{/* TITLE Column: Poster + Title + Rating (like screenshot 2) */}
|
||||||
"relative rounded-xl overflow-hidden shrink-0 shadow-md bg-card transition-all duration-300 group-hover:scale-105 border border-border/30",
|
<div className="flex-1 min-w-0 flex items-center gap-3 mr-4">
|
||||||
aspectRatioClass
|
{/* Poster Thumbnail */}
|
||||||
)}>
|
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-muted">
|
||||||
<img
|
<img
|
||||||
src={media.poster}
|
src={media.poster}
|
||||||
alt={media.title}
|
alt={media.title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
|
</div>
|
||||||
{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">
|
{/* Title + Rating stacked */}
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="min-w-0">
|
||||||
<h3 className="text-lg font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
|
<h3 className="text-sm font-medium text-foreground truncate group-hover:text-[#e8466c] transition-colors">
|
||||||
{media.title}
|
{media.title}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-sm font-bold text-muted-foreground">({media.year})</span>
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
</div>
|
<Star size={10} className="text-[#e8466c] fill-[#e8466c]" />
|
||||||
|
<span className="text-xs font-medium text-[#e8466c]">
|
||||||
<div className="flex items-center gap-4 mb-3">
|
{media.rating?.toFixed(1) || '-'}
|
||||||
<div className="flex items-center gap-1 text-xs font-bold text-muted-foreground">
|
</span>
|
||||||
<Star size={14} className="text-yellow-500" fill="currentColor" />
|
|
||||||
{media.rating || 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
|
||||||
{media.genres?.slice(0, 3).join(' • ') || 'Anime'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2 max-w-2xl">
|
|
||||||
{media.description || "No description available for this title."}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-2">
|
{/* TYPE Column */}
|
||||||
<Button size="icon" variant="ghost" className="rounded-xl text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10 transition-all duration-300">
|
<div className="w-[70px] shrink-0 mr-4">
|
||||||
<Play size={18} fill="currentColor" />
|
<span className={cn(
|
||||||
</Button>
|
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
|
||||||
<Button size="icon" variant="ghost" className="rounded-xl text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10 transition-all duration-300">
|
categoryInfo.bgColor,
|
||||||
<Bookmark size={18} />
|
categoryInfo.color
|
||||||
</Button>
|
)}>
|
||||||
|
{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>
|
</div>
|
||||||
</motion.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,34 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { MediaCategory, UserSettings } from '@/types';
|
import { MediaCategory, UserSettings, CustomColors } from '@/types';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Film, Music, Book, Tv, Gamepad2, ShieldAlert, LayoutGrid, List, Globe, Monitor, Sun, Moon, Save, ArrowLeft } from 'lucide-react';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Link } from 'react-router-dom';
|
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 { fetchSettings, updateSettings } from '@/api';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
|
const CATEGORY_ICONS: Record<MediaCategory, React.ElementType> = {
|
||||||
Anime: <Tv size={18} />,
|
Anime: Tv,
|
||||||
Movies: <Film size={18} />,
|
Movies: Film,
|
||||||
'TV Series': <Tv size={18} />,
|
'TV Series': Tv,
|
||||||
Music: <Music size={18} />,
|
Music: Music,
|
||||||
Books: <Book size={18} />,
|
Books: BookOpen,
|
||||||
Consoles: <Gamepad2 size={18} />,
|
Consoles: Gamepad2,
|
||||||
Games: <Gamepad2 size={18} />,
|
Games: Gamepad2,
|
||||||
Adult: <ShieldAlert size={18} />,
|
Adult: ShieldAlert,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ITEMS_PER_PAGE_OPTIONS = [12, 20, 36, 48, 60];
|
const ITEMS_PER_PAGE_OPTIONS = [12, 20, 36, 48, 60];
|
||||||
@@ -32,7 +45,9 @@ interface SettingsViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
const [activeTab, setActiveTab] = useState('library');
|
||||||
const [settings, setSettings] = useState<UserSettings>({
|
const [settings, setSettings] = useState<UserSettings>({
|
||||||
enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'],
|
enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'],
|
||||||
itemsPerPage: 20,
|
itemsPerPage: 20,
|
||||||
@@ -47,6 +62,12 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
// Page Settings State
|
||||||
|
const [pageTitle, setPageTitle] = useState<string>('');
|
||||||
|
const [favicon, setFavicon] = useState<string>('');
|
||||||
|
const [customColors, setCustomColors] = useState<CustomColors>({});
|
||||||
|
const [faviconPreview, setFaviconPreview] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -56,6 +77,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
|||||||
const loadedSettings = await fetchSettings();
|
const loadedSettings = await fetchSettings();
|
||||||
if (loadedSettings) {
|
if (loadedSettings) {
|
||||||
setSettings(loadedSettings);
|
setSettings(loadedSettings);
|
||||||
|
setPageTitle(loadedSettings.pageTitle || '');
|
||||||
|
setFavicon(loadedSettings.favicon || '');
|
||||||
|
setCustomColors(loadedSettings.customColors || {});
|
||||||
|
setFaviconPreview(loadedSettings.favicon || '');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
@@ -68,7 +93,13 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveStatus('idle');
|
setSaveStatus('idle');
|
||||||
try {
|
try {
|
||||||
const savedSettings = await updateSettings(settings);
|
const updatedSettings: UserSettings = {
|
||||||
|
...settings,
|
||||||
|
pageTitle: pageTitle || undefined,
|
||||||
|
favicon: favicon || undefined,
|
||||||
|
customColors: Object.keys(customColors).length > 0 ? customColors : undefined,
|
||||||
|
};
|
||||||
|
const savedSettings = await updateSettings(updatedSettings);
|
||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
setSettings(savedSettings);
|
setSettings(savedSettings);
|
||||||
setSaveStatus('success');
|
setSaveStatus('success');
|
||||||
@@ -96,6 +127,31 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFaviconUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const base64 = reader.result as string;
|
||||||
|
setFavicon(base64);
|
||||||
|
setFaviconPreview(base64);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFavicon = () => {
|
||||||
|
setFavicon('');
|
||||||
|
setFaviconPreview('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorChange = (colorKey: keyof CustomColors, value: string) => {
|
||||||
|
setCustomColors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[colorKey]: value || undefined,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
@@ -104,245 +160,427 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enabledCount = settings.enabledCategories.length;
|
||||||
|
const totalCategories = 8;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background pt-20">
|
<div className="min-h-screen bg-background pb-16">
|
||||||
{/* Content */}
|
{/* Header */}
|
||||||
<div className="max-w-[1920px] mx-auto px-6 py-12">
|
<div className="border-b border-border/50">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 py-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
<div className="flex items-center gap-4">
|
||||||
to="/"
|
<Button
|
||||||
className="inline-flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-[#6d28d9] transition-colors mb-2 hover:bg-muted/50 px-3 py-1 rounded-xl transition-all duration-300"
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<ArrowLeft size={16} />
|
onClick={() => navigate(-1)}
|
||||||
Back to home
|
className="rounded-lg"
|
||||||
</Link>
|
>
|
||||||
<h1 className="text-4xl font-black text-foreground">Settings</h1>
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</div>
|
</Button>
|
||||||
<button
|
<div>
|
||||||
onClick={handleSave}
|
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
||||||
disabled={isSaving}
|
<p className="text-sm text-muted-foreground">Manage your preferences</p>
|
||||||
className="bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-bold px-6 py-3 h-12 rounded-xl flex items-center gap-2 transition-all duration-300 hover:scale-[1.02] shadow-lg shadow-[#6d28d9]/30 disabled:opacity-50 disabled:hover:scale-100"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
'Saving...'
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save size={16} />
|
|
||||||
Save Changes
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{saveStatus === 'success' && (
|
|
||||||
<div className="mb-6 p-4 bg-green-500/10 border border-green-500/30 rounded-xl text-green-500 font-medium backdrop-blur-sm">
|
|
||||||
Settings saved successfully!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{saveStatus === 'error' && (
|
|
||||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-500 font-medium backdrop-blur-sm">
|
|
||||||
Failed to save settings. Please try again.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-8">
|
|
||||||
{/* Library Settings */}
|
|
||||||
<section>
|
|
||||||
<h2 className="text-2xl font-black text-foreground mb-6">Library Settings</h2>
|
|
||||||
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-4">
|
|
||||||
Toggle which media areas you want to see in your library.
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => (
|
|
||||||
<div key={category} className="flex items-center justify-between p-4 rounded-xl bg-background border border-border/50 transition-all hover:border-[#6d28d9]/30 hover:bg-muted/50">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-muted flex items-center justify-center text-[#6d28d9] border border-border/30">
|
|
||||||
{CATEGORY_ICONS[category]}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={category} className="text-sm font-black text-foreground cursor-pointer">
|
|
||||||
{category}
|
|
||||||
</Label>
|
|
||||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
|
||||||
{settings.enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id={category}
|
|
||||||
checked={settings.enabledCategories.includes(category)}
|
|
||||||
onCheckedChange={() => toggleCategory(category)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{saveStatus === 'success' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
className="flex items-center gap-2 text-sm text-emerald-500 bg-emerald-500/10 px-3 py-1.5 rounded-lg"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
Saved
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{saveStatus === 'error' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
className="flex items-center gap-2 text-sm text-red-500 bg-red-500/10 px-3 py-1.5 rounded-lg"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
Error
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 py-6">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
|
<TabsList className="mb-6 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto flex-wrap">
|
||||||
|
<TabsTrigger value="library" className="gap-2">
|
||||||
|
<Library className="h-4 w-4" />
|
||||||
|
Library
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="display" className="gap-2">
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
Display
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="content" className="gap-2">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Content
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="appearance" className="gap-2">
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
Appearance
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Library Settings */}
|
||||||
|
<TabsContent value="library" className="mt-0 space-y-6">
|
||||||
|
<Card className="border-border/60">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Media Categories</CardTitle>
|
||||||
|
<CardDescription>Toggle which media types appear in your library</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{enabledCount}/{totalCategories} enabled</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => {
|
||||||
|
const Icon = CATEGORY_ICONS[category];
|
||||||
|
const isEnabled = settings.enabledCategories.includes(category);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={category}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-4 rounded-lg border transition-all cursor-pointer",
|
||||||
|
isEnabled
|
||||||
|
? "bg-background border-primary/30"
|
||||||
|
: "bg-muted/30 border-border/50 opacity-60"
|
||||||
|
)}
|
||||||
|
onClick={() => toggleCategory(category)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-lg flex items-center justify-center border",
|
||||||
|
isEnabled
|
||||||
|
? "bg-primary/10 text-primary border-primary/20"
|
||||||
|
: "bg-muted text-muted-foreground border-border"
|
||||||
|
)}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">{category}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isEnabled ? 'Visible in library' : 'Hidden'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isEnabled}
|
||||||
|
onCheckedChange={() => toggleCategory(category)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Display Settings */}
|
{/* Display Settings */}
|
||||||
<section>
|
<TabsContent value="display" className="mt-0 space-y-6">
|
||||||
<h2 className="text-2xl font-black text-foreground mb-6">Display Settings</h2>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50 space-y-6">
|
<Card className="border-border/60">
|
||||||
{/* Items per page */}
|
<CardHeader>
|
||||||
<div>
|
<CardTitle>View Options</CardTitle>
|
||||||
<Label className="text-sm font-black text-foreground mb-2 block">Items per page</Label>
|
<CardDescription>Configure how items are displayed</CardDescription>
|
||||||
<div className="flex gap-2 flex-wrap">
|
</CardHeader>
|
||||||
{ITEMS_PER_PAGE_OPTIONS.map((option) => (
|
<CardContent className="space-y-6">
|
||||||
<button
|
{/* Items per page */}
|
||||||
key={option}
|
<div className="space-y-3">
|
||||||
onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: option }))}
|
<Label>Items per page</Label>
|
||||||
className={`px-4 py-2 rounded-xl text-sm font-bold transition-all ${
|
<div className="flex gap-2 flex-wrap">
|
||||||
settings.itemsPerPage === option
|
{ITEMS_PER_PAGE_OPTIONS.map((option) => (
|
||||||
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
|
<Button
|
||||||
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
|
key={option}
|
||||||
}`}
|
variant={settings.itemsPerPage === option ? 'default' : 'outline'}
|
||||||
>
|
size="sm"
|
||||||
{option}
|
onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: option }))}
|
||||||
</button>
|
>
|
||||||
))}
|
{option}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Default view */}
|
<Separator />
|
||||||
<div>
|
|
||||||
<Label className="text-sm font-black text-foreground mb-2 block">Default view</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all ${
|
|
||||||
settings.defaultView === 'grid'
|
|
||||||
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
|
|
||||||
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<LayoutGrid size={18} />
|
|
||||||
Grid
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'list' }))}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all ${
|
|
||||||
settings.defaultView === 'list'
|
|
||||||
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
|
|
||||||
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<List size={18} />
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid item size */}
|
{/* Default view */}
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-black text-foreground mb-2 block">Grid item size</Label>
|
<Label>Default view</Label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<span className="text-xs font-bold text-muted-foreground">Small</span>
|
<Button
|
||||||
<input
|
variant={settings.defaultView === 'grid' ? 'default' : 'outline'}
|
||||||
type="range"
|
className="justify-center gap-2"
|
||||||
min="1"
|
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))}
|
||||||
max="10"
|
>
|
||||||
value={settings.gridItemSize}
|
<LayoutGrid className="h-4 w-4" />
|
||||||
onChange={(e) => setSettings(prev => ({ ...prev, gridItemSize: Number(e.target.value) }))}
|
Grid
|
||||||
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
|
</Button>
|
||||||
/>
|
<Button
|
||||||
<span className="text-xs font-bold text-muted-foreground">Large</span>
|
variant={settings.defaultView === 'list' ? 'default' : 'outline'}
|
||||||
<span className="text-sm font-bold text-[#6d28d9] w-8 text-center">{settings.gridItemSize}</span>
|
className="justify-center gap-2"
|
||||||
</div>
|
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'list' }))}
|
||||||
</div>
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Theme */}
|
<Separator />
|
||||||
<div>
|
|
||||||
<Label className="text-sm font-black text-foreground mb-2 block">Theme</Label>
|
{/* Grid item size */}
|
||||||
<div className="flex gap-2">
|
<div className="space-y-3">
|
||||||
{(['light', 'dark', 'system'] as const).map((theme) => (
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<Label>Grid item size</Label>
|
||||||
key={theme}
|
<span className="text-sm font-medium text-primary">{settings.gridItemSize}</span>
|
||||||
onClick={() => setSettings(prev => ({ ...prev, theme }))}
|
</div>
|
||||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all ${
|
<div className="flex items-center gap-4">
|
||||||
settings.theme === theme
|
<span className="text-xs text-muted-foreground">Small</span>
|
||||||
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
|
<Slider
|
||||||
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
|
value={settings.gridItemSize}
|
||||||
}`}
|
min={1}
|
||||||
>
|
max={10}
|
||||||
{theme === 'light' && <Sun size={18} />}
|
onValueChange={(value) => setSettings(prev => ({ ...prev, gridItemSize: value }))}
|
||||||
{theme === 'dark' && <Moon size={18} />}
|
className="flex-1"
|
||||||
{theme === 'system' && <Monitor size={18} />}
|
/>
|
||||||
{theme.charAt(0).toUpperCase() + theme.slice(1)}
|
<span className="text-xs text-muted-foreground">Large</span>
|
||||||
</button>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Content Settings */}
|
{/* Content Settings */}
|
||||||
<section>
|
<TabsContent value="content" className="mt-0 space-y-6">
|
||||||
<h2 className="text-2xl font-black text-foreground mb-6">Content Settings</h2>
|
<Card className="border-border/60">
|
||||||
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50 space-y-4">
|
<CardHeader>
|
||||||
{/* Show adult content */}
|
<CardTitle>Content Preferences</CardTitle>
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-background border border-border/50 hover:border-[#6d28d9]/30 transition-all">
|
<CardDescription>Control what content is shown</CardDescription>
|
||||||
<div>
|
</CardHeader>
|
||||||
<Label htmlFor="showAdult" className="text-sm font-black text-foreground cursor-pointer">
|
<CardContent className="space-y-4">
|
||||||
Show adult content
|
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30 border border-border/50">
|
||||||
</Label>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground mt-1">
|
<Label htmlFor="showAdult" className="cursor-pointer">Show adult content</Label>
|
||||||
Display adult media in your library
|
<p className="text-sm text-muted-foreground">Display adult media in your library</p>
|
||||||
</p>
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="showAdult"
|
||||||
|
checked={settings.showAdultContent}
|
||||||
|
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, showAdultContent: checked }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
id="showAdult"
|
|
||||||
checked={settings.showAdultContent}
|
|
||||||
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, showAdultContent: checked }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Auto-play trailers */}
|
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30 border border-border/50">
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-background border border-border/50 hover:border-[#6d28d9]/30 transition-all">
|
<div>
|
||||||
<div>
|
<Label htmlFor="autoPlay" className="cursor-pointer">Auto-play trailers</Label>
|
||||||
<Label htmlFor="autoPlay" className="text-sm font-black text-foreground cursor-pointer">
|
<p className="text-sm text-muted-foreground">Automatically play trailers when viewing media</p>
|
||||||
Auto-play trailers
|
</div>
|
||||||
</Label>
|
<Switch
|
||||||
<p className="text-xs font-medium text-muted-foreground mt-1">
|
id="autoPlay"
|
||||||
Automatically play trailers when viewing media
|
checked={settings.autoPlayTrailers}
|
||||||
</p>
|
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
</CardContent>
|
||||||
id="autoPlay"
|
</Card>
|
||||||
checked={settings.autoPlayTrailers}
|
</TabsContent>
|
||||||
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Language Settings */}
|
{/* Appearance Settings */}
|
||||||
<section>
|
<TabsContent value="appearance" className="mt-0 space-y-6">
|
||||||
<h2 className="text-2xl font-black text-foreground mb-6">Language</h2>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
<Card className="border-border/60">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<CardHeader>
|
||||||
<Globe size={18} className="text-[#6d28d9]" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Label className="text-sm font-black text-foreground">Interface language</Label>
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
</div>
|
Theme
|
||||||
<div className="flex gap-2 flex-wrap">
|
</CardTitle>
|
||||||
{LANGUAGE_OPTIONS.map((option) => (
|
<CardDescription>Choose your preferred color scheme</CardDescription>
|
||||||
<button
|
</CardHeader>
|
||||||
key={option.value}
|
<CardContent>
|
||||||
onClick={() => setSettings(prev => ({ ...prev, language: option.value }))}
|
<div className="grid grid-cols-3 gap-3">
|
||||||
className={`px-4 py-2 rounded-xl text-sm font-bold transition-all ${
|
{([
|
||||||
settings.language === option.value
|
{ value: 'light' as const, icon: Sun, label: 'Light' },
|
||||||
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
|
{ value: 'dark' as const, icon: Moon, label: 'Dark' },
|
||||||
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
|
{ value: 'system' as const, icon: Monitor, label: 'System' },
|
||||||
}`}
|
]).map(({ value, icon: Icon, label }) => (
|
||||||
>
|
<Button
|
||||||
{option.label}
|
key={value}
|
||||||
</button>
|
variant={settings.theme === value ? 'default' : 'outline'}
|
||||||
))}
|
className="flex-col gap-2 h-auto py-4"
|
||||||
</div>
|
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>
|
</div>
|
||||||
</section>
|
</TabsContent>
|
||||||
</div>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,85 +1,58 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
BookOpen,
|
Library,
|
||||||
Film,
|
|
||||||
Tv,
|
|
||||||
Gamepad2,
|
|
||||||
Users,
|
Users,
|
||||||
Tag,
|
|
||||||
Music as MusicIcon,
|
|
||||||
Monitor,
|
|
||||||
Eye,
|
|
||||||
Dumbbell,
|
|
||||||
Calendar,
|
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
|
Database,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
LogOut,
|
LogOut,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
Plus
|
Plus,
|
||||||
|
Film,
|
||||||
|
Tv,
|
||||||
|
Gamepad2,
|
||||||
|
Heart,
|
||||||
|
Eye,
|
||||||
|
Flame,
|
||||||
|
Clock,
|
||||||
|
ChevronRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { MediaCategory } from '@/types';
|
import { MediaCategory } from '@/types';
|
||||||
import { CATEGORY_PATHS } from '@/constants';
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
enabledCategories: MediaCategory[];
|
enabledCategories: MediaCategory[];
|
||||||
onToggleCategory: (category: MediaCategory) => void;
|
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 }: SidebarProps) {
|
export default function Sidebar({
|
||||||
const [isMediaExpanded, setIsMediaExpanded] = useState(true);
|
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 [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const categoryIcons: Record<string, any> = {
|
|
||||||
'Audio Book': <BookOpen size={18} />,
|
|
||||||
'Book': <BookOpen size={18} />,
|
|
||||||
'Movie': <Film size={18} />,
|
|
||||||
'Music': <MusicIcon size={18} />,
|
|
||||||
'Show': <Tv size={18} />,
|
|
||||||
'Video Game': <Gamepad2 size={18} />,
|
|
||||||
'Consoles': <Monitor size={18} />,
|
|
||||||
'Adult': <Eye size={18} />,
|
|
||||||
'Groups': <Users size={18} />,
|
|
||||||
'People': <Users size={18} />,
|
|
||||||
'Genres': <Tag size={18} />
|
|
||||||
};
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ icon: <LayoutDashboard size={18} />, label: 'Dashboard', path: '/' },
|
|
||||||
{
|
|
||||||
icon: <Film size={18} />,
|
|
||||||
label: 'Media',
|
|
||||||
hasSubmenu: true,
|
|
||||||
submenu: [
|
|
||||||
...(enabledCategories.includes('Anime') ? [{ label: 'Anime', path: '/anime' }] : []),
|
|
||||||
...(enabledCategories.includes('Books') ? [{ label: 'Book', path: '/books' }] : []),
|
|
||||||
...(enabledCategories.includes('Movies') ? [{ label: 'Movie', path: '/movies' }] : []),
|
|
||||||
...(enabledCategories.includes('Music') ? [{ label: 'Music', path: '/music' }] : []),
|
|
||||||
...(enabledCategories.includes('TV Series') ? [{ label: 'Show', path: '/tv-series' }] : []),
|
|
||||||
...(enabledCategories.includes('Games') ? [{ label: 'Video Game', path: '/games' }] : []),
|
|
||||||
...(enabledCategories.includes('Consoles') ? [{ label: 'Consoles', path: '/consoles' }] : []),
|
|
||||||
...(enabledCategories.includes('Adult') ? [{ label: 'Adult', path: '/adult' }] : []),
|
|
||||||
{ label: 'People', path: '/cast' },
|
|
||||||
{ label: 'Genres', path: '/browse' }
|
|
||||||
].filter(Boolean)
|
|
||||||
},
|
|
||||||
//{ icon: <Dumbbell size={18} />, label: 'Fitness', path: '/fitness' },
|
|
||||||
//{ icon: <Calendar size={18} />, label: 'Calendar', path: '/calendar' },
|
|
||||||
//{ icon: <FolderKanban size={18} />, label: 'Collections', path: '/collections' },
|
|
||||||
{ icon: <Plus size={18} />, label: 'Add Media', path: '/add' },
|
|
||||||
{ icon: <Settings size={18} />, label: 'Settings', path: '/settings' },
|
|
||||||
{ icon: <FolderKanban size={18} />, label: 'Import', path: '/import' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
@@ -89,6 +62,36 @@ export default function Sidebar({ enabledCategories, onToggleCategory }: Sidebar
|
|||||||
console.log('Logout clicked');
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile menu button */}
|
{/* Mobile menu button */}
|
||||||
@@ -110,100 +113,299 @@ export default function Sidebar({ enabledCategories, onToggleCategory }: Sidebar
|
|||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed left-0 top-0 bottom-0 w-72 bg-card border-r border-border/50 z-50 flex flex-col transition-transform duration-300',
|
'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'
|
isMobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="p-6 border-b border-border/50">
|
<div className="p-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-xl flex items-center justify-center shadow-lg shadow-[#6d28d9]/30">
|
<div className="w-8 h-8 bg-gradient-to-br from-[#e8466c] to-[#f47298] rounded-lg flex items-center justify-center">
|
||||||
<div className="w-5 h-5 rounded-full bg-white" />
|
<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>
|
</div>
|
||||||
<span className="text-xl font-black text-foreground">kyoo</span>
|
<span className="text-lg font-bold text-white">{pageTitle || 'MediaVault'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
<nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
|
||||||
{navItems.map((item) => (
|
{/* Main Navigation */}
|
||||||
<div key={item.label}>
|
<NavLink
|
||||||
{item.hasSubmenu ? (
|
to="/"
|
||||||
<div>
|
onClick={() => setIsMobileOpen(false)}
|
||||||
<button
|
className={cn(
|
||||||
onClick={() => setIsMediaExpanded(!isMediaExpanded)}
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
|
||||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
isActive('/')
|
||||||
>
|
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||||
<div className="flex items-center gap-3">
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||||
<div className="text-muted-foreground group-hover:text-foreground transition-colors">
|
)}
|
||||||
{item.icon}
|
>
|
||||||
</div>
|
<LayoutDashboard size={18} />
|
||||||
<span className="font-bold text-foreground">{item.label}</span>
|
<span className="font-medium text-sm">Dashboard</span>
|
||||||
</div>
|
</NavLink>
|
||||||
{isMediaExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
||||||
</button>
|
<NavLink
|
||||||
{isMediaExpanded && item.submenu && (
|
to="/browse"
|
||||||
<div className="ml-4 mt-1 space-y-1">
|
onClick={() => setIsMobileOpen(false)}
|
||||||
{item.submenu.map((subItem) => (
|
className={cn(
|
||||||
<NavLink
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
|
||||||
key={subItem.label}
|
isActive('/browse') || isActive('/movies') || isActive('/tv-series') || isActive('/games') || isActive('/adult')
|
||||||
to={subItem.path}
|
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||||
onClick={() => setIsMobileOpen(false)}
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||||
className={({ isActive }) =>
|
)}
|
||||||
cn(
|
>
|
||||||
'flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors',
|
<Library size={18} />
|
||||||
isActive
|
<span className="font-medium text-sm">Library</span>
|
||||||
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
|
</NavLink>
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
||||||
)
|
<NavLink
|
||||||
}
|
to="/cast"
|
||||||
>
|
onClick={() => setIsMobileOpen(false)}
|
||||||
{categoryIcons[subItem.label]}
|
className={cn(
|
||||||
{subItem.label}
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
|
||||||
</NavLink>
|
isActive('/cast')
|
||||||
))}
|
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||||
</div>
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
) : (
|
<Users size={18} />
|
||||||
<NavLink
|
<span className="font-medium text-sm">Actors</span>
|
||||||
to={item.path}
|
</NavLink>
|
||||||
onClick={() => setIsMobileOpen(false)}
|
|
||||||
className={({ isActive }) =>
|
<NavLink
|
||||||
cn(
|
to="/collections"
|
||||||
'flex items-center gap-3 px-4 py-3 rounded-xl transition-colors group',
|
onClick={() => setIsMobileOpen(false)}
|
||||||
isActive
|
className={cn(
|
||||||
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
isActive('/collections')
|
||||||
)
|
? 'bg-[#e8466c]/10 text-[#e8466c]'
|
||||||
}
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||||
>
|
)}
|
||||||
<div className={cn('transition-colors', location.pathname === item.path ? 'text-[#6d28d9]' : 'group-hover:text-foreground')}>
|
>
|
||||||
{item.icon}
|
<FolderKanban size={18} />
|
||||||
</div>
|
<span className="font-medium text-sm">Collections</span>
|
||||||
<span className="font-bold">{item.label}</span>
|
</NavLink>
|
||||||
</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>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{/* Bottom section */}
|
{/* Bottom section */}
|
||||||
<div className="p-4 border-t border-border/50 space-y-2">
|
<div className="p-3 border-t border-white/5 space-y-1">
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
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={18} />
|
<Sun size={16} />
|
||||||
<span className="font-medium">{theme === 'dark' ? 'Light theme' : 'Dark theme'}</span>
|
<span className="text-sm font-medium">{theme === 'dark' ? 'Light theme' : 'Dark theme'}</span>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<LogOut size={18} />
|
|
||||||
<span className="font-medium">Logout</span>
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</aside>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export default function MediaDetailRoute({ allMedia, onPersonClick }: MediaDetai
|
|||||||
return (
|
return (
|
||||||
<DetailView
|
<DetailView
|
||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
|
allMedia={allMedia}
|
||||||
onPersonClick={onPersonClick}
|
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 }
|
||||||
@@ -7,7 +7,7 @@ interface LoadingProps {
|
|||||||
export default function Loading({ message = 'Loading...' }: LoadingProps) {
|
export default function Loading({ message = 'Loading...' }: LoadingProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
<Loader2 className="animate-spin h-12 w-12 text-[#6d28d9] mb-4" />
|
<Loader2 className="animate-spin h-12 w-12 text-[#e8466c] mb-4" />
|
||||||
<p className="text-lg font-bold">{message}</p>
|
<p className="text-lg font-bold">{message}</p>
|
||||||
</div>
|
</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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
value?: number
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
onValueChange?: (value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||||
|
({ className, value, min = 0, max = 100, step = 1, onValueChange, onChange, ...props }, ref) => {
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = Number(e.target.value)
|
||||||
|
onValueChange?.(newValue)
|
||||||
|
onChange?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Slider.displayName = "Slider"
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Tab
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Panel
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 text-sm outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
|
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||||
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toggleVariants } from "@/components/ui/toggle"
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
spacing: 0,
|
||||||
|
orientation: "horizontal",
|
||||||
|
})
|
||||||
|
|
||||||
|
function ToggleGroup({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
spacing = 0,
|
||||||
|
orientation = "horizontal",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ToggleGroupPrimitive.Props &
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive
|
||||||
|
data-slot="toggle-group"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
data-spacing={spacing}
|
||||||
|
data-orientation={orientation}
|
||||||
|
style={{ "--gap": spacing } as React.CSSProperties}
|
||||||
|
className={cn(
|
||||||
|
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider
|
||||||
|
value={{ variant, size, spacing, orientation }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleGroupItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TogglePrimitive
|
||||||
|
data-slot="toggle-group-item"
|
||||||
|
data-variant={context.variant || variant}
|
||||||
|
data-size={context.size || size}
|
||||||
|
data-spacing={context.spacing}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TogglePrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border border-input bg-transparent hover:bg-muted",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<TogglePrimitive
|
||||||
|
data-slot="toggle"
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
@@ -53,10 +53,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const setTheme = (newTheme: Theme) => {
|
const setTheme = useCallback((newTheme: Theme) => {
|
||||||
setThemeState(newTheme);
|
setThemeState(newTheme);
|
||||||
localStorage.setItem('theme', newTheme);
|
localStorage.setItem('theme', newTheme);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, setTheme, effectiveTheme }}>
|
<ThemeContext.Provider value={{ theme, setTheme, effectiveTheme }}>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener("change", onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener("change", onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
||||||
@@ -93,59 +93,70 @@
|
|||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
|
/* MediaVault accent color - pink/coral */
|
||||||
|
--mv-accent: #e8466c;
|
||||||
|
--mv-accent-hover: #d13d60;
|
||||||
|
--mv-accent-light: #f47298;
|
||||||
|
|
||||||
/* Custom gradient colors */
|
/* Custom gradient colors */
|
||||||
--gradient-purple: linear-gradient(135deg, #6d28d9 0%, #8b5cf6 50%, #a78bfa 100%);
|
--gradient-purple: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
||||||
--gradient-blue: linear-gradient(135deg, #3b82f6 0%, #60a5fa 50%, #93c5fd 100%);
|
--gradient-blue: linear-gradient(135deg, #3b82f6 0%, #60a5fa 50%, #93c5fd 100%);
|
||||||
--gradient-green: linear-gradient(135deg, #22c55e 0%, #4ade80 50%, #86efac 100%);
|
--gradient-green: linear-gradient(135deg, #22c55e 0%, #4ade80 50%, #86efac 100%);
|
||||||
--gradient-yellow: linear-gradient(135deg, #eab308 0%, #facc15 50%, #fde047 100%);
|
--gradient-yellow: linear-gradient(135deg, #eab308 0%, #facc15 50%, #fde047 100%);
|
||||||
|
--gradient-pink: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.12 0.01 264);
|
--background: oklch(0.145 0.005 35);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.82 0.008 35);
|
||||||
--card: oklch(0.18 0.02 264);
|
--card: oklch(0.17 0.005 35);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.82 0.008 35);
|
||||||
--popover: oklch(0.18 0.02 264);
|
--popover: oklch(0.17 0.005 35);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.82 0.008 35);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.82 0.008 35);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.145 0.005 35);
|
||||||
--secondary: oklch(0.269 0.01 264);
|
--secondary: oklch(0.21 0.005 35);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.82 0.008 35);
|
||||||
--muted: oklch(0.25 0.01 264);
|
--muted: oklch(0.19 0.005 35);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.55 0.01 35);
|
||||||
--accent: oklch(0.269 0.01 264);
|
--accent: oklch(0.21 0.005 35);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.82 0.008 35);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(0.985 0 0 / 15%);
|
--border: oklch(0.82 0.008 35 / 10%);
|
||||||
--input: oklch(0.985 0 0 / 20%);
|
--input: oklch(0.82 0.008 35 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.55 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.7 0.08 35);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.55 0.04 35);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.4 0.02 35);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.3 0.015 35);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.2 0.01 35);
|
||||||
--sidebar: oklch(0.18 0.02 264);
|
--sidebar: oklch(0.125 0.005 35);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.82 0.008 35);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.55 0.22 0);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(1 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.19 0.005 35);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.82 0.008 35);
|
||||||
--sidebar-border: oklch(0.985 0 0 / 10%);
|
--sidebar-border: oklch(0.82 0.008 35 / 8%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.55 0 0);
|
||||||
|
|
||||||
/* Custom gradient colors for dark mode - more vibrant */
|
/* MediaVault accent color - pink/coral */
|
||||||
--gradient-purple: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 50%, #a78bfa 100%);
|
--mv-accent: #e8466c;
|
||||||
--gradient-blue: linear-gradient(135deg, #2563eb 0%, #3b82f6 50%, #60a5fa 100%);
|
--mv-accent-hover: #d13d60;
|
||||||
--gradient-green: linear-gradient(135deg, #16a34a 0%, #22c55e 50%, #4ade80 100%);
|
--mv-accent-light: #f47298;
|
||||||
--gradient-yellow: linear-gradient(135deg, #ca8a04 0%, #eab308 50%, #facc15 100%);
|
|
||||||
--gradient-pink: linear-gradient(135deg, #db2777 0%, #ec4899 50%, #f472b6 100%);
|
/* Custom gradient colors for dark mode - softer on eyes */
|
||||||
--gradient-orange: linear-gradient(135deg, #ea580c 0%, #f97316 50%, #fb923c 100%);
|
--gradient-purple: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
||||||
--gradient-cyan: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
|
--gradient-blue: linear-gradient(135deg, #3b82f6 0%, #60a5fa 50%, #93c5fd 100%);
|
||||||
|
--gradient-green: linear-gradient(135deg, #22c55e 0%, #4ade80 50%, #86efac 100%);
|
||||||
|
--gradient-yellow: linear-gradient(135deg, #eab308 0%, #facc15 50%, #fde047 100%);
|
||||||
|
--gradient-pink: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
||||||
|
--gradient-orange: linear-gradient(135deg, #f97316 0%, #fb923c 50%, #fbbf24 100%);
|
||||||
|
--gradient-cyan: linear-gradient(135deg, #06b6d4 0%, #22d3ee 50%, #67e8f9 100%);
|
||||||
|
|
||||||
/* Background gradients for dark mode */
|
/* Background gradients for dark mode */
|
||||||
--bg-gradient-subtle: radial-gradient(circle at top right, rgba(124, 58, 237, 0.1) 0%, transparent 50%),
|
--bg-gradient-subtle: radial-gradient(circle at top right, rgba(232, 70, 108, 0.06) 0%, transparent 50%),
|
||||||
radial-gradient(circle at bottom left, rgba(139, 92, 246, 0.1) 0%, transparent 50%);
|
radial-gradient(circle at bottom left, rgba(232, 70, 108, 0.04) 0%, transparent 50%);
|
||||||
--bg-gradient-mesh: linear-gradient(135deg, rgba(124, 58, 237, 0.05) 0%, rgba(139, 92, 246, 0.05) 50%, rgba(167, 139, 250, 0.05) 100%);
|
--bg-gradient-mesh: linear-gradient(135deg, rgba(232, 70, 108, 0.02) 0%, rgba(244, 114, 152, 0.02) 50%, rgba(249, 168, 201, 0.02) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -153,7 +164,7 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground transition-[background-color,border-color] duration-200;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Staff, Media } from '../../types';
|
|||||||
import { ApiResponse, PaginatedResponse, ApiCastItem, CreateCastInput, UpdateCastInput } from './types';
|
import { ApiResponse, PaginatedResponse, ApiCastItem, CreateCastInput, UpdateCastInput } from './types';
|
||||||
import { convertApiCastToStaff, convertApiToMedia } from './converters';
|
import { convertApiCastToStaff, convertApiToMedia } from './converters';
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<Staff[]> {
|
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<Staff[]> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import { ApiMediaItem, ApiStaff, ApiCastItem, ApiSettingsItem, CreateSettingsInp
|
|||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
function normalizeUrl(url: string | null): string {
|
const isLocalBackend = BASE_URL.includes('localhost') || BASE_URL.includes('127.0.0.1');
|
||||||
|
|
||||||
|
function normalizeUrl(url: string | null, type?: 'actor' | 'cover' | 'background' | 'icon'): string {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
if (isLocalBackend) {
|
||||||
|
let proxyUrl = `${BASE_URL}/api/images/proxy?url=${encodeURIComponent(url)}`;
|
||||||
|
if (type) proxyUrl += `&type=${type}`;
|
||||||
|
return proxyUrl;
|
||||||
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
|
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
|
||||||
@@ -18,7 +25,7 @@ export function convertApiCastToStaff(apiItem: ApiCastItem): Staff {
|
|||||||
name: apiItem.name,
|
name: apiItem.name,
|
||||||
cleanname: apiItem.cleanname,
|
cleanname: apiItem.cleanname,
|
||||||
role: apiItem.occupations?.[0] || 'Actor',
|
role: apiItem.occupations?.[0] || 'Actor',
|
||||||
photo: normalizeUrl(apiItem.photo) || `https://picsum.photos/seed/cast-${apiItem.id}/200/200`,
|
photo: normalizeUrl(apiItem.photo, 'actor') || `https://picsum.photos/seed/cast-${apiItem.id}/200/200`,
|
||||||
bio: apiItem.bio || undefined,
|
bio: apiItem.bio || undefined,
|
||||||
birthDate: apiItem.birthDate || undefined,
|
birthDate: apiItem.birthDate || undefined,
|
||||||
birthPlace: apiItem.birthPlace || undefined,
|
birthPlace: apiItem.birthPlace || undefined,
|
||||||
@@ -38,7 +45,7 @@ export function convertApiCastToStaff(apiItem: ApiCastItem): Staff {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
year: item.year,
|
year: item.year,
|
||||||
poster: normalizeUrl(item.poster) || `https://picsum.photos/seed/${item.id}/400/600`,
|
poster: normalizeUrl(item.poster, 'cover') || `https://picsum.photos/seed/${item.id}/400/600`,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
role: item.role,
|
role: item.role,
|
||||||
@@ -54,9 +61,9 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
|||||||
id: staffMember.id.toString(),
|
id: staffMember.id.toString(),
|
||||||
name: staffMember.name,
|
name: staffMember.name,
|
||||||
role: staffMember.role,
|
role: staffMember.role,
|
||||||
photo: normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
photo: normalizeUrl(staffMember.photo, 'actor') || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
||||||
characterName: staffMember.characterName || staffMember.name,
|
characterName: staffMember.characterName || staffMember.name,
|
||||||
characterImage: normalizeUrl(staffMember.characterImage) || normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
characterImage: normalizeUrl(staffMember.characterImage, 'icon') || normalizeUrl(staffMember.photo, 'actor') || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
|
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
|
||||||
@@ -133,9 +140,9 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
|||||||
id: apiItem.id.toString(),
|
id: apiItem.id.toString(),
|
||||||
title: apiItem.title,
|
title: apiItem.title,
|
||||||
year: apiItem.year?.toString() || 'Unknown',
|
year: apiItem.year?.toString() || 'Unknown',
|
||||||
poster: normalizeUrl(apiItem.poster) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
|
poster: normalizeUrl(apiItem.poster, 'cover') || `https://picsum.photos/seed/${apiItem.id}/400/600`,
|
||||||
category: mediaCategory,
|
category: mediaCategory,
|
||||||
banner: normalizeUrl(apiItem.banner) || undefined,
|
banner: normalizeUrl(apiItem.banner, 'background') || undefined,
|
||||||
description: apiItem.description || undefined,
|
description: apiItem.description || undefined,
|
||||||
rating: apiItem.rating || undefined,
|
rating: apiItem.rating || undefined,
|
||||||
genres: apiItem.genres || [],
|
genres: apiItem.genres || [],
|
||||||
@@ -147,6 +154,7 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
|||||||
staff: staff.length > 0 ? staff : undefined,
|
staff: staff.length > 0 ? staff : undefined,
|
||||||
aspectRatio: aspectRatio,
|
aspectRatio: aspectRatio,
|
||||||
categories: apiItem.categories,
|
categories: apiItem.categories,
|
||||||
|
series: apiItem.series,
|
||||||
platforms: apiItem.platforms,
|
platforms: apiItem.platforms,
|
||||||
developers: apiItem.developers,
|
developers: apiItem.developers,
|
||||||
completionStatus: apiItem.completionStatus,
|
completionStatus: apiItem.completionStatus,
|
||||||
@@ -170,6 +178,12 @@ export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
|
|||||||
language: apiItem.language || 'en',
|
language: apiItem.language || 'en',
|
||||||
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
|
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
|
||||||
jellyfinLibraryMappings: apiItem.jellyfin_library_mappings,
|
jellyfinLibraryMappings: apiItem.jellyfin_library_mappings,
|
||||||
|
|
||||||
|
// Page Settings
|
||||||
|
pageTitle: apiItem.page_title,
|
||||||
|
favicon: apiItem.favicon,
|
||||||
|
customColors: apiItem.custom_colors ? JSON.parse(apiItem.custom_colors) : undefined,
|
||||||
|
|
||||||
createdAt: apiItem.created_at,
|
createdAt: apiItem.created_at,
|
||||||
updatedAt: apiItem.updated_at,
|
updatedAt: apiItem.updated_at,
|
||||||
};
|
};
|
||||||
@@ -186,5 +200,10 @@ export function convertSettingsToApi(settings: UserSettings): CreateSettingsInpu
|
|||||||
language: settings.language,
|
language: settings.language,
|
||||||
theme: settings.theme,
|
theme: settings.theme,
|
||||||
jellyfin_library_mappings: settings.jellyfinLibraryMappings,
|
jellyfin_library_mappings: settings.jellyfinLibraryMappings,
|
||||||
|
|
||||||
|
// Page Settings
|
||||||
|
page_title: settings.pageTitle,
|
||||||
|
favicon: settings.favicon,
|
||||||
|
custom_colors: settings.customColors ? JSON.stringify(settings.customColors) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Media } from '../../types';
|
|||||||
import { ApiResponse, PaginatedResponse, ApiMediaItem, CreateMediaInput, UpdateMediaInput } from './types';
|
import { ApiResponse, PaginatedResponse, ApiMediaItem, CreateMediaInput, UpdateMediaInput } from './types';
|
||||||
import { convertApiToMedia } from './converters';
|
import { convertApiToMedia } from './converters';
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
|
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { UserSettings } from '../../types';
|
|||||||
import { ApiResponse, ApiSettingsItem, CreateSettingsInput, UpdateSettingsInput } from './types';
|
import { ApiResponse, ApiSettingsItem, CreateSettingsInput, UpdateSettingsInput } from './types';
|
||||||
import { convertApiToSettings, convertSettingsToApi } from './converters';
|
import { convertApiToSettings, convertSettingsToApi } from './converters';
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
export async function fetchSettings(): Promise<UserSettings | null> {
|
export async function fetchSettings(): Promise<UserSettings | null> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface ApiMediaItem {
|
|||||||
studios?: string[];
|
studios?: string[];
|
||||||
staff?: ApiStaff[];
|
staff?: ApiStaff[];
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
|
series?: string[];
|
||||||
platforms?: string[];
|
platforms?: string[];
|
||||||
developers?: string[];
|
developers?: string[];
|
||||||
completionStatus?: string;
|
completionStatus?: string;
|
||||||
@@ -193,6 +194,12 @@ export interface ApiSettingsItem {
|
|||||||
language: string;
|
language: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
jellyfin_library_mappings?: string;
|
jellyfin_library_mappings?: string;
|
||||||
|
|
||||||
|
// Page Settings
|
||||||
|
page_title?: string;
|
||||||
|
favicon?: string;
|
||||||
|
custom_colors?: string; // JSON string of CustomColors
|
||||||
|
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@@ -207,6 +214,11 @@ export interface CreateSettingsInput {
|
|||||||
language?: string;
|
language?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
jellyfin_library_mappings?: string;
|
jellyfin_library_mappings?: string;
|
||||||
|
|
||||||
|
// Page Settings
|
||||||
|
page_title?: string;
|
||||||
|
favicon?: string;
|
||||||
|
custom_colors?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
|
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
let ready: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
export function waitForBackend(maxRetries = 30, interval = 1000): Promise<boolean> {
|
||||||
|
if (ready) return ready;
|
||||||
|
ready = (async () => {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/health`);
|
||||||
|
if (response.ok) return true;
|
||||||
|
} catch {}
|
||||||
|
await new Promise(r => setTimeout(r, interval));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Jellyfin Importer Module
|
* Jellyfin Importer Module
|
||||||
*
|
*
|
||||||
* This module provides functionality to import media from a Jellyfin media server into the Kyoo media database.
|
* This module provides functionality to import media from a Jellyfin media server into the Omnyx media database.
|
||||||
* It supports importing movies, TV series (including episodes), music albums, and cast members.
|
* It supports importing movies, TV series (including episodes), music albums, and cast members.
|
||||||
* The module handles library mapping to categorize content appropriately and supports both new imports
|
* The module handles library mapping to categorize content appropriately and supports both new imports
|
||||||
* and updates to existing entries.
|
* and updates to existing entries.
|
||||||
@@ -25,7 +25,7 @@ export interface JellyfinConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapping configuration for Jellyfin libraries to Kyoo categories
|
* Mapping configuration for Jellyfin libraries to Omnyx categories
|
||||||
*/
|
*/
|
||||||
export interface LibraryMapping {
|
export interface LibraryMapping {
|
||||||
/** Name of the Jellyfin library */
|
/** Name of the Jellyfin library */
|
||||||
@@ -838,10 +838,10 @@ function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports media from a Jellyfin instance into the Kyoo media database
|
* Imports media from a Jellyfin instance into the Omnyx media database
|
||||||
*
|
*
|
||||||
* This function performs the following steps:
|
* This function performs the following steps:
|
||||||
* 1. Fetches existing media and cast from Kyoo to check for duplicates
|
* 1. Fetches existing media and cast from Omnyx to check for duplicates
|
||||||
* 2. Fetches Jellyfin libraries for category mapping (if library mappings are provided)
|
* 2. Fetches Jellyfin libraries for category mapping (if library mappings are provided)
|
||||||
* 3. Imports movies (if enabled)
|
* 3. Imports movies (if enabled)
|
||||||
* 4. Imports TV series with episodes (if enabled)
|
* 4. Imports TV series with episodes (if enabled)
|
||||||
@@ -895,7 +895,7 @@ export async function importFromJellyfin(
|
|||||||
logCallback('Starting Jellyfin import...');
|
logCallback('Starting Jellyfin import...');
|
||||||
|
|
||||||
// Step 0: Fetch existing media and cast to check for duplicates
|
// Step 0: Fetch existing media and cast to check for duplicates
|
||||||
logCallback('Fetching existing media from Kyoo API...');
|
logCallback('Fetching existing media from Omnyx API...');
|
||||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingMedia = new Map(
|
const existingMedia = new Map(
|
||||||
@@ -903,7 +903,7 @@ export async function importFromJellyfin(
|
|||||||
);
|
);
|
||||||
logCallback(`Found ${existingMedia.size} existing media items in database`);
|
logCallback(`Found ${existingMedia.size} existing media items in database`);
|
||||||
|
|
||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Omnyx API...');
|
||||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingCast = new Map(
|
const existingCast = new Map(
|
||||||
@@ -1297,15 +1297,15 @@ export async function cleanupJellyfinMedia(
|
|||||||
try {
|
try {
|
||||||
logCallback('Starting Jellyfin cleanup...');
|
logCallback('Starting Jellyfin cleanup...');
|
||||||
|
|
||||||
// Fetch all existing media from Kyoo API
|
// Fetch all existing media from Omnyx API
|
||||||
logCallback('Fetching existing media from Kyoo API...');
|
logCallback('Fetching existing media from Omnyx API...');
|
||||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: Media) => m.source === 'jellyfin');
|
const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: Media) => m.source === 'jellyfin');
|
||||||
logCallback(`Found ${jellyfinMedia.length} Jellyfin media items in database`);
|
logCallback(`Found ${jellyfinMedia.length} Jellyfin media items in database`);
|
||||||
|
|
||||||
// Fetch all existing cast from Kyoo API
|
// Fetch all existing cast from Omnyx API
|
||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Omnyx API...');
|
||||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const jellyfinCast = (existingCastData.data?.items || []).filter((c: Staff) => c.photo && c.photo.includes(normalizeUrl(config.url)));
|
const jellyfinCast = (existingCastData.data?.items || []).filter((c: Staff) => c.photo && c.photo.includes(normalizeUrl(config.url)));
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Playnite Importer Module
|
* Playnite Importer Module
|
||||||
*
|
*
|
||||||
* This module provides functionality to import games from a Playnite library into the Kyoo media database.
|
* This module provides functionality to import games from a Playnite library into the Omnyx media database.
|
||||||
* It fetches game data from the Playnite API, converts it to the Kyoo media format, and handles both
|
* It fetches game data from the Playnite API, converts it to the Omnyx media format, and handles both
|
||||||
* new imports and updates to existing entries.
|
* new imports and updates to existing entries.
|
||||||
*
|
*
|
||||||
* @module playniteImporter
|
* @module playniteImporter
|
||||||
@@ -27,6 +27,16 @@ export interface PlayniteConfig {
|
|||||||
updateExisting?: boolean;
|
updateExisting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for controlling the Playnite import process
|
||||||
|
*/
|
||||||
|
export interface PlayniteImportOptions {
|
||||||
|
/** Maximum number of items to import (optional) */
|
||||||
|
limit?: number;
|
||||||
|
/** Filter items by name (case-insensitive, optional - for debugging) */
|
||||||
|
nameFilter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress tracking for the import operation
|
* Progress tracking for the import operation
|
||||||
*/
|
*/
|
||||||
@@ -216,16 +226,17 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
|
|||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Imports games from a Playnite library into the Kyoo media database
|
* Imports games from a Playnite library into the Omnyx media database
|
||||||
*
|
*
|
||||||
* This function performs the following steps:
|
* This function performs the following steps:
|
||||||
* 1. Fetches existing media from Kyoo to check for duplicates
|
* 1. Fetches existing media from Omnyx to check for duplicates
|
||||||
* 2. Fetches all games from the Playnite API
|
* 2. Fetches all games from the Playnite API
|
||||||
* 3. Fetches detailed information for each game
|
* 3. Fetches detailed information for each game
|
||||||
* 4. Converts Playnite game data to Kyoo media format
|
* 4. Converts Playnite game data to Omnyx media format
|
||||||
* 5. Imports or updates each game in the Kyoo database
|
* 5. Imports or updates each game in the Omnyx database
|
||||||
*
|
*
|
||||||
* @param config - Configuration for connecting to Playnite
|
* @param config - Configuration for connecting to Playnite
|
||||||
|
* @param options - Import options to control behavior
|
||||||
* @param logCallback - Callback function for logging progress messages
|
* @param logCallback - Callback function for logging progress messages
|
||||||
* @param progressCallback - Callback function for updating import progress
|
* @param progressCallback - Callback function for updating import progress
|
||||||
* @returns Promise resolving to the final import progress state
|
* @returns Promise resolving to the final import progress state
|
||||||
@@ -234,6 +245,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
|
|||||||
* ```typescript
|
* ```typescript
|
||||||
* const progress = await importFromPlaynite(
|
* const progress = await importFromPlaynite(
|
||||||
* { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 },
|
* { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 },
|
||||||
|
* { limit: 10, nameFilter: 'Reside' },
|
||||||
* (msg) => console.log(msg),
|
* (msg) => console.log(msg),
|
||||||
* (prog) => updateUI(prog)
|
* (prog) => updateUI(prog)
|
||||||
* );
|
* );
|
||||||
@@ -242,6 +254,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
|
|||||||
*/
|
*/
|
||||||
export async function importFromPlaynite(
|
export async function importFromPlaynite(
|
||||||
config: PlayniteConfig,
|
config: PlayniteConfig,
|
||||||
|
options: PlayniteImportOptions,
|
||||||
logCallback: LogCallback,
|
logCallback: LogCallback,
|
||||||
progressCallback: ProgressCallback
|
progressCallback: ProgressCallback
|
||||||
): Promise<ImportProgress> {
|
): Promise<ImportProgress> {
|
||||||
@@ -254,6 +267,8 @@ export async function importFromPlaynite(
|
|||||||
errors: []
|
errors: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { limit, nameFilter } = options;
|
||||||
|
|
||||||
const baseUrl = `http://${config.ip}:${config.port || 19821}`;
|
const baseUrl = `http://${config.ip}:${config.port || 19821}`;
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -264,11 +279,14 @@ export async function importFromPlaynite(
|
|||||||
logCallback('Starting Playnite import...');
|
logCallback('Starting Playnite import...');
|
||||||
|
|
||||||
// Step 0: Fetch existing media to check for duplicates and enable updates
|
// Step 0: Fetch existing media to check for duplicates and enable updates
|
||||||
logCallback('Fetching existing media from Kyoo API...');
|
logCallback('Fetching existing media from Omnyx API...');
|
||||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingMedia = new Map(
|
const existingMedia = new Map(
|
||||||
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
|
(existingMediaData.data?.items || []).map((m: Media) => [
|
||||||
|
m.cleanname || m.title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-'),
|
||||||
|
m
|
||||||
|
])
|
||||||
);
|
);
|
||||||
logCallback(`Found ${existingMedia.size} existing games in database`);
|
logCallback(`Found ${existingMedia.size} existing games in database`);
|
||||||
|
|
||||||
@@ -276,7 +294,7 @@ export async function importFromPlaynite(
|
|||||||
logCallback(`Fetching games from ${baseUrl}/api/games...`);
|
logCallback(`Fetching games from ${baseUrl}/api/games...`);
|
||||||
progressCallback({ message: 'Fetching games from Playnite...' });
|
progressCallback({ message: 'Fetching games from Playnite...' });
|
||||||
|
|
||||||
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=5000`, {
|
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=${limit || 5000}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers
|
headers
|
||||||
});
|
});
|
||||||
@@ -286,22 +304,49 @@ export async function importFromPlaynite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gamesData: PlayniteGamesResponse = await gamesResponse.json();
|
const gamesData: PlayniteGamesResponse = await gamesResponse.json();
|
||||||
const games = gamesData.games || [];
|
let games = gamesData.games || [];
|
||||||
|
|
||||||
|
// Apply name filter if provided (case-insensitive)
|
||||||
|
if (nameFilter) {
|
||||||
|
const filterLower = nameFilter.toLowerCase();
|
||||||
|
games = games.filter(game => game.name?.toLowerCase().includes(filterLower));
|
||||||
|
logCallback(`Filtered to ${games.length} games matching "${nameFilter}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply limit if provided (after name filter)
|
||||||
|
if (limit && games.length > limit) {
|
||||||
|
games = games.slice(0, limit);
|
||||||
|
logCallback(`Limited to ${games.length} games`);
|
||||||
|
}
|
||||||
|
|
||||||
logCallback(`Found ${games.length} games in Playnite`);
|
logCallback(`Found ${games.length} games in Playnite`);
|
||||||
|
|
||||||
|
// Deduplicate games by name (case-insensitive, trimmed)
|
||||||
|
const uniqueGamesMap = new Map<string, PlayniteGame>();
|
||||||
|
for (const game of games) {
|
||||||
|
const normalizedName = game.name.toLowerCase().trim();
|
||||||
|
if (!uniqueGamesMap.has(normalizedName)) {
|
||||||
|
uniqueGamesMap.set(normalizedName, game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uniqueGames = Array.from(uniqueGamesMap.values());
|
||||||
|
if (uniqueGames.length !== games.length) {
|
||||||
|
logCallback(`Deduplicated: ${games.length} → ${uniqueGames.length} unique games`);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2: Fetch detailed information for each game
|
// Step 2: Fetch detailed information for each game
|
||||||
progressCallback({
|
progressCallback({
|
||||||
total: games.length,
|
total: uniqueGames.length,
|
||||||
current: 0,
|
current: 0,
|
||||||
stage: 'fetching',
|
stage: 'fetching',
|
||||||
message: 'Fetching game details...'
|
message: 'Fetching game details...'
|
||||||
});
|
});
|
||||||
|
|
||||||
const detailedGames: PlayniteGame[] = [];
|
const detailedGames: PlayniteGame[] = [];
|
||||||
for (let i = 0; i < games.length; i++) {
|
for (let i = 0; i < uniqueGames.length; i++) {
|
||||||
const game = games[i];
|
const game = uniqueGames[i];
|
||||||
try {
|
try {
|
||||||
logCallback(`Fetching details for: ${game.name} (${i + 1}/${games.length})`);
|
logCallback(`Fetching details for: ${game.name} (${i + 1}/${uniqueGames.length})`);
|
||||||
|
|
||||||
const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, {
|
const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -355,9 +400,24 @@ export async function importFromPlaynite(
|
|||||||
for (let i = 0; i < detailedGames.length; i++) {
|
for (let i = 0; i < detailedGames.length; i++) {
|
||||||
const game = detailedGames[i];
|
const game = detailedGames[i];
|
||||||
|
|
||||||
const existingGame = existingMedia.get(game.name);
|
const cleanName = game.name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-');
|
||||||
|
const existingGame = existingMedia.get(cleanName);
|
||||||
const isUpdate = existingGame !== undefined;
|
const isUpdate = existingGame !== undefined;
|
||||||
|
|
||||||
|
if (!isUpdate) {
|
||||||
|
// Debug: show similar titles from database for games not found
|
||||||
|
const similarTitles = Array.from(existingMedia.keys()).filter((key): key is string =>
|
||||||
|
typeof key === 'string' && (key.includes(cleanName.substring(0, 10)) || cleanName.includes(key.substring(0, 10)))
|
||||||
|
).slice(0, 5);
|
||||||
|
if (similarTitles.length > 0) {
|
||||||
|
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND. Similar titles in DB: ${similarTitles.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND (will import)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - FOUND (will update)`);
|
||||||
|
}
|
||||||
|
|
||||||
// Skip if updateExisting is false and item already exists
|
// Skip if updateExisting is false and item already exists
|
||||||
if (!config.updateExisting && isUpdate) {
|
if (!config.updateExisting && isUpdate) {
|
||||||
logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`);
|
logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* StashAPP Importer Module
|
* StashAPP Importer Module
|
||||||
*
|
*
|
||||||
* This module provides functionality to import adult video content and performers from a StashAPP instance
|
* This module provides functionality to import adult video content and performers from a StashAPP instance
|
||||||
* into the Kyoo media database. It fetches scene and performer data via GraphQL, converts it to the Kyoo
|
* into the Omnyx media database. It fetches scene and performer data via GraphQL, converts it to the Omnyx
|
||||||
* media format, and handles both new imports and updates to existing entries.
|
* media format, and handles both new imports and updates to existing entries.
|
||||||
*
|
*
|
||||||
* @module stashappImporter
|
* @module stashappImporter
|
||||||
@@ -226,7 +226,7 @@ function isPathBlacklisted(filePath: string, blacklist: string[]): boolean {
|
|||||||
* Updates or creates actor entries from StashAPP performers
|
* Updates or creates actor entries from StashAPP performers
|
||||||
*
|
*
|
||||||
* This function fetches all performers from StashAPP and updates or creates
|
* This function fetches all performers from StashAPP and updates or creates
|
||||||
* corresponding actor entries in the Kyoo database.
|
* corresponding actor entries in the Omnyx database.
|
||||||
*
|
*
|
||||||
* @param config - Configuration for connecting to StashAPP
|
* @param config - Configuration for connecting to StashAPP
|
||||||
* @param logCallback - Callback function for logging progress messages
|
* @param logCallback - Callback function for logging progress messages
|
||||||
@@ -251,8 +251,8 @@ export async function updateActorsFromStashAPP(
|
|||||||
try {
|
try {
|
||||||
logCallback('Starting StashAPP actor update...');
|
logCallback('Starting StashAPP actor update...');
|
||||||
|
|
||||||
// Fetch existing cast from Kyoo API
|
// Fetch existing cast from Omnyx API
|
||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Omnyx API...');
|
||||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingActors = new Map<string, Staff>(
|
const existingActors = new Map<string, Staff>(
|
||||||
@@ -456,10 +456,10 @@ export async function updateActorsFromStashAPP(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports scenes and performers from a StashAPP instance into the Kyoo media database
|
* Imports scenes and performers from a StashAPP instance into the Omnyx media database
|
||||||
*
|
*
|
||||||
* This function performs the following steps:
|
* This function performs the following steps:
|
||||||
* 1. Fetches existing media and cast from Kyoo to check for duplicates
|
* 1. Fetches existing media and cast from Omnyx to check for duplicates
|
||||||
* 2. Fetches all scenes from StashAPP via GraphQL
|
* 2. Fetches all scenes from StashAPP via GraphQL
|
||||||
* 3. Extracts unique performers from all scenes
|
* 3. Extracts unique performers from all scenes
|
||||||
* 4. Imports or updates performers first
|
* 4. Imports or updates performers first
|
||||||
@@ -499,7 +499,7 @@ export async function importFromStashAPP(
|
|||||||
logCallback('Starting StashAPP import...');
|
logCallback('Starting StashAPP import...');
|
||||||
|
|
||||||
// Step 0: Fetch existing media and cast to check for duplicates
|
// Step 0: Fetch existing media and cast to check for duplicates
|
||||||
logCallback('Fetching existing media from Kyoo API...');
|
logCallback('Fetching existing media from Omnyx API...');
|
||||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingTitles = new Set(
|
const existingTitles = new Set(
|
||||||
@@ -507,7 +507,7 @@ export async function importFromStashAPP(
|
|||||||
);
|
);
|
||||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||||
|
|
||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Omnyx API...');
|
||||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingActors = new Map<string, Staff>(
|
const existingActors = new Map<string, Staff>(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* XBVR Importer Module
|
* XBVR Importer Module
|
||||||
*
|
*
|
||||||
* This module provides functionality to import VR adult video content from an XBVR instance into the Kyoo media database.
|
* This module provides functionality to import VR adult video content from an XBVR instance into the Omnyx media database.
|
||||||
* It fetches scene data from the DeoVR API endpoint, extracts actors and video details, and handles both new imports
|
* It fetches scene data from the DeoVR API endpoint, extracts actors and video details, and handles both new imports
|
||||||
* and updates to existing entries. The module specifically filters for content in the 'Recent' scene group.
|
* and updates to existing entries. The module specifically filters for content in the 'Recent' scene group.
|
||||||
*
|
*
|
||||||
@@ -124,10 +124,10 @@ export type LogCallback = (message: string) => void;
|
|||||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports VR adult videos and actors from an XBVR instance into the Kyoo media database
|
* Imports VR adult videos and actors from an XBVR instance into the Omnyx media database
|
||||||
*
|
*
|
||||||
* This function performs the following steps:
|
* This function performs the following steps:
|
||||||
* 1. Fetches existing media and cast from Kyoo to check for duplicates
|
* 1. Fetches existing media and cast from Omnyx to check for duplicates
|
||||||
* 2. Fetches the scene list from the DeoVR API endpoint
|
* 2. Fetches the scene list from the DeoVR API endpoint
|
||||||
* 3. Extracts videos from the 'Recent' scene group
|
* 3. Extracts videos from the 'Recent' scene group
|
||||||
* 4. Fetches detailed information for each video
|
* 4. Fetches detailed information for each video
|
||||||
@@ -170,7 +170,7 @@ export async function importFromXBVR(
|
|||||||
logCallback('Starting DeoVR import...');
|
logCallback('Starting DeoVR import...');
|
||||||
|
|
||||||
// Step 0: Fetch existing media and cast to check for duplicates
|
// Step 0: Fetch existing media and cast to check for duplicates
|
||||||
logCallback('Fetching existing media from Kyoo API...');
|
logCallback('Fetching existing media from Omnyx API...');
|
||||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingTitles = new Set(
|
const existingTitles = new Set(
|
||||||
@@ -178,7 +178,7 @@ export async function importFromXBVR(
|
|||||||
);
|
);
|
||||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||||
|
|
||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Omnyx API...');
|
||||||
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingActors = new Map(
|
const existingActors = new Map(
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import {StrictMode} from 'react';
|
|||||||
import {createRoot} from 'react-dom/client';
|
import {createRoot} from 'react-dom/client';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<TooltipProvider>
|
||||||
|
<App />
|
||||||
|
</TooltipProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type MediaCategory = 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books'
|
|||||||
export interface Media {
|
export interface Media {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
cleanname?: string;
|
||||||
year: string;
|
year: string;
|
||||||
poster: string;
|
poster: string;
|
||||||
category: MediaCategory;
|
category: MediaCategory;
|
||||||
@@ -19,6 +20,7 @@ export interface Media {
|
|||||||
tracks?: Track[];
|
tracks?: Track[];
|
||||||
staff?: Staff[];
|
staff?: Staff[];
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
|
series?: string[];
|
||||||
platforms?: string[];
|
platforms?: string[];
|
||||||
developers?: string[];
|
developers?: string[];
|
||||||
completionStatus?: string;
|
completionStatus?: string;
|
||||||
@@ -119,10 +121,26 @@ export interface UserSettings {
|
|||||||
language: string;
|
language: string;
|
||||||
theme: 'light' | 'dark' | 'system';
|
theme: 'light' | 'dark' | 'system';
|
||||||
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[]
|
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[]
|
||||||
|
|
||||||
|
// Page Settings
|
||||||
|
pageTitle?: string; // Custom page title
|
||||||
|
favicon?: string; // Base64 encoded favicon/image
|
||||||
|
customColors?: CustomColors; // Custom color scheme
|
||||||
|
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomColors {
|
||||||
|
primary?: string; // Primary accent color (hex)
|
||||||
|
secondary?: string; // Secondary accent color (hex)
|
||||||
|
background?: string; // Background color (hex)
|
||||||
|
surface?: string; // Surface/card color (hex)
|
||||||
|
text?: string; // Text color (hex)
|
||||||
|
muted?: string; // Muted text color (hex)
|
||||||
|
border?: string; // Border color (hex)
|
||||||
|
}
|
||||||
|
|
||||||
// Source to Category mapping - ensures sources are only used with appropriate categories
|
// Source to Category mapping - ensures sources are only used with appropriate categories
|
||||||
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
|
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
|
||||||
'xbvr': ['Adult'],
|
'xbvr': ['Adult'],
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"./src/lib/xbvrImporter.ts"
|
"./src/lib/xbvrImporter.ts"
|
||||||
],
|
],
|
||||||
"out": "docs",
|
"out": "docs",
|
||||||
"name": "Kyoo Importer Documentation",
|
"name": "Omnyx Importer Documentation",
|
||||||
"theme": "default",
|
"theme": "default",
|
||||||
"excludePrivate": true,
|
"excludePrivate": true,
|
||||||
"excludeProtected": false,
|
"excludeProtected": false,
|
||||||
|
|||||||