10 Commits

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

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

Playnite importer: introduced PlayniteImportOptions (limit, nameFilter), added UI inputs to ImporterView, increased existing media fetch limit, added name filtering, import limiting, deduplication and improved cleanname-based matching/logging. Adjusted progress/total handling to account for deduped items.
2026-04-25 23:54:18 +02:00
Lars Behrends 34bb4a27be Add logo and banner to README
Embed project logo and banner in the README and add the corresponding image assets. Adds img/logo.png (displayed above the title) and img/banner.png (displayed below the title) to improve repository branding and visual presentation.
2026-04-20 22:55:48 +02:00
Lars Behrends e5cdd6b383 Rename Kyoo to Omnyx & add page settings
Rename project branding from "Kyoo" to "Omnyx" across README, index.html, metadata.json, typedoc and various UI components. Add support for page-level settings: pageTitle, favicon (Base64 upload/preview), and customColors (color scheme) — introduced CustomColors type, persisted via API types and converters, and wired into updateSettings/fetchSettings flows. UI: SettingsView adds page settings UI (upload, preview, color pickers) and handlers; App applies pageTitle, favicon and sets CSS variables for customColors; Sidebar and Header now display the configured page title. Also update importer modules and docs to use the new project name in logs/comments.
2026-04-20 22:51:33 +02:00
94 changed files with 13844 additions and 2141 deletions
+4 -2
View File
@@ -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
+1
View File
@@ -7,3 +7,4 @@ coverage/
.env* .env*
!.env.example !.env.example
/docs /docs
/.windsurf
+311
View File
@@ -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)
+67 -1
View File
@@ -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/)
+51
View File
@@ -0,0 +1,51 @@
# Multi-stage build for Omnyx Frontend
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Stage 2: Production
FROM nginx:alpine AS production
# Copy built files to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
RUN echo 'server { \
listen 3000; \
server_name localhost; \
location / { \
root /usr/share/nginx/html; \
index index.html; \
try_files $uri $uri/ /index.html; \
} \
location /api { \
proxy_pass http://backend:3001; \
proxy_http_version 1.1; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
proxy_set_header X-Forwarded-Proto $scheme; \
} \
}' > /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 3000
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+105 -2
View File
@@ -1,6 +1,10 @@
# Kyoo - Media Discovery Platform ![Omnyx Logo](img/logo.png)
A modern web application for browsing, managing, and discovering media across multiple categories. Kyoo provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR. # Omnyx - Media Discovery Platform
![Omnyx Banner](img/banner.png)
A modern web application for browsing, managing, and discovering media across multiple categories. Omnyx provides a unified interface for your media library with support for importing from external sources like Playnite, StashAPP, and XBVR.
## Features ## 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
+3 -1
View File
@@ -21,5 +21,7 @@
}, },
"menuColor": "default", "menuColor": "default",
"menuAccent": "subtle", "menuAccent": "subtle",
"registries": {} "registries": {
"@acme": "https://acme.com/r/{name}.json"
}
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

+1 -1
View File
@@ -3,7 +3,7 @@
<head> <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 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "Kyoo - Media Discovery", "name": "Omnyx - Media Discovery",
"description": "A polished media discovery and tracking application inspired by modern anime platforms.", "description": "A polished media discovery and tracking application inspired by modern anime platforms.",
"requestFramePermissions": [] "requestFramePermissions": []
} }
+495 -5
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -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",
+13
View File
@@ -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");
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+5145
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+12
View File
@@ -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"
]
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+111
View File
@@ -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");
}
+6
View File
@@ -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();
}
+43
View File
@@ -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"
]
}
}
+250 -39
View File
@@ -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 => {
// Avoid duplicate staff entries
if (!staffIds.has(s.id)) {
staffIds.add(s.id);
staff.push({ staff.push({
...s, ...s,
mediaId: media.id, mediaId: media.id,
mediaTitle: media.title 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,14 +387,99 @@ function AppContent() {
navigate('/browse'); navigate('/browse');
}; };
// Calculate media counts for sidebar (all categories)
const mediaCounts = useMemo(() => {
const counts: Record<string, number> = {};
// Count all enabled categories using allEnabledMedia
enabledCategories.forEach(cat => {
counts[cat] = allEnabledMedia.filter(m => m.category === cat).length;
});
// Add favorites count
counts['favorites'] = allEnabledMedia.filter(m => m.rating && m.rating >= 8).length;
// Add total count
counts['all'] = allEnabledMedia.length;
return counts;
}, [allEnabledMedia, enabledCategories]);
// Calculate active filter based on current URL
const activeFilter = useMemo(() => {
const path = location.pathname;
// Map routes to filter IDs
const routeMap: Record<string, string> = {
'/anime': 'anime',
'/movies': 'movies',
'/tv-series': 'tv-series',
'/music': 'music',
'/books': 'books',
'/adult': 'adult',
'/consoles': 'consoles',
'/games': 'games',
};
if (routeMap[path]) return routeMap[path];
if (searchParams.get('favorites') === 'true') return 'favorites';
return undefined;
}, [location.pathname, searchParams]);
return ( return (
<div className="min-h-screen bg-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9] flex"> <div className="min-h-screen bg-background font-sans selection:bg-[#e8466c]/20 selection:text-[#e8466c] flex">
<Sidebar <SidebarProvider defaultOpen={true}>
<AppSidebar
enabledCategories={enabledCategories} enabledCategories={enabledCategories}
onToggleCategory={toggleCategory} onToggleCategory={toggleCategory}
pageTitle={settings?.pageTitle || 'MediaVault'}
mediaCounts={mediaCounts}
activeFilter={activeFilter}
/> />
<main className="flex-1 lg:ml-72 flex flex-col"> <main className="flex-1 flex flex-col relative">
{/* Header with Search and Add Media */}
<header className="sticky top-0 z-30 bg-background/80 backdrop-blur-xl border-b border-border px-6 py-4">
<div className="flex items-center justify-between gap-4 max-w-[1920px] mx-auto">
{/* Search Bar */}
<div className="flex-1 max-w-xl">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
type="text"
placeholder="Search library..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-muted/30 border-border rounded-lg text-foreground placeholder:text-muted-foreground focus:border-[#e8466c]/50 focus:ring-[#e8466c]/20"
/>
</div>
</div>
{/* View Toggle and Add Button */}
<div className="flex items-center gap-3">
<div className="flex items-center bg-muted/30 rounded-lg p-1 border border-border">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded bg-accent text-accent-foreground"
>
<LayoutGrid className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
>
<List className="w-4 h-4" />
</Button>
</div>
<Button
onClick={handleAddMediaView}
className="bg-[#e8466c] hover:bg-[#d13d60] text-white font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<Plus className="w-4 h-4" />
Add Media
</Button>
</div>
</div>
</header>
<div className="flex-1">
<LayoutGroup> <LayoutGroup>
<Routes> <Routes>
<Route path="/" element={ <Route path="/" element={
@@ -329,13 +491,16 @@ function AppContent() {
} /> } />
<Route path="/browse" element={ <Route path="/browse" element={
<BrowseView <BrowseView
mediaList={filteredMedia} mediaList={searchQuery.trim() ? searchResultsMedia : allMedia}
onMediaClick={handleMediaClick} onMediaClick={handleMediaClick}
activeCategory={activeCategory} activeCategory={activeCategory}
itemsPerPage={settings?.itemsPerPage} itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize} gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange} onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading} loading={mediaLoading}
searchResultsCast={searchQuery.trim() ? searchResultsCast : []}
onCastClick={handlePersonClick}
searchQuery={searchQuery}
/> />
} /> } />
<Route path="/:category" element={ <Route path="/:category" element={
@@ -379,30 +544,76 @@ function AppContent() {
} /> } />
</Routes> </Routes>
</LayoutGroup> </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>
+30 -30
View File
@@ -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>
+312 -243
View File
@@ -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,154 +103,112 @@ 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));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleNextPage = () => {
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
{/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div className="flex flex-wrap items-center gap-2">
{/* Genre Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Star size={16} />
{selectedGenre || 'Genres'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
{allGenres.sort().map(genre => (
<DropdownMenuItem key={genre} onClick={() => setSelectedGenre(genre)}>{genre}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Studio Filter */}
<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", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
Studios
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
{allStudios.sort().map(studio => (
<DropdownMenuItem key={studio} onClick={() => setSelectedStudio(studio)}>{studio}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Platform Filter - Only for Games */}
{activeCategory === 'Games' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Monitor size={16} />
{selectedPlatform || 'Platforms'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedPlatform(null)}>All Platforms</DropdownMenuItem>
{allPlatforms.sort().map(platform => (
<DropdownMenuItem key={platform} onClick={() => setSelectedPlatform(platform)}>{platform}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Developer Filter - Only for Games */}
{activeCategory === 'Games' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Users size={16} />
{selectedDeveloper || 'Developers'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedDeveloper(null)}>All Developers</DropdownMenuItem>
{allDevelopers.sort().map(developer => (
<DropdownMenuItem key={developer} onClick={() => setSelectedDeveloper(developer)}>{developer}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Category Filter - Only for Games */}
{activeCategory === 'Games' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<FolderTree size={16} />
{selectedCategory || 'Categories'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>All Categories</DropdownMenuItem>
{allCategories.sort().map(category => (
<DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Source Filter */}
{allSources.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<Tag size={16} />
{selectedSource || 'Source'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedSource(null)}>All Sources</DropdownMenuItem>
{allSources.sort().map(source => (
<DropdownMenuItem key={source} onClick={() => setSelectedSource(source)}>{source}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory || selectedSource) && (
<Button
variant="link"
size="sm"
className="text-muted-foreground font-bold hover:text-[#6d28d9] transition-colors"
onClick={() => {
setSelectedGenre(null); setSelectedGenre(null);
setSelectedStudio(null); setSelectedStudio(null);
setSelectedPlatform(null); setSelectedPlatform(null);
setSelectedDeveloper(null); setSelectedDeveloper(null);
setSelectedCategory(null); setSelectedCategory(null);
setSelectedSource(null); setSelectedSource(null);
}} };
>
Clear Filters const handlePageChange = (page: number) => {
</Button> setCurrentPage(page);
)} const scrollContainer = document.getElementById('browse-scroll-container');
</div> if (scrollContainer) {
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
}
};
// Generate pagination items with ellipsis
const getPaginationItems = () => {
const items: (number | string)[] = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
items.push(i);
}
} else {
// Always show first page
items.push(1);
if (currentPage > 3) {
items.push('ellipsis-start');
}
// Show pages around current
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
items.push(i);
}
if (currentPage < totalPages - 2) {
items.push('ellipsis-end');
}
// Always show last page
if (totalPages > 1) {
items.push(totalPages);
}
}
return items;
};
// Calculate favorite IDs
const favoriteIds = useMemo(() => {
return new Set(mediaList.filter(m => m.rating && m.rating >= 8).map(m => m.id));
}, [mediaList]);
// Check if we have search results
const hasSearchResults = searchQuery.trim().length > 0;
const hasCastResults = searchResultsCast.length > 0;
const hasMediaResults = mediaList.length > 0;
// Pagination for cast results (show first 12)
const paginatedCast = useMemo(() => {
return searchResultsCast.slice(0, itemsPerPage);
}, [searchResultsCast, itemsPerPage]);
return (
<div className="flex flex-col h-[calc(100vh-4rem-4rem)] w-full">
{/* Sticky Header - Filter + Results Summary + Count */}
<div className="px-6 pt-4 pb-4 bg-background border-b border-white/10 shrink-0 z-10">
{/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
<MediaFilters
mediaList={mediaList}
activeCategory={activeCategory}
selectedGenre={selectedGenre}
selectedStudio={selectedStudio}
selectedPlatform={selectedPlatform}
selectedDeveloper={selectedDeveloper}
selectedCategory={selectedCategory}
selectedSource={selectedSource}
onGenreChange={setSelectedGenre}
onStudioChange={setSelectedStudio}
onPlatformChange={setSelectedPlatform}
onDeveloperChange={setSelectedDeveloper}
onCategoryChange={setSelectedCategory}
onSourceChange={setSelectedSource}
onClearAll={handleClearAll}
/>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Grid item size slider */} {/* Grid item size slider - only show in grid mode */}
<div className="flex items-center gap-3 bg-muted/50 backdrop-blur-sm rounded-xl px-4 py-2.5 border border-border/50"> {viewMode === 'grid' && (
<span className="text-xs font-bold text-muted-foreground">Size</span> <div className="flex items-center gap-3 bg-[#1a1d26] rounded-xl px-4 py-2 border border-white/10">
<span className="text-xs font-bold text-gray-500">Size</span>
<input <input
type="range" type="range"
min="1" min="1"
@@ -264,32 +219,20 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
setGridItemSize(newSize); setGridItemSize(newSize);
onGridItemSizeChange?.(newSize); onGridItemSizeChange?.(newSize);
}} }}
className="w-24 h-2 bg-background rounded-lg appearance-none cursor-pointer accent-[#6d28d9]" className="w-24 h-2 bg-[#0d0f14] rounded-lg appearance-none cursor-pointer accent-[#e8466c]"
/> />
<span className="text-xs font-bold text-[#6d28d9] w-5 text-center">{gridItemSize}</span> <span className="text-xs font-bold text-[#e8466c] w-5 text-center">{gridItemSize}</span>
</div> </div>
)}
<DropdownMenu> {/* View Toggle */}
<DropdownMenuTrigger asChild> <div className="flex items-center bg-[#1a1d26] rounded-xl p-1 border border-white/10">
<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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 transition-all rounded-lg", "h-8 w-8 transition-all rounded-lg",
viewMode === 'grid' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50" viewMode === 'grid' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
)} )}
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
> >
@@ -300,7 +243,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
size="icon" size="icon"
className={cn( className={cn(
"h-8 w-8 transition-all rounded-lg", "h-8 w-8 transition-all rounded-lg",
viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50" viewMode === 'list' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
)} )}
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
> >
@@ -310,90 +253,216 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
</div> </div>
</div> </div>
{/* Content */} {/* Search Results Summary */}
{hasSearchResults && (
<div className="flex items-center gap-4 mb-4 p-3 bg-[#1a1d26] rounded-lg border border-white/10">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">Search results for:</span>
<Badge variant="secondary" className="bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30">
"{searchQuery}"
</Badge>
</div>
<div className="flex items-center gap-4 ml-auto">
{hasMediaResults && (
<div className="flex items-center gap-1.5 text-sm text-gray-400">
<LayoutGrid size={14} />
<span>{mediaList.length} media</span>
</div>
)}
{hasCastResults && (
<div className="flex items-center gap-1.5 text-sm text-gray-400">
<Users size={14} />
<span>{searchResultsCast.length} cast</span>
</div>
)}
</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>
{/* 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">
{mediaList.length}
</Badge>
</div>
)}
{viewMode === 'list' ? (
<MediaTable
mediaList={paginatedMedia}
onMediaClick={onMediaClick}
favoriteIds={favoriteIds}
/>
) : (
<div className={cn(gridColsClass, "gap-x-4 gap-y-8")}>
{paginatedMedia.map((media) => ( {paginatedMedia.map((media) => (
viewMode === 'grid' ? (
<MediaCard <MediaCard
key={media.id} key={media.id}
media={media} media={media}
onClick={onMediaClick} onClick={onMediaClick}
showBadge={true}
showFavorite={true}
/> />
) : (
<MediaListItem
key={media.id}
media={media}
onClick={onMediaClick}
/>
)
))} ))}
</AnimatePresence>
</div> </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">
{/* Sticky Pagination Controls */}
{filteredMedia.length > 0 && (
<div className="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground font-medium">Items per page:</span> <span className="text-sm text-gray-500 font-medium">Items per page:</span>
<select <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => { onChange={(e) => {
setItemsPerPage(Number(e.target.value)); setItemsPerPage(Number(e.target.value));
setCurrentPage(1); setCurrentPage(1);
}} }}
className="bg-muted border-none rounded-md px-2 py-1 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none" 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].map(size => ( {[12, 20, 36, 48, 60, 100].map(size => (
<option key={size} value={size}>{size}</option> <option key={size} value={size}>{size}</option>
))} ))}
</select> </select>
</div> </div>
<div className="flex items-center gap-6"> <Pagination>
<Button <PaginationContent>
variant="outline" <PaginationItem>
size="sm" <PaginationPrevious
onClick={handlePrevPage} onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
disabled={currentPage === 1} className={cn(
className="gap-2 font-bold border-border" "border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
> currentPage === 1 && "pointer-events-none opacity-50"
<ChevronLeft size={16} /> )}
Previous />
</Button> </PaginationItem>
<div className="flex items-center gap-2"> {getPaginationItems().map((item, index) => (
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span> <React.Fragment key={index}>
<span className="text-sm text-muted-foreground font-medium">of</span> {item === 'ellipsis-start' || item === 'ellipsis-end' ? (
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span> <PaginationItem>
</div> <PaginationEllipsis />
</PaginationItem>
<Button ) : (
variant="outline" <PaginationItem>
size="sm" <PaginationLink
onClick={handleNextPage} isActive={currentPage === item}
disabled={currentPage === totalPages || totalPages === 0} onClick={() => handlePageChange(item as number)}
className="gap-2 font-bold border-border" 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"
)}
> >
Next {item}
<ChevronRight size={16} /> </PaginationLink>
</Button> </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>
)} )}
+320 -198
View File
@@ -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">
<AvatarImage
src={person.photo} src={person.photo}
alt={person.name} alt={person.name}
className="w-full h-full object-cover" className="object-cover"
referrerPolicy="no-referrer" 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,192 +116,255 @@ 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> </div>
<div> <span className="text-xs text-muted-foreground">Origin</span>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Date</p>
<p className="font-bold text-foreground">{person.birthDate || 'Unknown'}</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<MapPin size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Place</p>
<p className="font-bold text-foreground">{person.birthPlace || 'Unknown'}</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-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>
<span className="text-sm font-medium truncate max-w-[140px]" title={person.birthPlace || undefined}>
{person.birthPlace || '—'}
</span>
</div>
<Separator />
{/* Known For */}
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
<Briefcase size={14} />
</div>
<span className="text-xs text-muted-foreground">Role</span>
</div>
<Badge variant="secondary" className="text-xs font-normal bg-[#e8466c]/10 text-[#e8466c] border-none">
{person.role}
</Badge>
</div> </div>
{/* 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> </div>
<div> <span className="text-xs text-muted-foreground">Ethnicity</span>
<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>
<span className="text-sm font-medium truncate max-w-[140px]">
{person.adult_specifics?.ethnicity || person.ethnicity}
</span>
</div>
</>
)}
</CardContent>
</Card>
{/* Measurements Card - Only if data exists */}
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight ||
person.adult_specifics?.measurements || person.bust_size || person.hair_color || person.adult_specifics?.hair_color) && (
<Card className="border-border/60 overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<div className="w-5 h-5 rounded bg-[#e8466c]/10 flex items-center justify-center">
<Ruler size={12} className="text-[#e8466c]" />
</div>
Measurements
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{/* Height & Weight Grid */}
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight) && (
<>
<div className="grid grid-cols-2 divide-x divide-border">
{(person.adult_specifics?.height || person.height) && (
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Height</p>
<p className="text-lg font-semibold text-foreground">
{person.adult_specifics?.height || person.height}
<span className="text-xs font-normal text-muted-foreground ml-0.5">cm</span>
</p>
</div>
)}
{(person.adult_specifics?.weight || person.weight) && (
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Weight</p>
<p className="text-lg font-semibold text-foreground">
{person.adult_specifics?.weight || person.weight}
<span className="text-xs font-normal text-muted-foreground ml-0.5">kg</span>
</p>
</div> </div>
)} )}
</div> </div>
</div> <Separator />
</>
<div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
<h3 className="text-2xl font-black text-foreground">Measurements</h3>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Height</p>
<p className="font-bold text-foreground">{person.adult_specifics?.height || person.height} cm</p>
</div>
</div>
{(person.weight || person.adult_specifics?.weight) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Weight</p>
<p className="font-bold text-foreground">{person.adult_specifics?.weight || person.weight} kg</p>
</div>
</div>
)} )}
{/* 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 />
</>
)} )}
{/* Hair & Eyes Grid */}
<div className="grid grid-cols-2 divide-x divide-border">
{(person.hair_color || person.adult_specifics?.hair_color) && ( {(person.hair_color || person.adult_specifics?.hair_color) && (
<div className="flex items-start gap-4"> <div className="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 mb-1">
<Palette size={20} /> <Palette size={12} className="text-[#e8466c]" />
</div> <span className="text-[10px] text-muted-foreground uppercase tracking-wide">Hair</span>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Hair Color</p>
<p className="font-bold text-foreground">{person.adult_specifics?.hair_color || person.hair_color}</p>
</div> </div>
<p className="text-sm font-medium truncate">
{person.adult_specifics?.hair_color || person.hair_color}
</p>
</div> </div>
)} )}
{(person.eye_color || person.adult_specifics?.eye_color) && ( {(person.eye_color || person.adult_specifics?.eye_color) && (
<div className="flex items-start gap-4"> <div className="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 mb-1">
<Eye size={20} /> <Eye size={12} className="text-[#e8466c]" />
</div> <span className="text-[10px] text-muted-foreground uppercase tracking-wide">Eyes</span>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Eye Color</p>
<p className="font-bold text-foreground">{person.adult_specifics?.eye_color || person.eye_color}</p>
</div> </div>
<p className="text-sm font-medium truncate">
{person.adult_specifics?.eye_color || person.eye_color}
</p>
</div> </div>
)} )}
</div>
{/* Tattoos & Piercings */}
{(person.adult_specifics?.tattoos || person.adult_specifics?.piercings) && (
<>
<Separator />
<div className="grid grid-cols-2 divide-x divide-border">
{person.adult_specifics?.tattoos && ( {person.adult_specifics?.tattoos && (
<div className="flex items-start gap-4"> <div className="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"> <p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Tattoos</p>
<Palette size={20} /> <p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.tattoos}</p>
</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> </div>
)} )}
{person.adult_specifics?.piercings && ( {person.adult_specifics?.piercings && (
<div className="flex items-start gap-4"> <div className="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"> <p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Piercings</p>
<Palette size={20} /> <p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.piercings}</p>
</div>
<div>
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Piercings</p>
<p className="font-bold text-foreground">{person.adult_specifics.piercings}</p>
</div>
</div> </div>
)} )}
</div> </div>
</div> </>
)}
</CardContent>
</Card>
)}
</div> </div>
{/* Main Bio & Roles */} {/* Main Bio & Roles - Wider */}
<div className="lg:col-span-2 space-y-12"> <div className="lg:col-span-3">
<Tabs defaultValue={person.bio ? 'bio' : 'filmography'} className="w-full">
<TabsList className="mb-4 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto">
{person.bio && ( {person.bio && (
<section> <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 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3"> <BookOpen size={14} />
Biography Biography
</h2> </TabsTrigger>
<p className="text-foreground leading-relaxed text-lg"> )}
{person.filmography && person.filmography.length > 0 && (
<>
<TabsTrigger value="characters" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
<Theater size={14} />
Characters
</TabsTrigger>
<TabsTrigger value="filmography" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
<Film size={14} />
Filmography
</TabsTrigger>
</>
)}
</TabsList>
{person.bio && (
<TabsContent value="bio" className="mt-0">
<Card className="border-border/60">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">Biography</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-foreground leading-relaxed text-sm">
{person.bio} {person.bio}
</p> </p>
</section> </CardContent>
</Card>
</TabsContent>
)} )}
{person.filmography && person.filmography.length > 0 && ( {person.filmography && person.filmography.length > 0 && (
<section> <>
<h2 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3"> <TabsContent value="characters" className="mt-0">
<User className="text-[#6d28d9]" /> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
Characters <AnimatePresence mode="popLayout">
</h2> {person.filmography.map((item, index) => (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <motion.div
{person.filmography.map(item => (
<div
key={`${item.id}-char`} 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" initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
> >
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-background"> <Card
className="hover:border-[#e8466c]/30 hover:shadow-md transition-all duration-200 cursor-pointer group border-border/60"
onClick={() => handleMediaClick(item.id.toString())}
>
<CardContent className="p-3 flex items-center gap-3">
<div className="w-14 h-14 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
<img <img
src={item.poster || person.photo} src={item.poster || person.photo}
alt={item.title} alt={item.title}
@@ -282,61 +373,87 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
/> />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Character</p> <p className="text-[10px] text-muted-foreground uppercase tracking-wide">Character</p>
<h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4> <h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#e8466c] transition-colors">
<button {item.characterName || item.role}
onClick={() => handleMediaClick(item.id.toString())} </h4>
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left transition-colors" <p className="text-xs text-[#e8466c] truncate">{item.title}</p>
>
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>
</CardContent>
</Card>
</motion.div>
))} ))}
</AnimatePresence>
</div> </div>
</section> </TabsContent>
)}
{person.filmography && person.filmography.length > 0 && ( <TabsContent value="filmography" className="mt-0">
<section> {/* Sort Toolbar */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-4">
<h2 className="text-3xl font-black text-foreground flex items-center gap-3"> <p className="text-sm text-muted-foreground">
<Film className="text-[#6d28d9]" /> {person.filmography.length} {person.filmography.length === 1 ? 'title' : 'titles'}
Filmography </p>
</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')} className="h-8 px-2.5 rounded-lg text-xs border-border/60"
className="rounded-xl border-border hover:border-[#6d28d9]/50 transition-all duration-300"
> >
<ListFilter size={16} /> <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> </Button>
<select </DropdownMenuTrigger>
value={sortBy} <DropdownMenuContent align="end" className="w-40">
onChange={(e) => setSortBy(e.target.value as 'year' | 'title' | 'role')} <DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
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" 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"
> >
<option value="year">Year</option> <span className="flex items-center gap-2">
<option value="title">Title</option> <option.icon size={14} />
<option value="role">Role</option> {option.label}
</select> </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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{sortedFilmography.map(item => ( {/* Filmography Grid */}
<div <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} key={item.id}
onClick={() => handleMediaClick(item.id.toString())} initial={{ opacity: 0, y: 10 }}
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" animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
> >
<div className="w-16 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border border-border/30"> <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 <img
src={item.poster || person.photo} src={item.poster || person.photo}
alt={item.title} alt={item.title}
@@ -344,29 +461,34 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</div> </div>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<h4 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300"> <h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#e8466c] transition-colors">
{item.title} {item.title}
</h4> </h4>
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1"> <p className="text-xs text-muted-foreground mb-1">
{item.year || 'Unknown'} {item.year || 'Unknown'}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-border/50"> <Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-border/50 font-normal">
{item.role} {item.role}
</Badge> </Badge>
{item.category && ( {item.category && (
<Badge variant="secondary" className="text-[10px] font-bold py-0 h-5 bg-muted text-muted-foreground border-none"> <Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 bg-muted font-normal">
{item.category} {item.category}
</Badge> </Badge>
)} )}
</div> </div>
</div> </div>
</div> </CardContent>
</Card>
</motion.div>
))} ))}
</AnimatePresence>
</div> </div>
</section> </TabsContent>
</>
)} )}
</Tabs>
</div> </div>
</div> </div>
</div> </div>
+557 -186
View File
@@ -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,201 +214,482 @@ 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="flex items-center gap-3"> <div className="relative flex-1 min-w-[200px] max-w-[320px]">
<div className="relative"> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
<Input <Input
placeholder="Search cast..." placeholder="Search cast..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 w-full md:w-[300px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-11" className="pl-9 h-9 bg-muted/50 border-none rounded-lg text-sm focus-visible:ring-[#e8466c]/30"
/> />
</div> </div>
<Button
variant={showFilters ? 'default' : 'outline'} {/* View Toggle */}
size="icon" <ToggleGroup
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'}`} type="single"
onClick={() => setShowFilters(!showFilters)} value={viewMode}
onValueChange={(value: string | string[]) => {
const v = Array.isArray(value) ? value[0] : value;
if (v === 'grid' || v === 'table') {
setViewMode(v);
}
}}
className="bg-muted/50 p-0.5 rounded-lg"
> >
<Filter 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 <Button
variant="outline" variant="outline"
size="icon" size="sm"
className="rounded-xl h-11 w-11 border-border hover:border-[#6d28d9]/50 transition-all duration-300" className={cn(
onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')} "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={20} /> <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> </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 && ( {hasActiveFilters && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
className="rounded-xl h-11 w-11 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-300"
onClick={handleResetFilters} onClick={handleResetFilters}
title="Reset filters" className="h-8 px-2 text-muted-foreground hover:text-foreground hover:bg-muted/50"
> >
<X size={20} /> <X size={14} className="mr-1" />
Clear
</Button> </Button>
)} )}
</div> </div>
</div>
{showFilters && ( {/* Active Filter Badges */}
<motion.div {hasActiveFilters && (
initial={{ opacity: 0, height: 0 }} <div className="flex flex-wrap items-center gap-1.5">
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 && ( {searchQuery && (
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20"> <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} Search: {searchQuery}
<button onClick={() => setSearchQuery('')} className="hover:text-foreground"> <X size={12} className="ml-1" />
<X size={12} />
</button>
</Badge> </Badge>
)} )}
{filterOccupation && ( {filterOccupation && filterOccupation !== 'all' && (
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20"> <Badge
Occupation: {filterOccupation} variant="secondary"
<button onClick={() => setFilterOccupation('')} className="hover:text-foreground"> className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
<X size={12} /> onClick={() => setFilterOccupation('all')}
</button> >
{filterOccupation}
<X size={12} className="ml-1" />
</Badge> </Badge>
)} )}
{filterMediaType && ( {filterMediaType && filterMediaType !== 'all' && (
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20"> <Badge
Media Type: {filterMediaType} variant="secondary"
<button onClick={() => setFilterMediaType('')} className="hover:text-foreground"> className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
<X size={12} /> onClick={() => setFilterMediaType('all')}
</button> >
{filterMediaType}
<X size={12} className="ml-1" />
</Badge> </Badge>
)} )}
{(sortBy !== 'name' || sortOrder !== 'asc') && ( {(sortBy !== 'roleCount' || sortOrder !== 'desc') && (
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20"> <Badge
Sort: {sortBy} ({sortOrder}) variant="secondary"
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-foreground"> className="h-6 px-2 text-xs bg-muted text-muted-foreground hover:bg-muted/80 cursor-pointer"
<X size={12} /> onClick={() => { setSortBy('roleCount'); setSortOrder('desc'); }}
</button> >
Sort: {sortOptions.find(o => o.value === sortBy)?.label}
<X size={12} className="ml-1" />
</Badge> </Badge>
)} )}
</div> </div>
</motion.div>
)} )}
</div>
</div>
{/* Scrollable Content Area */}
<div id="cast-scroll-container" className="flex-1 overflow-y-auto px-6 pt-4 pb-20">
{/* Content Area */}
{loading ? ( {loading ? (
<Loading message="Loading cast..." /> <Loading message="Loading cast..." />
) : filteredStaff.length === 0 ? ( ) : filteredStaff.length === 0 ? (
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground"> <Card className="border-dashed">
<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"> <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} /> <User size={40} />
</div> </div>
<p className="text-xl font-bold">No cast members found</p> <p className="text-xl font-bold">No cast members found</p>
</div> </CardContent>
) : ( </Card>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> ) : 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"> <AnimatePresence mode="popLayout">
{paginatedStaff.map((person) => ( {paginatedStaff.map((person) => (
<motion.div <motion.div
key={person.id} key={person.id}
layout layout
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, y: -20 }}
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" 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)} onClick={() => onPersonClick(person)}
> >
<div className="flex items-center gap-4 mb-4"> {/* Card Header with Avatar and Info */}
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-border/50 group-hover:border-[#6d28d9] transition-colors duration-300"> <div className="p-3">
<div className="flex items-start gap-3">
<Avatar className="h-12 w-12 rounded-lg border-2 border-border/50 group-hover:border-[#e8466c] transition-colors duration-300 shadow-sm">
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" className="object-cover" />
<AvatarFallback className="rounded-lg bg-muted">
<User className="h-5 w-5 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-foreground truncate group-hover:text-[#e8466c] transition-colors duration-300 text-sm leading-tight">
{person.name}
</h3>
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
{person.role}
</p>
<div className="flex items-center gap-1.5 mt-1.5">
{person.filmography && person.filmography.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 bg-muted">
<Star className="w-2.5 h-2.5 mr-0.5" />
{person.filmography.length}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{person.filmography.length} roles</p>
</TooltipContent>
</Tooltip>
)}
{person.birthDate && (
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
<Calendar className="w-2.5 h-2.5" />
{new Date(person.birthDate).getFullYear()}
</span>
)}
</div>
</div>
</div>
</div>
{/* Latest Role Section */}
{person.filmography && person.filmography.length > 0 && (
<div className="px-3 pb-3">
<div className="bg-muted/50 rounded-lg p-2 flex items-center gap-2 border border-border/40 group-hover:border-[#e8466c]/20 transition-colors">
<div className="w-8 h-11 rounded overflow-hidden shrink-0 bg-background border border-border/40">
<img <img
src={person.photo} src={person.filmography[0].poster || person.photo}
alt={person.name} alt={person.filmography[0].title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300"> <p className="text-[10px] text-muted-foreground uppercase tracking-wide leading-none">Latest</p>
{person.name} <p className="text-[11px] font-medium text-foreground truncate">{person.filmography[0].title}</p>
</h3> <p className="text-[10px] text-[#e8466c] truncate">{person.filmography[0].role}</p>
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{person.role}
</p>
</div> </div>
{person.filmography && person.filmography.length > 0 && ( </div>
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold text-[10px] px-2 py-0.5 shrink-0"> </div>
{person.filmography.length} )}
</Badge> </Card>
</motion.div>
))}
</AnimatePresence>
</div>
) : (
/* Table View */
<Table className="w-full table-fixed">
<TableHeader>
<TableRow className="hover:bg-transparent border-border/60 bg-muted/30">
<TableHead className="w-14 rounded-tl-lg"></TableHead>
<TableHead
className="cursor-pointer hover:text-[#e8466c] transition-colors"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-1">
Name
{sortBy === 'name' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:text-[#e8466c] transition-colors"
onClick={() => handleSort('role')}
>
<div className="flex items-center gap-1">
Role
{sortBy === 'role' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
</div>
</TableHead>
<TableHead className="hidden md:table-cell">Latest Work</TableHead>
<TableHead
className="hidden sm:table-cell cursor-pointer hover:text-[#e8466c] transition-colors text-right"
onClick={() => handleSort('roleCount')}
>
<div className="flex items-center justify-end gap-1">
Roles
{sortBy === 'roleCount' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
</div>
</TableHead>
<TableHead className="w-10 rounded-tr-lg"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence mode="popLayout">
{paginatedStaff.map((person) => (
<motion.tr
key={person.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={cn(
"group cursor-pointer border-border/40 transition-colors",
hoveredRow === person.id ? "bg-muted/60" : "hover:bg-muted/40"
)}
onMouseEnter={() => setHoveredRow(person.id)}
onMouseLeave={() => setHoveredRow(null)}
onClick={() => onPersonClick(person)}
>
<TableCell className="py-3">
<Avatar className="h-10 w-10 rounded-lg border border-border/50">
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" />
<AvatarFallback className="rounded-lg bg-muted">
<User className="h-4 w-4 text-muted-foreground" />
</AvatarFallback>
</Avatar>
</TableCell>
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="group-hover:text-[#e8466c] transition-colors">{person.name}</span>
{person.birthDate && (
<span className="text-xs text-muted-foreground">
{new Date(person.birthDate).toLocaleDateString()}
</span>
)} )}
</div> </div>
</TableCell>
{person.filmography && person.filmography.length > 0 && ( <TableCell>
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-3 flex items-center gap-3 border border-border/30"> <Badge variant="secondary" className="font-normal bg-muted/80 text-muted-foreground">
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background border border-border/30"> {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 <img
src={person.filmography[0].poster || person.photo} src={person.filmography[0].poster || person.photo}
alt={person.filmography[0].title} alt={person.filmography[0].title}
@@ -378,67 +698,118 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
/> />
</div> </div>
<div className="min-w-0"> <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-sm truncate">{person.filmography[0].title}</p>
<p className="text-xs font-bold text-foreground truncate">{person.filmography[0].title}</p> <p className="text-xs text-muted-foreground">{person.filmography[0].role}</p>
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">{person.filmography[0].role}</p>
</div> </div>
</div> </div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)} )}
</motion.div> </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> </AnimatePresence>
</div> </TableBody>
</Table>
)} )}
{/* Pagination Controls */} {/* End of scrollable content area */}
</div>
{/* Sticky Pagination Controls */}
{filteredStaff.length > 0 && ( {filteredStaff.length > 0 && (
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border/50 pt-8"> <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"> <div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground font-medium">Items per page:</span> <span className="text-sm text-gray-500 font-medium">Items per page:</span>
<select <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => { onChange={(e) => {
setItemsPerPage(Number(e.target.value)); setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}} }}
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" 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].map(size => ( {[12, 20, 36, 48, 60, 100].map(size => (
<option key={size} value={size}>{size}</option> <option key={size} value={size}>{size}</option>
))} ))}
</select> </select>
</div> </div>
<div className="flex items-center gap-6"> <Pagination>
<Button <PaginationContent>
variant="outline" <PaginationItem>
size="sm" <PaginationPrevious
onClick={handlePrevPage} onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
disabled={currentPage === 1} className={cn(
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300" "border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
> currentPage === 1 && "pointer-events-none opacity-50"
<ChevronLeft size={16} /> )}
Previous />
</Button> </PaginationItem>
<div className="flex items-center gap-2"> {getPaginationItems().map((item, index) => (
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span> <React.Fragment key={index}>
<span className="text-sm text-muted-foreground font-medium">of</span> {item === 'ellipsis-start' || item === 'ellipsis-end' ? (
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span> <PaginationItem>
</div> <PaginationEllipsis />
</PaginationItem>
<Button ) : (
variant="outline" <PaginationItem>
size="sm" <PaginationLink
onClick={handleNextPage} isActive={currentPage === item}
disabled={currentPage === totalPages || totalPages === 0} onClick={() => handlePageChange(item as number)}
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300" 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"
)}
> >
Next {item}
<ChevronRight size={16} /> </PaginationLink>
</Button> </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>
)} )}
</div> </div>
</TooltipProvider>
); );
} }
+167 -193
View File
@@ -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 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> </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="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" className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-8"
> >
<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" /> {categoryCards.map((card, index) => {
<div className="relative"> const Icon = card.icon;
<div className="flex items-center justify-between mb-4"> return (
<Hash className="w-10 h-10 text-[#6d28d9]" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
</div>
<div className="text-4xl font-black text-foreground">{stats.totalMedia}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Media Items</div>
</div>
</motion.div>
<motion.div <motion.div
key={card.key}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.1 + index * 0.05 }}
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" onClick={() => navigate(card.path)}
className={`relative overflow-hidden rounded-xl p-5 bg-gradient-to-br ${card.color} border border-border/50 hover:border-border/80 transition-all duration-300 cursor-pointer group`}
> >
<div className="absolute top-0 right-0 w-32 h-32 bg-yellow-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" /> <div className="flex items-start justify-between">
<div className="relative"> <div>
<div className="flex items-center justify-between mb-4"> <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">{card.label}</p>
<Star className="w-10 h-10 text-yellow-500" /> <p className="text-3xl font-bold text-foreground">{card.count}</p>
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Average</span> </div>
<div className={`w-10 h-10 rounded-lg ${card.iconBg} flex items-center justify-center`}>
<Icon className="w-5 h-5 text-white" />
</div> </div>
<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> </div>
</motion.div> </motion.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 className="relative">
<div className="flex items-center justify-between mb-4">
<Play className="w-10 h-10 text-green-500" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
</div>
<div className="text-4xl font-black text-foreground">{stats.totalPlayCount}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Play Count</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-blue-500/10 to-cyan-500/5 border border-blue-500/20 hover:border-blue-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/10"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="relative">
<div className="flex items-center justify-between mb-4">
<Clock className="w-10 h-10 text-blue-500" />
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
</div>
<div className="text-4xl font-black text-foreground">{formatPlaytime(stats.totalPlaytime)}</div>
<div className="text-sm text-muted-foreground font-medium mt-1">Playtime</div>
</div>
</motion.div>
</div>
{/* Category Breakdown */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="relative overflow-hidden rounded-2xl p-8 bg-gradient-to-br from-muted/50 to-muted/30 border border-border mb-10"
>
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
<TrendingUp className="w-6 h-6 text-[#6d28d9]" />
Category Breakdown
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-4">
{(Object.keys(stats.categories) as MediaCategory[]).map((category) => {
const Icon = categoryIcons[category];
const count = stats.categories[category] || 0;
const percentage = stats.totalMedia > 0 ? ((count / stats.totalMedia) * 100).toFixed(1) : '0';
return (
<div <div
key={category} onClick={() => navigate('/browse?favorites=true')}
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`} 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"
> >
<Icon className="w-7 h-7" /> <div className="flex items-center justify-between">
<div className="text-xs font-bold uppercase tracking-wider">{category}</div> <div className="flex items-center gap-4">
<div className="text-3xl font-black">{count}</div> <div className="w-12 h-12 rounded-xl bg-[#e8466c]/20 flex items-center justify-center">
<div className="text-xs font-medium opacity-75">{percentage}%</div> <Heart className="w-6 h-6 text-[#e8466c]" />
</div>
<div>
<p className="text-xs font-semibold text-[#e8466c] uppercase tracking-wider">FAVORITES</p>
<p className="text-2xl font-bold text-foreground">{favoritesMedia.length} <span className="text-sm font-normal text-muted-foreground">items in your favorites</span></p>
</div>
</div>
<div className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
<span className="text-sm font-medium">View Favorites</span>
<ChevronRight className="w-5 h-5" />
</div>
</div> </div>
);
})}
</div> </div>
</motion.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>
)} )}
+341 -301
View File
@@ -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 */}
<div className="relative h-[40vh] md:h-[45vh] overflow-hidden bg-zinc-900">
<img <img
src={media.banner || media.poster} src={media.banner || media.poster}
alt={media.title} alt={media.title}
className="w-full h-full object-cover" className="w-full h-full object-cover opacity-40 blur-sm scale-105"
referrerPolicy="no-referrer" 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 */}
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
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" 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"
> >
<ChevronLeft size={24} /> <ArrowLeft className="h-5 w-5" />
</button> </Button>
{/* Quick Actions - z-50 to ensure clickable */}
<div className="absolute top-4 right-4 sm:right-6 z-50 flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
>
<Heart className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add to favorites</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
>
<Bookmark className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Bookmark</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
>
<Share2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Share</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>More options</TooltipContent>
</Tooltip>
</div> </div>
{/* Content */} {/* Hero Content - pt-16 to account for navbar + buttons */}
<div className="max-w-[1920px] mx-auto px-6 py-8 pb-24 -mt-32 relative z-10"> <div className="absolute inset-0 pt-16 flex items-end px-4 sm:px-6 pb-8">
<div className="flex flex-col lg:flex-row gap-8"> <div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-6">
{/* Left Column: Cover Image */} {/* Poster */}
<div className="w-full lg:w-[400px] shrink-0">
<motion.div <motion.div
layoutId={`media-${media.id}`} initial={{ opacity: 0, y: 20 }}
className={`rounded-2xl overflow-hidden shadow-2xl bg-card border border-border/50 ${ animate={{ opacity: 1, y: 0 }}
className="shrink-0"
>
<Avatar className={`h-40 md:h-48 w-auto rounded-none border-4 border-background shadow-2xl ${
media.aspectRatio === '16/9' ? 'aspect-video' : media.aspectRatio === '16/9' ? 'aspect-video' :
media.aspectRatio === '1/1' ? 'aspect-square' : media.aspectRatio === '1/1' ? 'aspect-square' :
'aspect-[2/3]' 'aspect-[2/3]'
}`} }`}>
> <AvatarImage
<img
src={media.poster} src={media.poster}
alt={media.title} alt={media.title}
className="w-full h-full object-cover" className="object-cover"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
<AvatarFallback className="rounded-none text-3xl bg-muted">
{categoryIcons[media.category] || <Film className="h-12 w-12" />}
</AvatarFallback>
</Avatar>
</motion.div> </motion.div>
</div>
{/* Right Column: Info */} {/* Title & Meta */}
<div className="flex-1"> <div className="flex-1 text-center md:text-left pb-2">
{/* Header with tags */} <motion.div
<div className="flex flex-wrap items-center gap-3 mb-4"> initial={{ opacity: 0, x: -20 }}
<h1 className="text-4xl lg:text-5xl font-black text-foreground"> animate={{ opacity: 1, x: 0 }}
{media.title} transition={{ delay: 0.1 }}
</h1> >
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mb-3">
{categoryIcons[media.category] && (
<Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20">
{categoryIcons[media.category]}
<span className="ml-1">{media.category}</span>
</Badge>
)}
{media.type && (
<Badge variant="outline" className="text-xs">
{media.type}
</Badge>
)}
{media.status && ( {media.status && (
<Badge className={ <Badge variant="outline" className={`text-xs font-medium ${statusBadgeClass}`}>
media.status === 'watching' || media.status === 'reading' || media.status === 'listening' || media.status === 'playing' {media.status.charAt(0).toUpperCase() + media.status.slice(1)}
? 'bg-green-500/20 text-green-400 border-green-500/30 font-bold'
: media.status === 'completed'
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 font-bold'
: 'bg-gray-500/20 text-gray-400 border-gray-500/30 font-bold'
}>
{media.status.toUpperCase()}
</Badge> </Badge>
)} )}
{media.completionStatus && ( {media.completionStatus && (
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30 font-bold">{media.completionStatus.toUpperCase()}</Badge> <Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20 text-xs font-medium">
{media.completionStatus}
</Badge>
)} )}
</div> </div>
{/* Show Details */} <h1 className="text-3xl md:text-5xl font-bold text-foreground mb-3 tracking-tight">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6"> {media.title}
<div className="flex items-center gap-2 text-sm text-muted-foreground"> </h1>
<Calendar size={16} />
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
<span>{media.year}</span> <span>{media.year}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> {media.rating && (
<span>{media.status ? media.status.charAt(0).toUpperCase() + media.status.slice(1) : 'Unknown'}</span> <div className="flex items-center gap-1.5">
<Star className="w-4 h-4 text-amber-500" />
<span>{media.rating.toFixed(1)}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> )}
<Clock size={16} /> {media.playtime && (
<span>{media.playtime ? `${media.playtime}h` : '12h 30m'}</span> <div className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
<span>{media.playtime}h played</span>
</div> </div>
)}
{hasEpisodes && (
<div className="flex items-center gap-1.5">
<Tv className="w-4 h-4" />
<span>{media.episodes!.length} episodes</span>
</div>
)}
{hasTracks && (
<div className="flex items-center gap-1.5">
<Disc className="w-4 h-4" />
<span>{media.tracks!.length} tracks</span>
</div>
)}
</div>
</motion.div>
</div> </div>
{/* Progress Bar */} {/* Primary Action */}
<div className="mb-6"> <motion.div
<div className="flex items-center justify-between mb-2"> initial={{ opacity: 0, y: 20 }}
<span className="text-sm font-bold text-foreground">Progress</span> animate={{ opacity: 1, y: 0 }}
<span className="text-sm font-bold text-[#6d28d9]">{progress}%</span> transition={{ delay: 0.2 }}
</div> className="shrink-0"
<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} <Button size="lg" className="rounded-xl px-8 shadow-lg">
</button> <Play className="w-5 h-5 mr-2 fill-current" />
))} Play
</Button>
</motion.div>
</div>
</div>
</div> </div>
{/* Genre Tags */} {/* Content Section */}
{activeTab === 'Overview' && ( <div className="max-w-[1920px] mx-auto px-4 sm:px-6 mt-6">
<div className="flex flex-wrap gap-2 mb-6"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{media.genres?.map(genre => ( {/* Left Sidebar - Info Cards */}
<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"> <div className="space-y-4 lg:col-span-1">
{genre} {/* 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>
<div className="flex flex-wrap gap-1.5">
{media.studios.map(studio => (
<Badge key={studio} variant="secondary" className="text-xs">
{studio}
</Badge> </Badge>
))} ))}
</div> </div>
</CardContent>
</Card>
)} )}
{/* Description */} {/* Platforms (for Games) */}
{activeTab === 'Overview' && ( {media.platforms && media.platforms.length > 0 && (
<div <Card className="border-border/60 overflow-hidden">
className="text-foreground leading-relaxed mb-8 max-w-4xl prose prose-sm dark:prose-invert" <CardContent className="p-4">
dangerouslySetInnerHTML={{ __html: media.description || '' }} <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" />
{/* 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> </div>
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4> Platforms
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p> </h3>
</div> <div className="flex flex-wrap gap-1.5">
))} {media.platforms.map(platform => (
{hasMoreCast && ( <Badge key={platform} variant="secondary" className="text-xs">
<button {platform}
onClick={() => setShowAllCast(!showAllCast)}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 flex items-center justify-center"
>
<span className="font-bold text-[#6d28d9]">
{showAllCast ? 'Show Less' : `+${media.staff!.length - castLimit} more`}
</span>
</button>
)}
</div>
</section>
)}
{/* Episodes Section - Only show if episodes data exists and Seasons tab is active */}
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
<section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-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> </Badge>
</div>
<ChevronDown
size={24}
className={`transition-transform duration-300 text-muted-foreground ${
expandedSeasons.has(season) ? 'rotate-180' : ''
}`}
/>
</button>
{expandedSeasons.has(season) && (
<div className="p-6 pt-0 space-y-6">
{episodesBySeason[season].map(episode => (
<div key={episode.id} className="group cursor-pointer">
<div className="flex flex-col md:flex-row gap-6">
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-2xl overflow-hidden shadow-sm relative border border-border/30">
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
</div>
<div className="flex-1 py-1">
<div className="flex items-center justify-between mb-2">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
E{episode.episode_number} {episode.title}
</h3>
<span className="text-xs font-bold text-muted-foreground">{episode.air_date} {episode.duration}m</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
{episode.description}
</p>
</div>
</div>
<Separator className="mt-6 bg-border/50" />
</div>
))} ))}
</div> </div>
)} </CardContent>
</div> </Card>
))}
</div>
</section>
)} )}
{/* Tracks Section - Only show if tracks data exists and Tracks tab is active */} {/* Developers (for Games) */}
{media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && ( {media.developers && media.developers.length > 0 && (
<section className="mt-20"> <Card className="border-border/60 overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8"> <CardContent className="p-4">
<div className="flex items-center gap-6"> <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3 flex items-center gap-2">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl"> <div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<span className="opacity-40">{media.tracks.length}</span> Track{media.tracks.length !== 1 ? 's' : ''} <Users className="w-3 h-3 text-primary" />
</div> </div>
</div> Developers
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-2">
{media.tracks.map(track => (
<div key={track.id} className="group cursor-pointer flex items-center gap-4 p-4 rounded-2xl hover:bg-muted/50 transition-colors duration-300 border border-transparent hover:border-border/30">
<span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
<div className="flex-1">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
{track.title}
</h3> </h3>
<p className="text-sm text-muted-foreground">{track.artist}</p> <div className="flex flex-wrap gap-1.5">
</div> {media.developers.map(dev => (
<span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span> <Badge key={dev} variant="secondary" className="text-xs">
</div> {dev}
</Badge>
))} ))}
</div> </div>
</section> </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> </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>
</div> </div>
</div> </div>
</div>
</TooltipProvider>
); );
} }
+8 -8
View File
@@ -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
+30 -3
View File
@@ -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()}%` }}
/> />
+2 -2
View File
@@ -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>
+489 -44
View File
@@ -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()];
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 ( 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,
isHovered && 'ring-2 ring-primary/20 shadow-xl'
)}
>
<div className="absolute inset-0 bg-muted">
<img <img
src={media.poster} src={media.poster}
alt={media.title} alt={media.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" className="h-full w-full object-cover object-center"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
{renderCategoryBadge()}
{renderFavoriteButton()}
{renderStatusIndicator()}
<div className="absolute bottom-0 left-0 right-0 p-2">
<h3 className="text-xs font-semibold text-white line-clamp-1">{media.title}</h3>
<p className="text-[10px] text-white/60">{media.year}</p>
</div>
</Card>
</motion.div>
);
{/* Gradient Overlay */} const renderMinimalVariant = () => (
<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" /> <motion.div
layoutId={`media-${media.id}`}
{/* Rating Badge */} 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 && ( {media.rating && (
<div className="absolute top-3 right-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-full flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-all duration-500 transform translate-y-[-10px] group-hover:translate-y-0">
<Star size={12} className="text-yellow-400 fill-yellow-400" />
<span className="text-xs font-bold text-white">{media.rating}</span>
</div>
)}
{media.status && (
<div className={cn(
"absolute top-3 left-3 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-lg z-10",
statusColors[media.status]
)} />
)}
{/* 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> <span className="text-[10px] text-white/50"></span>
<p className="text-xs font-medium text-muted-foreground/70 line-clamp-1"> <span className="text-[10px] text-white/70">{media.rating.toFixed(1)}</span>
{media.genres[0]}
</p>
</> </>
)} )}
</div> </div>
</div> </div>
</Card>
</motion.div> </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();
} }
+77 -64
View File
@@ -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" />
{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>
<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">
<Star size={10} className="text-[#e8466c] fill-[#e8466c]" />
<span className="text-xs font-medium text-[#e8466c]">
{media.rating?.toFixed(1) || '-'}
</span>
</div> </div>
<div className="flex items-center gap-4 mb-3">
<div className="flex items-center gap-1 text-xs font-bold text-muted-foreground">
<Star size={14} className="text-yellow-500" fill="currentColor" />
{media.rating || 'N/A'}
</div>
<div className="text-xs font-bold text-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"> {/* TYPE Column */}
{media.description || "No description available for this title."} <div className="w-[70px] shrink-0 mr-4">
</p> <span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
categoryInfo.bgColor,
categoryInfo.color
)}>
{CategoryIcon && <CategoryIcon size={9} />}
{categoryInfo.label}
</span>
</div> </div>
<div className="hidden md:flex items-center gap-2"> {/* GENRE 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-[140px] shrink-0 mr-4">
<Play size={18} fill="currentColor" /> <span className="text-sm text-muted-foreground truncate block">
</Button> {media.genres?.slice(0, 2).join(', ') || '-'}
<Button size="icon" variant="ghost" className="rounded-xl text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10 transition-all duration-300"> </span>
<Bookmark size={18} /> </div>
</Button>
{/* 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>
); );
+264
View File
@@ -0,0 +1,264 @@
import React, { useState, useMemo } from 'react';
import { Media, MediaCategory } from '@/types';
import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import {
Star,
Heart,
Gamepad2,
Film,
Tv,
Eye,
Music,
BookOpen,
Monitor,
ArrowUpDown,
ArrowUp,
ArrowDown
} from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface MediaTableProps {
mediaList: Media[];
onMediaClick: (media: Media) => void;
onFavoriteToggle?: (media: Media) => void;
favoriteIds?: Set<string>;
}
type SortField = 'title' | 'category' | 'genre' | 'rating' | 'year' | 'plays';
type SortDirection = 'asc' | 'desc';
const categoryConfig: Record<MediaCategory, {
label: string;
color: string;
bgColor: string;
icon: React.ElementType | null;
}> = {
'Anime': { label: 'ANIME', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: null },
'Movies': { label: 'MOVIE', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Film },
'TV Series': { label: 'SERIES', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: Tv },
'Music': { label: 'MUSIC', color: 'text-pink-400', bgColor: 'bg-pink-500/20', icon: Music },
'Books': { label: 'BOOK', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: BookOpen },
'Games': { label: 'GAME', color: 'text-indigo-400', bgColor: 'bg-indigo-500/20', icon: Gamepad2 },
'Consoles': { label: 'CONSOLE', color: 'text-orange-400', bgColor: 'bg-orange-500/20', icon: Monitor },
'Adult': { label: 'ADULT', color: 'text-rose-400', bgColor: 'bg-rose-500/20', icon: Eye },
};
export default function MediaTable({
mediaList,
onMediaClick,
onFavoriteToggle,
favoriteIds = new Set()
}: MediaTableProps) {
const [sortField, setSortField] = useState<SortField>('title');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedMedia = useMemo(() => {
const sorted = [...mediaList];
sorted.sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'title':
comparison = a.title.localeCompare(b.title);
break;
case 'category':
comparison = a.category.localeCompare(b.category);
break;
case 'genre':
const genreA = a.genres?.[0] || '';
const genreB = b.genres?.[0] || '';
comparison = genreA.localeCompare(genreB);
break;
case 'rating':
comparison = (b.rating || 0) - (a.rating || 0);
break;
case 'year':
comparison = b.year.localeCompare(a.year);
break;
case 'plays':
comparison = (b.playCount || 0) - (a.playCount || 0);
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
return sorted;
}, [mediaList, sortField, sortDirection]);
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) {
return <ArrowUpDown size={14} className="text-muted-foreground/40 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />;
}
return sortDirection === 'asc'
? <ArrowUp size={14} className="text-[#e8466c] ml-1" />
: <ArrowDown size={14} className="text-[#e8466c] ml-1" />;
};
const handleFavoriteClick = (e: React.MouseEvent, media: Media) => {
e.stopPropagation();
onFavoriteToggle?.(media);
};
return (
<Table className="w-full">
<TableHeader>
<TableRow className="border-b border-border/20 hover:bg-transparent">
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[45%]"
onClick={() => handleSort('title')}
>
<div className="flex items-center">
Title <SortIcon field="title" />
</div>
</TableHead>
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[80px]"
onClick={() => handleSort('category')}
>
<div className="flex items-center">
Type <SortIcon field="category" />
</div>
</TableHead>
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[18%]"
onClick={() => handleSort('genre')}
>
<div className="flex items-center">
Genre <SortIcon field="genre" />
</div>
</TableHead>
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[70px] text-center"
onClick={() => handleSort('rating')}
>
<div className="flex items-center justify-center">
Rating <SortIcon field="rating" />
</div>
</TableHead>
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[60px] text-center"
onClick={() => handleSort('year')}
>
<div className="flex items-center justify-center">
Year <SortIcon field="year" />
</div>
</TableHead>
<TableHead
className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer hover:text-foreground/80 transition-colors group w-[60px] text-right"
onClick={() => handleSort('plays')}
>
<div className="flex items-center justify-end">
Plays <SortIcon field="plays" />
</div>
</TableHead>
<TableHead className="w-[40px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedMedia.map((media) => {
const categoryInfo = categoryConfig[media.category];
const CategoryIcon = categoryInfo?.icon;
const isFavorite = favoriteIds.has(media.id);
return (
<TableRow
key={media.id}
className="border-b border-border/20 hover:bg-muted/30 transition-colors cursor-pointer group"
onClick={() => onMediaClick(media)}
>
{/* Title Cell with Poster */}
<TableCell className="py-2">
<div className="flex items-center gap-3">
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-muted">
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground truncate group-hover:text-[#e8466c] transition-colors">
{media.title}
</div>
</div>
</div>
</TableCell>
{/* Type Badge */}
<TableCell>
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
categoryInfo.bgColor,
categoryInfo.color
)}>
{CategoryIcon && <CategoryIcon size={9} />}
{categoryInfo.label}
</span>
</TableCell>
{/* Genre */}
<TableCell>
<span className="text-sm text-muted-foreground truncate block">
{media.genres?.join(', ') || '-'}
</span>
</TableCell>
{/* Rating */}
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Star size={12} className="text-[#e8466c] fill-[#e8466c]" />
<span className="text-sm font-medium text-foreground/80">
{media.rating?.toFixed(1) || '-'}
</span>
</div>
</TableCell>
{/* Year */}
<TableCell className="text-center">
<span className="text-sm text-muted-foreground/80">{media.year}</span>
</TableCell>
{/* Plays */}
<TableCell className="text-right">
<span className="text-sm text-muted-foreground/80">{media.playCount || 0}</span>
</TableCell>
{/* Favorite */}
<TableCell>
<button
onClick={(e) => handleFavoriteClick(e, media)}
className={cn(
"p-1 rounded transition-colors",
isFavorite
? "text-[#e8466c]"
: "text-muted-foreground/40 hover:text-muted-foreground/60"
)}
>
<Heart size={14} className={cn(isFavorite && "fill-current")} />
</button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
+410 -172
View File
@@ -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,192 +160,260 @@ 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 className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="rounded-lg"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div> <div>
<Link <h1 className="text-2xl font-bold text-foreground">Settings</h1>
to="/" <p className="text-sm text-muted-foreground">Manage your preferences</p>
className="inline-flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-[#6d28d9] transition-colors mb-2 hover:bg-muted/50 px-3 py-1 rounded-xl transition-all duration-300"
>
<ArrowLeft size={16} />
Back to home
</Link>
<h1 className="text-4xl font-black text-foreground">Settings</h1>
</div> </div>
<button
onClick={handleSave}
disabled={isSaving}
className="bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-bold px-6 py-3 h-12 rounded-xl flex items-center gap-2 transition-all duration-300 hover:scale-[1.02] shadow-lg shadow-[#6d28d9]/30 disabled:opacity-50 disabled:hover:scale-100"
>
{isSaving ? (
'Saving...'
) : (
<>
<Save size={16} />
Save Changes
</>
)}
</button>
</div> </div>
<div className="flex items-center gap-3">
<AnimatePresence mode="wait">
{saveStatus === 'success' && ( {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"> <motion.div
Settings saved successfully! initial={{ opacity: 0, x: 20 }}
</div> 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' && ( {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"> <motion.div
Failed to save settings. Please try again. initial={{ opacity: 0, x: 20 }}
</div> 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>
<div className="grid gap-8">
{/* Library Settings */} {/* Library Settings */}
<section> <TabsContent value="library" className="mt-0 space-y-6">
<h2 className="text-2xl font-black text-foreground mb-6">Library Settings</h2> <Card className="border-border/60">
<div className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 border border-border/50"> <CardHeader>
<p className="text-sm font-medium text-muted-foreground mb-4"> <div className="flex items-center justify-between">
Toggle which media areas you want to see in your library. <div>
</p> <CardTitle>Media Categories</CardTitle>
<div className="grid gap-4"> <CardDescription>Toggle which media types appear in your library</CardDescription>
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => ( </div>
<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"> <Badge variant="secondary">{enabledCount}/{totalCategories} enabled</Badge>
<div className="flex items-center gap-4"> </div>
<div className="w-10 h-10 rounded-xl bg-muted flex items-center justify-center text-[#6d28d9] border border-border/30"> </CardHeader>
{CATEGORY_ICONS[category]} <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>
<div> <div>
<Label htmlFor={category} className="text-sm font-black text-foreground cursor-pointer"> <p className="font-medium text-foreground">{category}</p>
{category} <p className="text-xs text-muted-foreground">
</Label> {isEnabled ? 'Visible in library' : 'Hidden'}
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
{settings.enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
</p> </p>
</div> </div>
</div> </div>
<Switch <Switch
id={category} checked={isEnabled}
checked={settings.enabledCategories.includes(category)}
onCheckedChange={() => toggleCategory(category)} onCheckedChange={() => toggleCategory(category)}
/> />
</div> </div>
))} );
})}
</div> </div>
</div> </CardContent>
</section> </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">
<CardHeader>
<CardTitle>View Options</CardTitle>
<CardDescription>Configure how items are displayed</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Items per page */} {/* Items per page */}
<div> <div className="space-y-3">
<Label className="text-sm font-black text-foreground mb-2 block">Items per page</Label> <Label>Items per page</Label>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{ITEMS_PER_PAGE_OPTIONS.map((option) => ( {ITEMS_PER_PAGE_OPTIONS.map((option) => (
<button <Button
key={option} key={option}
variant={settings.itemsPerPage === option ? 'default' : 'outline'}
size="sm"
onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: option }))} onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: option }))}
className={`px-4 py-2 rounded-xl text-sm font-bold transition-all ${
settings.itemsPerPage === option
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20'
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30'
}`}
> >
{option} {option}
</button> </Button>
))} ))}
</div> </div>
</div> </div>
<Separator />
{/* Default view */} {/* Default view */}
<div> <div className="space-y-3">
<Label className="text-sm font-black text-foreground mb-2 block">Default view</Label> <Label>Default view</Label>
<div className="flex gap-2"> <div className="grid grid-cols-2 gap-3">
<button <Button
variant={settings.defaultView === 'grid' ? 'default' : 'outline'}
className="justify-center gap-2"
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))} 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} /> <LayoutGrid className="h-4 w-4" />
Grid Grid
</button> </Button>
<button <Button
variant={settings.defaultView === 'list' ? 'default' : 'outline'}
className="justify-center gap-2"
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'list' }))} 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 className="h-4 w-4" />
List List
</button> </Button>
</div> </div>
</div> </div>
<Separator />
{/* Grid item size */} {/* Grid item size */}
<div> <div className="space-y-3">
<Label className="text-sm font-black text-foreground mb-2 block">Grid item size</Label> <div className="flex items-center justify-between">
<Label>Grid item size</Label>
<span className="text-sm font-medium text-primary">{settings.gridItemSize}</span>
</div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-xs font-bold text-muted-foreground">Small</span> <span className="text-xs text-muted-foreground">Small</span>
<input <Slider
type="range"
min="1"
max="10"
value={settings.gridItemSize} value={settings.gridItemSize}
onChange={(e) => setSettings(prev => ({ ...prev, gridItemSize: Number(e.target.value) }))} min={1}
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-[#6d28d9]" max={10}
onValueChange={(value) => setSettings(prev => ({ ...prev, gridItemSize: value }))}
className="flex-1"
/> />
<span className="text-xs font-bold text-muted-foreground">Large</span> <span className="text-xs text-muted-foreground">Large</span>
<span className="text-sm font-bold text-[#6d28d9] w-8 text-center">{settings.gridItemSize}</span>
</div> </div>
</div> </div>
</CardContent>
</Card>
{/* Theme */} <Card className="border-border/60">
<div> <CardHeader>
<Label className="text-sm font-black text-foreground mb-2 block">Theme</Label> <CardTitle className="flex items-center gap-2">
<div className="flex gap-2"> <Languages className="h-4 w-4 text-primary" />
{(['light', 'dark', 'system'] as const).map((theme) => ( Language
<button </CardTitle>
key={theme} <CardDescription>Interface language preference</CardDescription>
onClick={() => setSettings(prev => ({ ...prev, theme }))} </CardHeader>
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all ${ <CardContent>
settings.theme === theme <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white shadow-lg shadow-[#6d28d9]/20' {LANGUAGE_OPTIONS.map((option) => (
: 'bg-background text-foreground hover:bg-muted border border-border/50 hover:border-[#6d28d9]/30' <Button
}`} key={option.value}
variant={settings.language === option.value ? 'default' : 'outline'}
size="sm"
onClick={() => setSettings(prev => ({ ...prev, language: option.value }))}
className="justify-center"
> >
{theme === 'light' && <Sun size={18} />} {option.label}
{theme === 'dark' && <Moon size={18} />} </Button>
{theme === 'system' && <Monitor size={18} />}
{theme.charAt(0).toUpperCase() + theme.slice(1)}
</button>
))} ))}
</div> </div>
</CardContent>
</Card>
</div> </div>
</div> </TabsContent>
</section>
{/* 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>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30 border border-border/50">
<div> <div>
<Label htmlFor="showAdult" className="text-sm font-black text-foreground cursor-pointer"> <Label htmlFor="showAdult" className="cursor-pointer">Show adult content</Label>
Show adult content <p className="text-sm text-muted-foreground">Display adult media in your library</p>
</Label>
<p className="text-xs font-medium text-muted-foreground mt-1">
Display adult media in your library
</p>
</div> </div>
<Switch <Switch
id="showAdult" id="showAdult"
@@ -298,15 +422,10 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
/> />
</div> </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="text-sm font-black text-foreground cursor-pointer"> <Label htmlFor="autoPlay" className="cursor-pointer">Auto-play trailers</Label>
Auto-play trailers <p className="text-sm text-muted-foreground">Automatically play trailers when viewing media</p>
</Label>
<p className="text-xs font-medium text-muted-foreground mt-1">
Automatically play trailers when viewing media
</p>
</div> </div>
<Switch <Switch
id="autoPlay" id="autoPlay"
@@ -314,36 +433,155 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))} onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))}
/> />
</div> </div>
</div> </CardContent>
</section> </Card>
</TabsContent>
{/* 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
key={value}
variant={settings.theme === value ? 'default' : 'outline'}
className="flex-col gap-2 h-auto py-4"
onClick={() => setSettings(prev => ({ ...prev, theme: value }))}
> >
{option.label} <Icon className="h-5 w-5" />
</button> <span className="text-xs">{label}</span>
</Button>
))} ))}
</div> </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>
</section> )}
<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>
</div> </div>
<p className="text-xs text-muted-foreground mt-3">
The image will be converted to Base64 format.
</p>
</CardContent>
</Card>
<Card className="border-border/60 lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-primary" />
Custom Colors
</CardTitle>
<CardDescription>Customize the application colors</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-4">
{[
{ key: 'primary', label: 'Primary' },
{ key: 'secondary', label: 'Secondary' },
{ key: 'background', label: 'Background' },
{ key: 'surface', label: 'Surface' },
{ key: 'text', label: 'Text' },
{ key: 'muted', label: 'Muted' },
{ key: 'border', label: 'Border' },
].map(({ key, label }) => (
<div key={key} className="space-y-2">
<Label className="text-xs">{label}</Label>
<div className="flex gap-2">
<input
type="color"
value={customColors[key as keyof CustomColors] || '#e8466c'}
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
className="w-10 h-10 rounded-lg cursor-pointer border-0 p-0"
/>
<Input
value={customColors[key as keyof CustomColors] || ''}
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
placeholder="#e8466c"
className="flex-1 text-xs"
/>
</div>
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-4">
Leave color fields empty to use the default theme colors.
</p>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
</div> </div>
); );
} }
+324 -122
View File
@@ -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,101 +113,300 @@ 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)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<LayoutDashboard size={18} />
<span className="font-medium text-sm">Dashboard</span>
</NavLink>
<NavLink
to="/browse"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/browse') || isActive('/movies') || isActive('/tv-series') || isActive('/games') || isActive('/adult')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Library size={18} />
<span className="font-medium text-sm">Library</span>
</NavLink>
<NavLink
to="/cast"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/cast')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Users size={18} />
<span className="font-medium text-sm">Actors</span>
</NavLink>
<NavLink
to="/collections"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/collections')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<FolderKanban size={18} />
<span className="font-medium text-sm">Collections</span>
</NavLink>
<NavLink
to="/sources"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/sources')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Database size={18} />
<span className="font-medium text-sm">Sources</span>
</NavLink>
<NavLink
to="/settings"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/settings')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Settings size={18} />
<span className="font-medium text-sm">Settings</span>
</NavLink>
{/* MEDIA TYPE Section */}
<div className="mt-6">
<div className="px-3 mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Media Type</span>
</div>
<button <button
onClick={() => setIsMediaExpanded(!isMediaExpanded)} onClick={() => handleFilterClick('all')}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl hover:bg-muted/50 transition-colors group" 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"> <div className="flex items-center gap-3">
<div className="text-muted-foreground group-hover:text-foreground transition-colors"> <Library size={16} />
{item.icon} <span className="text-sm">All</span>
</div> </div>
<span className="font-bold text-foreground">{item.label}</span> <span className={cn(
</div> 'text-xs px-2 py-0.5 rounded-full',
{isMediaExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />} activeFilter === 'all' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.all}
</span>
</button> </button>
{isMediaExpanded && item.submenu && (
<div className="ml-4 mt-1 space-y-1"> {enabledCategories.includes('Movies') && (
{item.submenu.map((subItem) => ( <button
<NavLink onClick={() => handleFilterClick('movies')}
key={subItem.label} className={cn(
to={subItem.path} 'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
onClick={() => setIsMobileOpen(false)} activeFilter === 'movies' || location.pathname === '/movies'
className={({ isActive }) => ? 'bg-[#e8466c]/10 text-[#e8466c]'
cn( : 'text-gray-400 hover:text-white hover:bg-white/5'
'flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors',
isActive
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)
}
>
{categoryIcons[subItem.label]}
{subItem.label}
</NavLink>
))}
</div>
)} )}
</div>
) : (
<NavLink
to={item.path}
onClick={() => setIsMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-xl transition-colors group',
isActive
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)
}
> >
<div className={cn('transition-colors', location.pathname === item.path ? 'text-[#6d28d9]' : 'group-hover:text-foreground')}> <div className="flex items-center gap-3">
{item.icon} <Film size={16} />
<span className="text-sm">Movies</span>
</div> </div>
<span className="font-bold">{item.label}</span> <span className={cn(
</NavLink> '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> </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>
{/* 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 <button
onClick={handleLogout} 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" className="text-gray-400 hover:text-white transition-colors"
> >
<LogOut size={18} /> <ChevronRight size={16} />
<span className="font-medium">Logout</span>
</button> </button>
</div> </div>
</div>
</aside> </aside>
</> </>
); );
+105
View File
@@ -0,0 +1,105 @@
import { Staff } from '@/types';
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Users, ChevronDown, ChevronUp, User } from 'lucide-react';
import { motion } from 'motion/react';
interface CastTabProps {
staff: Staff[];
onPersonClick: (person: Staff) => void;
}
export default function CastTab({ staff, onPersonClick }: CastTabProps) {
const [showAll, setShowAll] = useState(false);
const displayLimit = 8;
const displayedCast = showAll ? staff : staff.slice(0, displayLimit);
const hasMore = staff.length > displayLimit;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Users className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Cast & Crew
</h2>
<Badge variant="secondary" className="text-xs">
{staff.length}
</Badge>
</div>
</div>
{/* Cast Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{displayedCast.map((person, index) => (
<motion.div
key={person.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<Card
className="group cursor-pointer hover:border-primary/30 hover:shadow-md transition-all duration-200 border-border/60"
onClick={() => onPersonClick(person)}
>
<CardContent className="p-3">
<div className="flex items-center gap-3">
<Avatar className="h-14 w-10 rounded-lg border border-border/30">
<AvatarImage
src={person.photo}
alt={person.name}
className="object-cover"
referrerPolicy="no-referrer"
/>
<AvatarFallback className="rounded-lg bg-muted">
<User className="h-4 w-4 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm text-foreground truncate group-hover:text-primary transition-colors">
{person.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{person.characterName || person.role}
</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
{/* Show More/Less Button */}
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowAll(!showAll)}
className="gap-2 rounded-lg"
>
{showAll ? (
<>
<ChevronUp className="w-4 h-4" />
Show Less
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
Show {staff.length - displayLimit} More
</>
)}
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,92 @@
import { Media } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { BookOpen, Tag } from 'lucide-react';
interface OverviewTabProps {
media: Media;
}
export default function OverviewTab({ media }: OverviewTabProps) {
return (
<div className="space-y-6">
{/* Genres */}
{media.genres && media.genres.length > 0 && (
<Card className="border-border/60 overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<Tag className="w-3 h-3 text-primary" />
</div>
Genres
</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div className="flex flex-wrap gap-2">
{media.genres.map(genre => (
<Badge
key={genre}
variant="secondary"
className="text-xs px-3 py-1 bg-primary/5 text-primary border-primary/20 hover:bg-primary/10 transition-colors"
>
{genre}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Tags */}
{media.tags && media.tags.length > 0 && (
<Card className="border-border/60 overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<Tag className="w-3 h-3 text-primary" />
</div>
Tags
</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div className="flex flex-wrap gap-2">
{media.tags.map(tag => (
<Badge
key={tag}
variant="outline"
className="text-xs px-3 py-1 border-border/50 hover:bg-muted/50 transition-colors"
>
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Description */}
<Card className="border-border/60 overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40">
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
<BookOpen className="w-3 h-3 text-primary" />
</div>
Synopsis
</CardTitle>
</CardHeader>
<CardContent className="p-4">
{media.description ? (
<div
className="text-foreground leading-relaxed prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: media.description }}
/>
) : (
<p className="text-muted-foreground text-sm italic">
No description available.
</p>
)}
</CardContent>
</Card>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { Episode } from '@/types';
import { useState, useMemo, useEffect } from 'react';
import { Search, Play, Clock, Calendar, ChevronDown, Tv } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
interface SeasonsTabProps {
episodes: Episode[];
}
export default function SeasonsTab({ episodes }: SeasonsTabProps) {
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
// Group episodes by season
const episodesBySeason = useMemo(() => {
if (!episodes) return {};
const grouped: Record<number, typeof episodes> = {};
episodes.forEach(episode => {
if (!grouped[episode.season]) {
grouped[episode.season] = [];
}
grouped[episode.season].push(episode);
});
// Sort episodes within each season by episode number
Object.keys(grouped).forEach(season => {
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
});
return grouped;
}, [episodes]);
// Expand first season by default on mount
useEffect(() => {
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
if (seasons.length > 0) {
setExpandedSeasons(new Set([seasons[0]]));
}
}, [episodesBySeason]);
const toggleSeason = (season: number) => {
setExpandedSeasons(prev => {
const newSet = new Set(prev);
if (newSet.has(season)) {
newSet.delete(season);
} else {
newSet.add(season);
}
return newSet;
});
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Tv className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Episodes
</h2>
<Badge variant="secondary" className="text-xs">
{episodes.length}
</Badge>
<span className="text-xs text-muted-foreground">
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
</span>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search episodes..."
className="pl-9 w-full sm:w-[200px] bg-muted/50 border-none rounded-lg h-9 text-sm"
/>
</div>
</div>
{/* Seasons */}
<div className="space-y-3">
{Object.keys(episodesBySeason)
.map(Number)
.sort((a, b) => a - b)
.map(season => (
<Collapsible
key={season}
open={expandedSeasons.has(season)}
onOpenChange={() => toggleSeason(season)}
>
<Card className="border-border/60 overflow-hidden">
<CollapsibleTrigger asChild>
<CardHeader className="py-3 px-4 cursor-pointer hover:bg-muted/30 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-foreground">Season {season}</h3>
<Badge variant="outline" className="text-xs border-primary/30 text-primary">
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
</Badge>
</div>
<ChevronDown
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${
expandedSeasons.has(season) ? 'rotate-180' : ''
}`}
/>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="p-0">
<div className="divide-y divide-border/50">
{episodesBySeason[season].map((episode, index) => (
<div
key={episode.id}
className="group p-4 hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="flex flex-col sm:flex-row gap-4">
{/* Thumbnail */}
<div className="w-full sm:w-[160px] shrink-0 aspect-video rounded-lg overflow-hidden relative bg-muted border border-border/30">
{episode.thumbnail ? (
<img
src={episode.thumbnail}
alt={episode.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Play className="w-8 h-8 text-muted-foreground" />
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<div className="w-10 h-10 rounded-full bg-primary/90 text-primary-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
<Play className="w-5 h-5 fill-current ml-0.5" />
</div>
</div>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-1">
Episode {episode.episode_number}
</p>
<h4 className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
{episode.title}
</h4>
{episode.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{episode.description}
</p>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0">
{episode.duration > 0 && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{episode.duration}m</span>
</div>
)}
{episode.air_date && (
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
<span>{episode.air_date}</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
))}
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
))}
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { Media } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Gamepad2, Layers } from 'lucide-react';
interface SeriesTabProps {
media: Media;
allMedia: Media[];
onMediaClick: (media: Media) => void;
}
export default function SeriesTab({ media, allMedia, onMediaClick }: SeriesTabProps) {
// Filter games that share at least one series with the current game
const seriesGames = allMedia.filter(
(m) =>
m.category === 'Games' &&
m.id !== media.id &&
m.series &&
media.series &&
m.series.some((s) => media.series!.includes(s))
);
if (seriesGames.length === 0) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Layers className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Series
</h2>
</div>
<Card className="border-border/60">
<CardContent className="p-6 text-center">
<Gamepad2 className="w-12 h-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-muted-foreground text-sm">
No other games found in the same series.
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Layers className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Series
</h2>
<Badge variant="secondary" className="text-xs">
{seriesGames.length}
</Badge>
</div>
<div className="flex flex-wrap gap-1.5">
{media.series?.map((s) => (
<Badge
key={s}
variant="outline"
className="text-xs border-primary/30 text-primary"
>
{s}
</Badge>
))}
</div>
</div>
{/* Games Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{seriesGames.map((game) => (
<Card
key={game.id}
className="group cursor-pointer hover:border-primary/30 hover:shadow-md transition-all duration-200 border-border/60 overflow-hidden"
onClick={() => onMediaClick(game)}
>
<div className={`aspect-[2/3] overflow-hidden bg-muted ${
game.aspectRatio === '16/9' ? 'aspect-video' :
game.aspectRatio === '1/1' ? 'aspect-square' : 'aspect-[2/3]'
}`}>
<img
src={game.poster}
alt={game.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
</div>
<CardContent className="p-3">
<p className="font-medium text-sm text-foreground truncate group-hover:text-primary transition-colors">
{game.title}
</p>
<p className="text-xs text-muted-foreground">
{game.year}
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { Track } from '@/types';
import { Search, Play, Disc, Clock } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
interface TracksTabProps {
tracks: Track[];
}
export default function TracksTab({ tracks }: TracksTabProps) {
const formatDuration = (seconds: number | null) => {
if (!seconds) return '—';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Disc className="w-4 h-4 text-primary" />
</div>
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Tracks
</h2>
<Badge variant="secondary" className="text-xs">
{tracks.length}
</Badge>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search tracks..."
className="pl-9 w-full sm:w-[200px] bg-muted/50 border-none rounded-lg h-9 text-sm"
/>
</div>
</div>
{/* Tracks List */}
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<div className="divide-y divide-border/50">
{tracks.map((track, index) => (
<div
key={track.id}
className="group flex items-center gap-4 p-3 hover:bg-muted/30 transition-colors cursor-pointer"
>
{/* Track Number / Play Button */}
<div className="w-8 text-center">
<span className="text-sm text-muted-foreground group-hover:hidden">
{track.track_number}
</span>
<div className="hidden group-hover:flex items-center justify-center">
<Play className="w-4 h-4 text-primary fill-current" />
</div>
</div>
{/* Track Info */}
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
{track.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{track.artist}
</p>
</div>
{/* Duration */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>{formatDuration(track.duration)}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
+375
View File
@@ -0,0 +1,375 @@
import React from 'react';
import { Media, MediaCategory } from '@/types';
import { cn } from '@/lib/utils';
import {
Star,
Building2,
Monitor,
Users,
FolderTree,
Database,
X
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuGroup
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
interface FilterOption {
label: string;
value: string;
count?: number;
}
interface MediaFiltersProps {
mediaList: Media[];
activeCategory: MediaCategory;
selectedGenre: string | null;
selectedStudio: string | null;
selectedPlatform: string | null;
selectedDeveloper: string | null;
selectedCategory: string | null;
selectedSource: string | null;
onGenreChange: (value: string | null) => void;
onStudioChange: (value: string | null) => void;
onPlatformChange: (value: string | null) => void;
onDeveloperChange: (value: string | null) => void;
onCategoryChange: (value: string | null) => void;
onSourceChange: (value: string | null) => void;
onClearAll: () => void;
}
export default function MediaFilters({
mediaList,
activeCategory,
selectedGenre,
selectedStudio,
selectedPlatform,
selectedDeveloper,
selectedCategory,
selectedSource,
onGenreChange,
onStudioChange,
onPlatformChange,
onDeveloperChange,
onCategoryChange,
onSourceChange,
onClearAll
}: MediaFiltersProps) {
// Extract unique filter values
const genres = React.useMemo(() =>
Array.from(new Set(mediaList.flatMap(m => m.genres || []))).sort(),
[mediaList]
);
const studios = React.useMemo(() =>
Array.from(new Set(mediaList.flatMap(m => m.studios || []))).sort(),
[mediaList]
);
const platforms = React.useMemo(() =>
Array.from(new Set(mediaList.flatMap(m => m.platforms || []))).sort(),
[mediaList]
);
const developers = React.useMemo(() =>
Array.from(new Set(mediaList.flatMap(m => m.developers || []))).sort(),
[mediaList]
);
const categories = React.useMemo(() =>
Array.from(new Set(mediaList.flatMap(m => m.series || []))).sort(),
[mediaList]
);
const sources = React.useMemo(() =>
Array.from(new Set(mediaList.map(m => m.source).filter(Boolean))).sort() as string[],
[mediaList]
);
const hasActiveFilters = selectedGenre || selectedStudio || selectedPlatform ||
selectedDeveloper || selectedCategory || selectedSource;
// Get available filters based on category
const getAvailableFilters = () => {
const baseFilters = ['genre'];
switch (activeCategory) {
case 'Movies':
case 'TV Series':
return [...baseFilters, 'studio'];
case 'Games':
return [...baseFilters, 'platform', 'developer', 'category'];
case 'Adult':
return [...baseFilters, 'studio'];
default:
return baseFilters;
}
};
const availableFilters = getAvailableFilters();
return (
<div className="flex flex-wrap items-center gap-2">
{/* Genre Filter - Always available */}
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedGenre
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Star size={14} className="mr-2" />
{selectedGenre || 'Genres'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Genre
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onGenreChange(null)}>
All Genres
</DropdownMenuItem>
{genres.map(genre => (
<DropdownMenuItem key={genre} onClick={() => onGenreChange(genre)}>
{genre}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Studio Filter - For Movies/Series/Adult */}
{availableFilters.includes('studio') && studios.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedStudio
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Building2 size={14} className="mr-2" />
{selectedStudio || 'Studios'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Studio
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onStudioChange(null)}>
All Studios
</DropdownMenuItem>
{studios.map(studio => (
<DropdownMenuItem key={studio} onClick={() => onStudioChange(studio)}>
{studio}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Platform Filter - For Games */}
{availableFilters.includes('platform') && platforms.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedPlatform
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Monitor size={14} className="mr-2" />
{selectedPlatform || 'Platforms'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Platform
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onPlatformChange(null)}>
All Platforms
</DropdownMenuItem>
{platforms.map(platform => (
<DropdownMenuItem key={platform} onClick={() => onPlatformChange(platform)}>
{platform}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Developer Filter - For Games */}
{availableFilters.includes('developer') && developers.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedDeveloper
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Users size={14} className="mr-2" />
{selectedDeveloper || 'Developers'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Developer
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDeveloperChange(null)}>
All Developers
</DropdownMenuItem>
{developers.map(developer => (
<DropdownMenuItem key={developer} onClick={() => onDeveloperChange(developer)}>
{developer}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Category/Series Filter - For Games */}
{availableFilters.includes('category') && categories.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedCategory
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<FolderTree size={14} className="mr-2" />
{selectedCategory || 'Series'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Series
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onCategoryChange(null)}>
All Series
</DropdownMenuItem>
{categories.map(category => (
<DropdownMenuItem key={category} onClick={() => onCategoryChange(category)}>
{category}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Source Filter */}
{sources.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
selectedSource
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Database size={14} className="mr-2" />
{selectedSource || 'Source'}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
Filter by Source
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSourceChange(null)}>
All Sources
</DropdownMenuItem>
{sources.map(source => (
<DropdownMenuItem key={source} onClick={() => onSourceChange(source)}>
{source}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Clear All Filters */}
{hasActiveFilters && (
<button
onClick={onClearAll}
className="h-9 px-3 inline-flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
>
<X size={14} className="mr-2" />
Clear
</button>
)}
{/* Active Filter Badges */}
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-1 ml-2">
{selectedGenre && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onGenreChange(null)}
>
{selectedGenre} <X size={12} className="ml-1" />
</Badge>
)}
{selectedStudio && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onStudioChange(null)}
>
{selectedStudio} <X size={12} className="ml-1" />
</Badge>
)}
{selectedPlatform && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onPlatformChange(null)}
>
{selectedPlatform} <X size={12} className="ml-1" />
</Badge>
)}
{selectedDeveloper && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onDeveloperChange(null)}
>
{selectedDeveloper} <X size={12} className="ml-1" />
</Badge>
)}
{selectedCategory && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onCategoryChange(null)}
>
{selectedCategory} <X size={12} className="ml-1" />
</Badge>
)}
{selectedSource && (
<Badge
variant="secondary"
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
onClick={() => onSourceChange(null)}
>
{selectedSource} <X size={12} className="ml-1" />
</Badge>
)}
</div>
)}
</div>
);
}
@@ -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}
/> />
); );
+327
View File
@@ -0,0 +1,327 @@
import { useLocation, useNavigate, NavLink } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useTheme } from '@/contexts/ThemeContext';
import { MediaCategory } from '@/types';
import {
LayoutDashboard,
Library,
Users,
FolderKanban,
Database,
Settings,
Sun,
Moon,
LogOut,
Film,
Tv,
Gamepad2,
Heart,
Eye,
Flame,
Clock,
User,
Music,
BookOpen,
Monitor,
Download,
} from 'lucide-react';
// shadcn/ui sidebar components
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from '@/components/ui/sidebar';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
interface AppSidebarProps {
enabledCategories: MediaCategory[];
onToggleCategory: (category: MediaCategory) => void;
pageTitle?: string;
mediaCounts?: Record<string, number>;
activeFilter?: string;
onFilterChange?: (filter: string) => void;
user?: {
name: string;
email: string;
avatar?: string;
};
}
export default function AppSidebar({
enabledCategories,
pageTitle = 'MediaVault',
mediaCounts = {},
activeFilter,
onFilterChange,
user,
}: AppSidebarProps) {
const { theme, setTheme } = useTheme();
const location = useLocation();
const navigate = useNavigate();
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
const handleLogout = () => {
console.log('Logout clicked');
};
// Category config with icons, colors and routes
const categoryConfig: Record<MediaCategory, { icon: any; label: string; route: string; color: string }> = {
'Anime': { icon: Tv, label: 'Anime', route: '/anime', color: 'text-purple-400' },
'Movies': { icon: Film, label: 'Movies', route: '/movies', color: 'text-blue-400' },
'TV Series': { icon: Tv, label: 'Series', route: '/tv-series', color: 'text-green-400' },
'Music': { icon: Music, label: 'Music', route: '/music', color: 'text-pink-400' },
'Books': { icon: BookOpen, label: 'Books', route: '/books', color: 'text-yellow-400' },
'Adult': { icon: Eye, label: 'Adult', route: '/adult', color: 'text-rose-400' },
'Consoles': { icon: Monitor, label: 'Consoles', route: '/consoles', color: 'text-orange-400' },
'Games': { icon: Gamepad2, label: 'Games', route: '/games', color: 'text-indigo-400' },
};
const handleFilterClick = (filter: string) => {
onFilterChange?.(filter);
if (filter === 'favorites') {
navigate('/browse?favorites=true');
return;
}
// Find route for category
const config = categoryConfig[filter as MediaCategory];
if (config) {
navigate(config.route);
}
};
const handleQuickFilter = (filter: string) => {
const routes: Record<string, string> = {
'most-played': '/browse?sort=plays',
'recently-added': '/browse?sort=recent',
};
navigate(routes[filter] || '/browse');
};
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
};
// Build category routes for Library isActive check
const categoryRoutes = enabledCategories.map(cat => categoryConfig[cat].route);
const mainNavItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', isActive: isActive('/') },
{ to: '/browse', icon: Library, label: 'Library', isActive: isActive('/browse') || categoryRoutes.some(route => isActive(route)) },
{ to: '/cast', icon: Users, label: 'Actors', isActive: isActive('/cast') },
//{ to: '/collections', icon: FolderKanban, label: 'Collections', isActive: isActive('/collections') },
{ to: '/import', icon: Download, label: 'Import', isActive: isActive('/import') },
//{ to: '/sources', icon: Database, label: 'Sources', isActive: isActive('/sources') },
{ to: '/settings', icon: Settings, label: 'Settings', isActive: isActive('/settings') },
];
// Build media type filters from enabled categories
const mediaTypeFilters = enabledCategories.map(cat => {
const config = categoryConfig[cat];
return {
id: cat.toLowerCase().replace(/\s+/g, '-'),
icon: config.icon,
label: config.label,
count: mediaCounts[cat] || 0,
color: config.color,
category: cat,
};
});
return (
<Sidebar>
<SidebarHeader className="p-4">
<NavLink to="/" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center shadow-lg shadow-[#e8466c]/20">
<Database className="w-4 h-4 text-white" />
</div>
<span className="text-lg font-bold text-sidebar-foreground tracking-tight">{pageTitle}</span>
</NavLink>
</SidebarHeader>
<SidebarContent className="px-2">
{/* Main Navigation */}
<SidebarGroup>
<SidebarGroupLabel>
Navigation
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{mainNavItems.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
asChild
isActive={item.isActive}
className={cn(
item.isActive
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
: ''
)}
>
<NavLink to={item.to} className="flex items-center gap-2 w-full">
<item.icon className={cn('w-4 h-4 shrink-0', item.isActive ? 'text-[#e8466c]' : '')} />
<span className="truncate">{item.label}</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Media Type Filters */}
<SidebarGroup>
<SidebarGroupLabel>
Media Type
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{mediaTypeFilters.map((filter) => {
const isFilterActive = activeFilter === filter.id;
return (
<SidebarMenuItem key={filter.id}>
<SidebarMenuButton
onClick={() => handleFilterClick(filter.category)}
isActive={isFilterActive}
className={cn(
isFilterActive
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
: ''
)}
>
<filter.icon
className={cn(
'w-4 h-4 shrink-0',
isFilterActive ? 'text-[#e8466c]' : filter.color || ''
)}
/>
<span className="truncate flex-1 text-left">{filter.label}</span>
<span
className={cn(
'ml-auto text-xs font-medium px-2 py-0.5 rounded-full shrink-0',
isFilterActive
? 'bg-[#e8466c]/20 text-[#e8466c]'
: 'bg-sidebar-accent text-sidebar-foreground/60'
)}
>
{filter.count}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Quick Filters */}
<SidebarGroup>
<SidebarGroupLabel>
Quick Filters
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => handleQuickFilter('most-played')}
>
<Flame className="w-4 h-4 text-orange-400 shrink-0" />
<span className="truncate">Most Played</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => handleQuickFilter('recently-added')}
>
<Clock className="w-4 h-4 text-cyan-400 shrink-0" />
<span className="truncate">Recently Added</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-2 space-y-1">
{/* Theme Toggle */}
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
className="w-full justify-start gap-2 text-sidebar-foreground/60 hover:text-sidebar-foreground hover:bg-sidebar-accent"
>
{theme === 'dark' ? (
<>
<Sun className="w-4 h-4 text-amber-400" />
<span>Light Mode</span>
</>
) : (
<>
<Moon className="w-4 h-4 text-sidebar-foreground/60" />
<span>Dark Mode</span>
</>
)}
</Button>
{/* User Profile */}
{user ? (
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-sidebar-accent">
<Avatar className="w-8 h-8">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c] text-xs">
{user.name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-sidebar-foreground truncate">{user.name}</p>
<p className="text-xs text-sidebar-foreground/50 truncate">{user.email}</p>
</div>
</div>
) : (
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-sidebar-accent">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c]">
<User className="w-4 h-4" />
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-sidebar-foreground">Guest</p>
<p className="text-xs text-sidebar-foreground/50">Not logged in</p>
</div>
</div>
)}
{/* Logout */}
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="w-full justify-start gap-2 text-sidebar-foreground/60 hover:text-red-400 hover:bg-red-500/10"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>
</Button>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 select-none after:absolute after:inset-0 after: after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+19
View File
@@ -0,0 +1,19 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+1 -1
View File
@@ -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>
); );
+130
View File
@@ -0,0 +1,130 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
}
/>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number
max?: number
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, max = 100, ...props }, ref) => {
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
return (
<div
ref={ref}
role="progressbar"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<div
className="h-full w-full flex-1 bg-primary transition-all duration-500"
style={{ transform: `translateX(-${100 - percentage}%)` }}
/>
</div>
)
}
)
Progress.displayName = "Progress"
export { Progress }
+199
View File
@@ -0,0 +1,199 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+136
View File
@@ -0,0 +1,136 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+723
View File
@@ -0,0 +1,723 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement> {
value?: number
min?: number
max?: number
step?: number
onValueChange?: (value: number) => void
}
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
({ className, value, min = 0, max = 100, step = 1, onValueChange, onChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = Number(e.target.value)
onValueChange?.(newValue)
onChange?.(e)
}
return (
<input
type="range"
ref={ref}
value={value}
min={min}
max={max}
step={step}
onChange={handleChange}
className={cn(
"w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary",
className
)}
{...props}
/>
)
}
)
Slider.displayName = "Slider"
export { Slider }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+80
View File
@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+87
View File
@@ -0,0 +1,87 @@
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+3 -3
View File
@@ -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 }}>
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+54 -43
View File
@@ -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;
+1 -1
View File
@@ -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 {
+26 -7
View File
@@ -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,
}; };
} }
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+12
View File
@@ -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> {}
+18
View File
@@ -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;
}
+10 -10
View File
@@ -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)));
+76 -16
View File
@@ -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)`);
+8 -8
View File
@@ -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>(
+5 -5
View File
@@ -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(
+3
View File
@@ -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>
<TooltipProvider>
<App /> <App />
</TooltipProvider>
</StrictMode>, </StrictMode>,
); );
+18
View File
@@ -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'],
+1 -1
View File
@@ -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,