Compare commits
3 Commits
901b342871
...
app
| Author | SHA1 | Date | |
|---|---|---|---|
| 15fe7670c8 | |||
| d61472f069 | |||
| 83617f75e4 |
@@ -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)
|
||||||
@@ -102,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
|
||||||
@@ -126,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
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@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",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"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",
|
||||||
@@ -2767,6 +2769,248 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/api": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"bin": {
|
||||||
|
"tauri": "tauri.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tauri-apps/cli-darwin-arm64": "2.11.2",
|
||||||
|
"@tauri-apps/cli-darwin-x64": "2.11.2",
|
||||||
|
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
|
||||||
|
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
|
||||||
|
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
|
||||||
|
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
|
||||||
|
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
|
||||||
|
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
|
||||||
|
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
|
||||||
|
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
|
||||||
|
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ts-morph/common": {
|
"node_modules/@ts-morph/common": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { execSync } from "child_process";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
const backendDir = resolve(import.meta.dirname, "../../backend");
|
||||||
|
const frontendDir = resolve(import.meta.dirname, "..");
|
||||||
|
|
||||||
|
console.log("\n=== 1/2: Backend kompilieren ===\n");
|
||||||
|
execSync("bun run compile:tauri", { cwd: backendDir, stdio: "inherit" });
|
||||||
|
|
||||||
|
console.log("\n=== 2/2: Tauri Desktop-App bauen ===\n");
|
||||||
|
execSync("npx tauri build", { cwd: frontendDir, stdio: "inherit" });
|
||||||
|
|
||||||
|
console.log("\n✅ Fertig! Installer liegt in src-tauri/target/release/bundle/\n");
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
license = ""
|
||||||
|
repository = ""
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "app_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.6.2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
log = "0.4"
|
||||||
|
tauri = { version = "2.11.2", features = [] }
|
||||||
|
tauri-plugin-log = "2"
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"shell:allow-execute",
|
||||||
|
"shell:allow-spawn",
|
||||||
|
"shell:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,111 @@
|
|||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
fn find_backend() -> Result<std::path::PathBuf, String> {
|
||||||
|
let exe = std::env::current_exe().map_err(|e| format!("current_exe: {}", e))?;
|
||||||
|
let exe_dir = exe.parent().ok_or("no parent dir")?;
|
||||||
|
|
||||||
|
let target_name = "omnyx-backend";
|
||||||
|
let triple_name = format!("{}-x86_64-pc-windows-msvc.exe", target_name);
|
||||||
|
let short_name = format!("{}.exe", target_name);
|
||||||
|
|
||||||
|
// Try paths relative to the executable
|
||||||
|
let candidates = [
|
||||||
|
// Dev: app.exe is in src-tauri/target/debug/, binary in src-tauri/binaries/
|
||||||
|
exe_dir.parent().and_then(|p| p.parent()).and_then(|p| p.parent()).map(|p| p.join("binaries").join(&triple_name)),
|
||||||
|
// Dev: via src-tauri/target/
|
||||||
|
exe_dir.parent().and_then(|p| p.parent()).map(|p| p.join("binaries").join(&triple_name)),
|
||||||
|
// Production: binary renamed by Tauri (no triple)
|
||||||
|
Some(exe_dir.join(&short_name)),
|
||||||
|
// Production: binary with triple in same dir
|
||||||
|
Some(exe_dir.join(&triple_name)),
|
||||||
|
// Production: binary in ./binaries/ relative to app.exe
|
||||||
|
Some(exe_dir.join("binaries").join(&triple_name)),
|
||||||
|
Some(exe_dir.join("binaries").join(&short_name)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in candidates.iter().flatten() {
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Backend binary not found (searched from {:?})", exe))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(
|
||||||
|
tauri_plugin_log::Builder::default()
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setup(|app| {
|
||||||
|
let exe_dir = app.path().resource_dir().unwrap_or_else(|_| std::env::current_exe().map(|p| p.parent().unwrap().to_path_buf()).unwrap_or_default());
|
||||||
|
let exe_path_str = exe_dir.to_string_lossy().to_lowercase();
|
||||||
|
|
||||||
|
// Portable mode: nur wenn auf Laufwerk C: UND in Program Files → APPDATA
|
||||||
|
// Alles andere (D:, E:, USB, etc.) → portable (data neben der Exe)
|
||||||
|
let on_c_drive = exe_path_str.starts_with("c:");
|
||||||
|
let in_program_files = exe_path_str.contains("program files") || exe_path_str.contains("programme");
|
||||||
|
let data_dir = if on_c_drive && in_program_files {
|
||||||
|
std::env::var("APPDATA")
|
||||||
|
.map(|a| format!("{}\\Omnyx", a))
|
||||||
|
.unwrap_or_else(|_| format!("{}\\data", exe_dir.to_string_lossy()))
|
||||||
|
} else {
|
||||||
|
format!("{}\\data", exe_dir.to_string_lossy())
|
||||||
|
};
|
||||||
|
|
||||||
|
match find_backend() {
|
||||||
|
Ok(bin_path) => {
|
||||||
|
log::info!("Starting backend: {:?}", bin_path);
|
||||||
|
log::info!("Data dir: {}", data_dir);
|
||||||
|
|
||||||
|
match Command::new(&bin_path)
|
||||||
|
.env("DATA_DIR", &data_dir)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(child) => {
|
||||||
|
log::info!("Backend started (PID: {})", child.id());
|
||||||
|
|
||||||
|
let mut stdout = child.stdout.unwrap();
|
||||||
|
let mut stderr = child.stderr.unwrap();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
while let Ok(n) = stdout.read(&mut buf) {
|
||||||
|
if n == 0 { break; }
|
||||||
|
log::info!("[backend] {}", String::from_utf8_lossy(&buf[..n]).trim_end());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
while let Ok(n) = stderr.read(&mut buf) {
|
||||||
|
if n == 0 { break; }
|
||||||
|
log::warn!("[backend:err] {}", String::from_utf8_lossy(&buf[..n]).trim_end());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to spawn {:?}: {}", bin_path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("{}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(1));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
app_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
|
"productName": "Omnyx",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.omnyx.desktop",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist",
|
||||||
|
"devUrl": "http://localhost:3000",
|
||||||
|
"beforeDevCommand": "npm run dev -- --mode tauri",
|
||||||
|
"beforeBuildCommand": "npm run build -- --mode tauri"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Omnyx - Media Discovery",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800,
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"center": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/omnyx-backend"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ 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 { Search, Plus, LayoutGrid, List, Filter } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -420,7 +421,7 @@ function AppContent() {
|
|||||||
}, [location.pathname, searchParams]);
|
}, [location.pathname, searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0c10] font-sans selection:bg-[#e8466c]/20 selection:text-[#e8466c] flex">
|
<div className="min-h-screen bg-background font-sans selection:bg-[#e8466c]/20 selection:text-[#e8466c] flex">
|
||||||
<SidebarProvider defaultOpen={true}>
|
<SidebarProvider defaultOpen={true}>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
enabledCategories={enabledCategories}
|
enabledCategories={enabledCategories}
|
||||||
@@ -432,7 +433,7 @@ function AppContent() {
|
|||||||
|
|
||||||
<main className="flex-1 flex flex-col relative">
|
<main className="flex-1 flex flex-col relative">
|
||||||
{/* Header with Search and Add Media */}
|
{/* Header with Search and Add Media */}
|
||||||
<header className="sticky top-0 z-30 bg-[#0a0c10]/80 backdrop-blur-xl border-b border-white/5 px-6 py-4">
|
<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">
|
<div className="flex items-center justify-between gap-4 max-w-[1920px] mx-auto">
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="flex-1 max-w-xl">
|
<div className="flex-1 max-w-xl">
|
||||||
@@ -443,25 +444,25 @@ function AppContent() {
|
|||||||
placeholder="Search library..."
|
placeholder="Search library..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 bg-[#1a1d26] border-white/10 rounded-lg text-white placeholder:text-gray-500 focus:border-[#e8466c]/50 focus:ring-[#e8466c]/20"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View Toggle and Add Button */}
|
{/* View Toggle and Add Button */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center bg-[#1a1d26] rounded-lg p-1 border border-white/10">
|
<div className="flex items-center bg-muted/30 rounded-lg p-1 border border-border">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded bg-white/10 text-white"
|
className="h-8 w-8 rounded bg-accent text-accent-foreground"
|
||||||
>
|
>
|
||||||
<LayoutGrid className="w-4 h-4" />
|
<LayoutGrid className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded text-gray-400 hover:text-white hover:bg-white/5"
|
className="h-8 w-8 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
>
|
>
|
||||||
<List className="w-4 h-4" />
|
<List className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -478,85 +479,87 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<LayoutGroup>
|
<div className="flex-1">
|
||||||
<Routes>
|
<LayoutGroup>
|
||||||
<Route path="/" element={
|
<Routes>
|
||||||
<DashboardView
|
<Route path="/" element={
|
||||||
mediaList={apiMedia.length > 0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))}
|
<DashboardView
|
||||||
onMediaClick={handleMediaClick}
|
mediaList={apiMedia.length > 0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))}
|
||||||
loading={mediaLoading}
|
onMediaClick={handleMediaClick}
|
||||||
/>
|
loading={mediaLoading}
|
||||||
} />
|
/>
|
||||||
<Route path="/browse" element={
|
} />
|
||||||
<BrowseView
|
<Route path="/browse" element={
|
||||||
mediaList={searchQuery.trim() ? searchResultsMedia : allMedia}
|
<BrowseView
|
||||||
onMediaClick={handleMediaClick}
|
mediaList={searchQuery.trim() ? searchResultsMedia : allMedia}
|
||||||
activeCategory={activeCategory}
|
onMediaClick={handleMediaClick}
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
activeCategory={activeCategory}
|
||||||
gridItemSize={settings?.gridItemSize}
|
itemsPerPage={settings?.itemsPerPage}
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
gridItemSize={settings?.gridItemSize}
|
||||||
loading={mediaLoading}
|
onGridItemSizeChange={handleGridItemSizeChange}
|
||||||
searchResultsCast={searchQuery.trim() ? searchResultsCast : []}
|
loading={mediaLoading}
|
||||||
onCastClick={handlePersonClick}
|
searchResultsCast={searchQuery.trim() ? searchResultsCast : []}
|
||||||
searchQuery={searchQuery}
|
onCastClick={handlePersonClick}
|
||||||
/>
|
searchQuery={searchQuery}
|
||||||
} />
|
/>
|
||||||
<Route path="/:category" element={
|
} />
|
||||||
<CategoryBrowseRoute
|
<Route path="/:category" element={
|
||||||
mediaList={filteredMedia}
|
<CategoryBrowseRoute
|
||||||
onMediaClick={handleMediaClick}
|
mediaList={filteredMedia}
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
onMediaClick={handleMediaClick}
|
||||||
gridItemSize={settings?.gridItemSize}
|
itemsPerPage={settings?.itemsPerPage}
|
||||||
onGridItemSizeChange={handleGridItemSizeChange}
|
gridItemSize={settings?.gridItemSize}
|
||||||
loading={mediaLoading}
|
onGridItemSizeChange={handleGridItemSizeChange}
|
||||||
/>
|
loading={mediaLoading}
|
||||||
} />
|
/>
|
||||||
<Route path="/media/:id" element={
|
} />
|
||||||
<MediaDetailRoute
|
<Route path="/media/:id" element={
|
||||||
allMedia={allMedia}
|
<MediaDetailRoute
|
||||||
onPersonClick={handlePersonClick}
|
allMedia={allMedia}
|
||||||
/>
|
onPersonClick={handlePersonClick}
|
||||||
} />
|
/>
|
||||||
<Route path="/cast" element={
|
} />
|
||||||
<CastView
|
<Route path="/cast" element={
|
||||||
onPersonClick={handlePersonClick}
|
<CastView
|
||||||
enabledCategories={enabledCategories}
|
onPersonClick={handlePersonClick}
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
enabledCategories={enabledCategories}
|
||||||
/>
|
itemsPerPage={settings?.itemsPerPage}
|
||||||
} />
|
/>
|
||||||
<Route path="/cast/:id" element={
|
} />
|
||||||
<CastDetailRoute />
|
<Route path="/cast/:id" element={
|
||||||
} />
|
<CastDetailRoute />
|
||||||
<Route path="/add" element={
|
} />
|
||||||
<AddMediaView
|
<Route path="/add" element={
|
||||||
activeCategory={activeCategory}
|
<AddMediaView
|
||||||
enabledCategories={enabledCategories}
|
activeCategory={activeCategory}
|
||||||
onAddComplete={handleAddMedia}
|
enabledCategories={enabledCategories}
|
||||||
/>
|
onAddComplete={handleAddMedia}
|
||||||
} />
|
/>
|
||||||
<Route path="/import" element={
|
} />
|
||||||
<ImporterView />
|
<Route path="/import" element={
|
||||||
} />
|
<ImporterView />
|
||||||
<Route path="/settings" element={
|
} />
|
||||||
<SettingsView onSettingsSaved={reloadSettings} />
|
<Route path="/settings" element={
|
||||||
} />
|
<SettingsView onSettingsSaved={reloadSettings} />
|
||||||
</Routes>
|
} />
|
||||||
</LayoutGroup>
|
</Routes>
|
||||||
|
</LayoutGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="fixed bottom-0 left-64 right-0 py-4 px-6 border-t border-white/5 bg-[#0a0c10] z-40">
|
<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-sm font-medium text-gray-500">
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
<span>{mediaCounts.all} total</span>
|
<span>{mediaCounts.all} total</span>
|
||||||
<span className="text-gray-700">•</span>
|
<span className="text-border-foreground">•</span>
|
||||||
<span className="text-blue-400">{mediaCounts.movies} Movies</span>
|
<span className="text-blue-400">{mediaCounts.movies} Movies</span>
|
||||||
<span className="text-green-400">{mediaCounts.series} Series</span>
|
<span className="text-green-400">{mediaCounts.series} Series</span>
|
||||||
<span className="text-purple-400">{mediaCounts.games} Games</span>
|
<span className="text-purple-400">{mediaCounts.games} Games</span>
|
||||||
<span className="text-red-400">{mediaCounts.adult} Adult</span>
|
<span className="text-red-400">{mediaCounts.adult} Adult</span>
|
||||||
<span className="text-gray-700">•</span>
|
<span className="text-border-foreground">•</span>
|
||||||
<span className="text-[#e8466c]">{mediaCounts.favorites} Favorites</span>
|
<span className="text-[#e8466c]">{mediaCounts.favorites} Favorites</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600">
|
<p className="text-xs text-muted-foreground">
|
||||||
© 2026 MediaVault v1.0.0
|
© 2026 MediaVault v1.0.0
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -568,6 +571,49 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [backendReady, setBackendReady] = useState(false);
|
||||||
|
const [backendFailed, setBackendFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
waitForBackend().then(ready => {
|
||||||
|
if (ready) setBackendReady(true);
|
||||||
|
else setBackendFailed(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (backendFailed) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-full bg-red-500/10 flex items-center justify-center">
|
||||||
|
<span className="text-2xl">!</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">Backend nicht erreichbar</h2>
|
||||||
|
<p className="text-muted-foreground max-w-md">
|
||||||
|
Der Backend-Dienst auf Port 3001 antwortet nicht. Bitte starten Sie die App neu.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { setBackendFailed(false); waitForBackend().then(r => r ? setBackendReady(true) : setBackendFailed(true)); }}
|
||||||
|
className="px-4 py-2 bg-[#e8466c] text-white rounded-lg hover:bg-[#d13d60]"
|
||||||
|
>
|
||||||
|
Neu verbinden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backendReady) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="w-12 h-12 border-4 border-[#e8466c]/30 border-t-[#e8466c] rounded-full animate-spin mx-auto" />
|
||||||
|
<p className="text-muted-foreground">Backend wird gestartet...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
|
|
||||||
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
|
<div className="bg-card/50 backdrop-blur-sm rounded-3xl shadow-xl p-8 border border-border/50 max-w-[1600px] mx-auto">
|
||||||
<div className="flex items-center gap-4 mb-8">
|
<div className="flex items-center gap-4 mb-8">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] flex items-center justify-center shadow-lg shadow-[#6d28d9]/30">
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center shadow-lg shadow-[#e8466c]/30">
|
||||||
{getCategoryIcon(activeCategory)}
|
{getCategoryIcon(activeCategory)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -234,7 +234,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
{/* Basic Info Card */}
|
{/* Basic Info Card */}
|
||||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||||
<FileText size={16} />
|
<FileText size={16} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
|
<h3 className="text-lg font-black text-foreground">Basic Information</h3>
|
||||||
@@ -247,7 +247,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.title}
|
value={newMedia.title}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||||
placeholder="e.g. Mob Psycho 100"
|
placeholder="e.g. Mob Psycho 100"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,7 +259,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.year}
|
value={newMedia.year}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||||
placeholder="2024"
|
placeholder="2024"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -268,7 +268,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
id="category"
|
id="category"
|
||||||
value={newMedia.category}
|
value={newMedia.category}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/50 outline-none"
|
||||||
>
|
>
|
||||||
{enabledCategories.map(cat => (
|
{enabledCategories.map(cat => (
|
||||||
<option key={cat} value={cat}>{cat}</option>
|
<option key={cat} value={cat}>{cat}</option>
|
||||||
@@ -283,7 +283,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
id="type"
|
id="type"
|
||||||
value={newMedia.type}
|
value={newMedia.type}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/50 outline-none"
|
||||||
>
|
>
|
||||||
{newMedia.category === 'Music' ? (
|
{newMedia.category === 'Music' ? (
|
||||||
<>
|
<>
|
||||||
@@ -322,7 +322,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
id="status"
|
id="status"
|
||||||
value={newMedia.status}
|
value={newMedia.status}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/50 outline-none"
|
||||||
>
|
>
|
||||||
<option value="Released">Released</option>
|
<option value="Released">Released</option>
|
||||||
<option value="Ongoing">Ongoing</option>
|
<option value="Ongoing">Ongoing</option>
|
||||||
@@ -343,7 +343,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
{/* Media Info Card */}
|
{/* Media Info Card */}
|
||||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||||
<Globe size={16} />
|
<Globe size={16} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black text-foreground">Media Information</h3>
|
<h3 className="text-lg font-black text-foreground">Media Information</h3>
|
||||||
@@ -356,7 +356,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.poster}
|
value={newMedia.poster}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||||
placeholder="https://example.com/poster.jpg"
|
placeholder="https://example.com/poster.jpg"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -367,7 +367,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.banner}
|
value={newMedia.banner}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
||||||
placeholder="https://example.com/banner.jpg"
|
placeholder="https://example.com/banner.jpg"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -377,7 +377,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
id="aspectRatio"
|
id="aspectRatio"
|
||||||
value={newMedia.aspectRatio}
|
value={newMedia.aspectRatio}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
className="bg-background border-border/50 rounded-xl h-11 px-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#e8466c]/50 outline-none"
|
||||||
>
|
>
|
||||||
<option value="2/3">2:3 (Poster)</option>
|
<option value="2/3">2:3 (Poster)</option>
|
||||||
<option value="16/9">16:9 (Banner)</option>
|
<option value="16/9">16:9 (Banner)</option>
|
||||||
@@ -395,7 +395,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.rating}
|
value={newMedia.rating}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
|
||||||
placeholder="8.5"
|
placeholder="8.5"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,7 +407,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
||||||
placeholder="Enter a description..."
|
placeholder="Enter a description..."
|
||||||
rows={4}
|
rows={4}
|
||||||
className="bg-background border-border/50 rounded-xl p-3 text-sm focus:ring-2 focus:ring-[#6d28d9]/50 outline-none resize-none"
|
className="bg-background border-border/50 rounded-xl p-3 text-sm focus:ring-2 focus:ring-[#e8466c]/50 outline-none resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -416,7 +416,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||||
<Clock size={16} />
|
<Clock size={16} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black text-foreground">Production Details</h3>
|
<h3 className="text-lg font-black text-foreground">Production Details</h3>
|
||||||
@@ -430,7 +430,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.runtime}
|
value={newMedia.runtime}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
|
||||||
placeholder="120"
|
placeholder="120"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -440,7 +440,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
type="date"
|
type="date"
|
||||||
value={newMedia.releaseDate}
|
value={newMedia.releaseDate}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -450,7 +450,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.director}
|
value={newMedia.director}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
||||||
placeholder="Director name"
|
placeholder="Director name"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -460,7 +460,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.writer}
|
value={newMedia.writer}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
||||||
placeholder="Writer name"
|
placeholder="Writer name"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,7 +469,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
{/* Classification Card */}
|
{/* Classification Card */}
|
||||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||||
<Tag size={16} />
|
<Tag size={16} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black text-foreground">Classification</h3>
|
<h3 className="text-lg font-black text-foreground">Classification</h3>
|
||||||
@@ -482,7 +482,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.genres}
|
value={newMedia.genres}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
||||||
placeholder="Action, Drama, Sci-Fi"
|
placeholder="Action, Drama, Sci-Fi"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -492,7 +492,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.tags}
|
value={newMedia.tags}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
||||||
placeholder="Classic, Best-selling"
|
placeholder="Classic, Best-selling"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -502,7 +502,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.studios}
|
value={newMedia.studios}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
||||||
placeholder="Studio A, Studio B"
|
placeholder="Studio A, Studio B"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -512,7 +512,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
value={newMedia.source}
|
value={newMedia.source}
|
||||||
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
|
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
|
||||||
placeholder="e.g. username, xbvr, stashapp"
|
placeholder="e.g. username, xbvr, stashapp"
|
||||||
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-background border-border/50 rounded-xl h-11 focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,7 +522,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||||
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50 lg:col-span-2">
|
<div className="bg-muted/30 backdrop-blur-sm rounded-2xl p-6 border border-border/50 lg:col-span-2">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#6d28d9] shadow-sm">
|
<div className="w-8 h-8 rounded-lg bg-background flex items-center justify-center text-[#e8466c] shadow-sm">
|
||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black text-foreground">Cast & Crew</h3>
|
<h3 className="text-lg font-black text-foreground">Cast & Crew</h3>
|
||||||
@@ -574,7 +574,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<Input
|
<Input
|
||||||
id="staffName"
|
id="staffName"
|
||||||
placeholder="Actor name"
|
placeholder="Actor name"
|
||||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -593,7 +593,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<Input
|
<Input
|
||||||
id="staffRole"
|
id="staffRole"
|
||||||
placeholder="e.g. Actor, Director"
|
placeholder="e.g. Actor, Director"
|
||||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -611,7 +611,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<Input
|
<Input
|
||||||
id="staffCharacter"
|
id="staffCharacter"
|
||||||
placeholder="Character name"
|
placeholder="Character name"
|
||||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,14 +620,14 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<Input
|
<Input
|
||||||
id="staffPhoto"
|
id="staffPhoto"
|
||||||
placeholder="https://example.com/photo.jpg"
|
placeholder="https://example.com/photo.jpg"
|
||||||
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#6d28d9]/50"
|
className="bg-muted/50 border-border/50 rounded-lg h-9 text-sm focus:ring-2 focus:ring-[#e8466c]/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addStaffMember}
|
onClick={addStaffMember}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-border/50 text-sm font-bold hover:border-[#6d28d9]/50 hover:bg-[#6d28d9]/10 rounded-xl transition-all duration-300"
|
className="w-full border-border/50 text-sm font-bold hover:border-[#e8466c]/50 hover:bg-[#e8466c]/10 rounded-xl transition-all duration-300"
|
||||||
>
|
>
|
||||||
+ Add Cast Member
|
+ Add Cast Member
|
||||||
</Button>
|
</Button>
|
||||||
@@ -640,7 +640,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] hover:from-[#5b21b6] hover:to-[#7c3aed] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/30 transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
className="w-full bg-gradient-to-br from-[#e8466c] to-[#f47298] hover:from-[#d13d60] hover:to-[#c5304e] text-white font-black h-12 rounded-xl shadow-lg shadow-[#e8466c]/30 transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -96,12 +96,12 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-wrap justify-center md:justify-start gap-2">
|
<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-medium px-3 py-1 text-xs">
|
<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-medium px-3 py-1 text-xs">
|
<Badge variant="outline" className="border-[#e8466c]/30 text-[#e8466c] font-medium px-3 py-1 text-xs">
|
||||||
<Star className="w-3 h-3 mr-1" />
|
<Star className="w-3 h-3 mr-1" />
|
||||||
{person.filmography.length}
|
{person.filmography.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -130,8 +130,8 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
<Card className="border-border/60 overflow-hidden">
|
<Card className="border-border/60 overflow-hidden">
|
||||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
<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">
|
<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-[#6d28d9]/10 flex items-center justify-center">
|
<div className="w-5 h-5 rounded bg-[#e8466c]/10 flex items-center justify-center">
|
||||||
<User size={12} className="text-[#6d28d9]" />
|
<User size={12} className="text-[#e8466c]" />
|
||||||
</div>
|
</div>
|
||||||
Personal Info
|
Personal Info
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -140,7 +140,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
{/* Birth Date */}
|
{/* Birth Date */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
<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="flex items-center gap-2.5">
|
||||||
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
|
||||||
<Calendar size={14} />
|
<Calendar size={14} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">Born</span>
|
<span className="text-xs text-muted-foreground">Born</span>
|
||||||
@@ -152,7 +152,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
{/* Birth Place */}
|
{/* Birth Place */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
<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="flex items-center gap-2.5">
|
||||||
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
|
||||||
<MapPin size={14} />
|
<MapPin size={14} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">Origin</span>
|
<span className="text-xs text-muted-foreground">Origin</span>
|
||||||
@@ -166,12 +166,12 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
{/* Known For */}
|
{/* Known For */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
<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="flex items-center gap-2.5">
|
||||||
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
|
||||||
<Briefcase size={14} />
|
<Briefcase size={14} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">Role</span>
|
<span className="text-xs text-muted-foreground">Role</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="text-xs font-normal bg-[#6d28d9]/10 text-[#6d28d9] border-none">
|
<Badge variant="secondary" className="text-xs font-normal bg-[#e8466c]/10 text-[#e8466c] border-none">
|
||||||
{person.role}
|
{person.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +182,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
<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="flex items-center gap-2.5">
|
||||||
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
<div className="w-7 h-7 rounded-md bg-[#e8466c]/10 flex items-center justify-center text-[#e8466c]">
|
||||||
<User size={14} />
|
<User size={14} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">Ethnicity</span>
|
<span className="text-xs text-muted-foreground">Ethnicity</span>
|
||||||
@@ -202,8 +202,8 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
<Card className="border-border/60 overflow-hidden">
|
<Card className="border-border/60 overflow-hidden">
|
||||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
<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">
|
<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-[#6d28d9]/10 flex items-center justify-center">
|
<div className="w-5 h-5 rounded bg-[#e8466c]/10 flex items-center justify-center">
|
||||||
<Ruler size={12} className="text-[#6d28d9]" />
|
<Ruler size={12} className="text-[#e8466c]" />
|
||||||
</div>
|
</div>
|
||||||
Measurements
|
Measurements
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -262,7 +262,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
{(person.hair_color || person.adult_specifics?.hair_color) && (
|
{(person.hair_color || person.adult_specifics?.hair_color) && (
|
||||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Palette size={12} className="text-[#6d28d9]" />
|
<Palette size={12} className="text-[#e8466c]" />
|
||||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Hair</span>
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Hair</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium truncate">
|
<p className="text-sm font-medium truncate">
|
||||||
@@ -273,7 +273,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
{(person.eye_color || person.adult_specifics?.eye_color) && (
|
{(person.eye_color || person.adult_specifics?.eye_color) && (
|
||||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Eye size={12} className="text-[#6d28d9]" />
|
<Eye size={12} className="text-[#e8466c]" />
|
||||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Eyes</span>
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Eyes</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium truncate">
|
<p className="text-sm font-medium truncate">
|
||||||
@@ -360,7 +360,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
transition={{ delay: index * 0.03 }}
|
transition={{ delay: index * 0.03 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className="hover:border-[#6d28d9]/30 hover:shadow-md transition-all duration-200 cursor-pointer group border-border/60"
|
className="hover:border-[#e8466c]/30 hover:shadow-md transition-all duration-200 cursor-pointer group border-border/60"
|
||||||
onClick={() => handleMediaClick(item.id.toString())}
|
onClick={() => handleMediaClick(item.id.toString())}
|
||||||
>
|
>
|
||||||
<CardContent className="p-3 flex items-center gap-3">
|
<CardContent className="p-3 flex items-center gap-3">
|
||||||
@@ -374,10 +374,10 @@ 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] text-muted-foreground uppercase tracking-wide">Character</p>
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Character</p>
|
||||||
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#6d28d9] transition-colors">
|
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#e8466c] transition-colors">
|
||||||
{item.characterName || item.role}
|
{item.characterName || item.role}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-[#6d28d9] truncate">{item.title}</p>
|
<p className="text-xs text-[#e8466c] truncate">{item.title}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -429,7 +429,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
{sortBy === option.value && (
|
{sortBy === option.value && (
|
||||||
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#6d28d9]" /> : <ArrowDownAZ size={14} className="text-[#6d28d9]" />
|
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#e8466c]" /> : <ArrowDownAZ size={14} className="text-[#e8466c]" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -450,7 +450,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
onClick={() => handleMediaClick(item.id.toString())}
|
onClick={() => handleMediaClick(item.id.toString())}
|
||||||
className="group cursor-pointer hover:border-[#6d28d9]/30 hover:shadow-md transition-all duration-200 border-border/60"
|
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">
|
<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">
|
<div className="w-12 h-16 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
|
||||||
@@ -462,7 +462,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#6d28d9] transition-colors">
|
<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 text-muted-foreground mb-1">
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
|||||||
@@ -137,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 => {
|
||||||
@@ -305,7 +300,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
placeholder="Search cast..."
|
placeholder="Search cast..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-9 h-9 bg-muted/50 border-none rounded-lg text-sm focus-visible:ring-[#6d28d9]/30"
|
className="pl-9 h-9 bg-muted/50 border-none rounded-lg text-sm focus-visible:ring-[#e8466c]/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -346,7 +341,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
className={cn(
|
className={cn(
|
||||||
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
||||||
(sortBy !== 'roleCount' || sortOrder !== 'desc')
|
(sortBy !== 'roleCount' || sortOrder !== 'desc')
|
||||||
? "border-[#6d28d9]/30 bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20"
|
? "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"
|
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -378,7 +373,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
{sortBy === option.value && (
|
{sortBy === option.value && (
|
||||||
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#6d28d9]" /> : <ArrowDownAZ size={14} className="text-[#6d28d9]" />
|
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#e8466c]" /> : <ArrowDownAZ size={14} className="text-[#e8466c]" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -395,7 +390,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
className={cn(
|
className={cn(
|
||||||
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
||||||
filterOccupation && filterOccupation !== 'all'
|
filterOccupation && filterOccupation !== 'all'
|
||||||
? "border-[#6d28d9]/30 bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20"
|
? "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"
|
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -430,7 +425,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
className={cn(
|
className={cn(
|
||||||
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
||||||
filterMediaType && filterMediaType !== 'all'
|
filterMediaType && filterMediaType !== 'all'
|
||||||
? "border-[#6d28d9]/30 bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20"
|
? "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"
|
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -475,7 +470,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="h-6 px-2 text-xs bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 hover:bg-[#6d28d9]/20 cursor-pointer"
|
className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => setSearchQuery('')}
|
||||||
>
|
>
|
||||||
Search: {searchQuery}
|
Search: {searchQuery}
|
||||||
@@ -485,7 +480,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
{filterOccupation && filterOccupation !== 'all' && (
|
{filterOccupation && filterOccupation !== 'all' && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="h-6 px-2 text-xs bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 hover:bg-[#6d28d9]/20 cursor-pointer"
|
className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||||
onClick={() => setFilterOccupation('all')}
|
onClick={() => setFilterOccupation('all')}
|
||||||
>
|
>
|
||||||
{filterOccupation}
|
{filterOccupation}
|
||||||
@@ -495,7 +490,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
{filterMediaType && filterMediaType !== 'all' && (
|
{filterMediaType && filterMediaType !== 'all' && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="h-6 px-2 text-xs bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 hover:bg-[#6d28d9]/20 cursor-pointer"
|
className="h-6 px-2 text-xs bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||||
onClick={() => setFilterMediaType('all')}
|
onClick={() => setFilterMediaType('all')}
|
||||||
>
|
>
|
||||||
{filterMediaType}
|
{filterMediaType}
|
||||||
@@ -545,20 +540,20 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className="group cursor-pointer overflow-hidden hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 border-border/60"
|
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)}
|
||||||
>
|
>
|
||||||
{/* Card Header with Avatar and Info */}
|
{/* Card Header with Avatar and Info */}
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Avatar className="h-12 w-12 rounded-lg border-2 border-border/50 group-hover:border-[#6d28d9] transition-colors duration-300 shadow-sm">
|
<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" />
|
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" className="object-cover" />
|
||||||
<AvatarFallback className="rounded-lg bg-muted">
|
<AvatarFallback className="rounded-lg bg-muted">
|
||||||
<User className="h-5 w-5 text-muted-foreground" />
|
<User className="h-5 w-5 text-muted-foreground" />
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="font-semibold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300 text-sm leading-tight">
|
<h3 className="font-semibold text-foreground truncate group-hover:text-[#e8466c] transition-colors duration-300 text-sm leading-tight">
|
||||||
{person.name}
|
{person.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||||
@@ -592,7 +587,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
{/* Latest Role Section */}
|
{/* Latest Role Section */}
|
||||||
{person.filmography && person.filmography.length > 0 && (
|
{person.filmography && person.filmography.length > 0 && (
|
||||||
<div className="px-3 pb-3">
|
<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-[#6d28d9]/20 transition-colors">
|
<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">
|
<div className="w-8 h-11 rounded overflow-hidden shrink-0 bg-background border border-border/40">
|
||||||
<img
|
<img
|
||||||
src={person.filmography[0].poster || person.photo}
|
src={person.filmography[0].poster || person.photo}
|
||||||
@@ -604,7 +599,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide leading-none">Latest</p>
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide leading-none">Latest</p>
|
||||||
<p className="text-[11px] font-medium text-foreground truncate">{person.filmography[0].title}</p>
|
<p className="text-[11px] font-medium text-foreground truncate">{person.filmography[0].title}</p>
|
||||||
<p className="text-[10px] text-[#6d28d9] truncate">{person.filmography[0].role}</p>
|
<p className="text-[10px] text-[#e8466c] truncate">{person.filmography[0].role}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -621,7 +616,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
<TableRow className="hover:bg-transparent border-border/60 bg-muted/30">
|
<TableRow className="hover:bg-transparent border-border/60 bg-muted/30">
|
||||||
<TableHead className="w-14 rounded-tl-lg"></TableHead>
|
<TableHead className="w-14 rounded-tl-lg"></TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:text-[#6d28d9] transition-colors"
|
className="cursor-pointer hover:text-[#e8466c] transition-colors"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -630,7 +625,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:text-[#6d28d9] transition-colors"
|
className="cursor-pointer hover:text-[#e8466c] transition-colors"
|
||||||
onClick={() => handleSort('role')}
|
onClick={() => handleSort('role')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -640,7 +635,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Latest Work</TableHead>
|
<TableHead className="hidden md:table-cell">Latest Work</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="hidden sm:table-cell cursor-pointer hover:text-[#6d28d9] transition-colors text-right"
|
className="hidden sm:table-cell cursor-pointer hover:text-[#e8466c] transition-colors text-right"
|
||||||
onClick={() => handleSort('roleCount')}
|
onClick={() => handleSort('roleCount')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
@@ -678,7 +673,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="group-hover:text-[#6d28d9] transition-colors">{person.name}</span>
|
<span className="group-hover:text-[#e8466c] transition-colors">{person.name}</span>
|
||||||
{person.birthDate && (
|
{person.birthDate && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{new Date(person.birthDate).toLocaleDateString()}
|
{new Date(person.birthDate).toLocaleDateString()}
|
||||||
|
|||||||
@@ -133,11 +133,11 @@ export default function DashboardView({ mediaList, onMediaClick, loading = false
|
|||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center">
|
<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" />
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-white">
|
<h1 className="text-2xl font-bold text-foreground">
|
||||||
Welcome to MediaVault
|
Welcome to MediaVault
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 text-sm ml-11">Your media library at a glance</p>
|
<p className="text-muted-foreground text-sm ml-11">Your media library at a glance</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
@@ -156,12 +156,12 @@ export default function DashboardView({ mediaList, onMediaClick, loading = false
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.1 + index * 0.05 }}
|
transition={{ delay: 0.1 + index * 0.05 }}
|
||||||
onClick={() => navigate(card.path)}
|
onClick={() => navigate(card.path)}
|
||||||
className={`relative overflow-hidden rounded-xl p-5 bg-gradient-to-br ${card.color} border border-white/5 hover:border-white/10 transition-all duration-300 cursor-pointer group`}
|
className={`relative overflow-hidden rounded-xl p-5 bg-gradient-to-br ${card.color} border border-border/50 hover:border-border/80 transition-all duration-300 cursor-pointer group`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">{card.label}</p>
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">{card.label}</p>
|
||||||
<p className="text-3xl font-bold text-white">{card.count}</p>
|
<p className="text-3xl font-bold text-foreground">{card.count}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`w-10 h-10 rounded-lg ${card.iconBg} flex items-center justify-center`}>
|
<div className={`w-10 h-10 rounded-lg ${card.iconBg} flex items-center justify-center`}>
|
||||||
<Icon className="w-5 h-5 text-white" />
|
<Icon className="w-5 h-5 text-white" />
|
||||||
@@ -191,10 +191,10 @@ export default function DashboardView({ mediaList, onMediaClick, loading = false
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-[#e8466c] uppercase tracking-wider">FAVORITES</p>
|
<p className="text-xs font-semibold text-[#e8466c] uppercase tracking-wider">FAVORITES</p>
|
||||||
<p className="text-2xl font-bold text-white">{favoritesMedia.length} <span className="text-sm font-normal text-gray-400">items in your favorites</span></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>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-gray-400 group-hover:text-white transition-colors">
|
<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>
|
<span className="text-sm font-medium">View Favorites</span>
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
@@ -214,11 +214,11 @@ export default function DashboardView({ mediaList, onMediaClick, loading = false
|
|||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Clock className="w-5 h-5 text-[#e8466c]" />
|
<Clock className="w-5 h-5 text-[#e8466c]" />
|
||||||
<h2 className="text-sm font-bold text-white uppercase tracking-wider">Recently Added</h2>
|
<h2 className="text-sm font-bold text-foreground uppercase tracking-wider">Recently Added</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/browse?sort=recent')}
|
onClick={() => navigate('/browse?sort=recent')}
|
||||||
className="flex items-center gap-1 text-sm text-gray-400 hover:text-white transition-colors"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
View All <ChevronRight className="w-4 h-4" />
|
View All <ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -240,11 +240,11 @@ 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-gray-400">
|
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
|
||||||
<div className="w-20 h-20 bg-white/5 rounded-2xl flex items-center justify-center mb-6 border border-white/10">
|
<div className="w-20 h-20 bg-muted rounded-2xl flex items-center justify-center mb-6 border border-border">
|
||||||
<Database className="w-10 h-10" />
|
<Database className="w-10 h-10" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-bold text-white">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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -418,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' ? (
|
||||||
<>
|
<>
|
||||||
@@ -1010,7 +1010,7 @@ export default function ImporterView() {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full transition-all duration-300 ease-out",
|
"h-full transition-all duration-300 ease-out",
|
||||||
progress.stage === 'error' ? "bg-gradient-to-r from-red-500 to-red-600" : "bg-gradient-to-r from-[#6d28d9] to-[#8b5cf6]"
|
progress.stage === 'error' ? "bg-gradient-to-r from-red-500 to-red-600" : "bg-gradient-to-r from-[#6d28d9] to-[#f47298]"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${getProgressPercentage()}%` }}
|
style={{ width: `${getProgressPercentage()}%` }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ export default function LibrarySettings({ enabledCategories, onToggleCategory }:
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-6 py-6">
|
<div className="grid gap-6 py-6">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-muted/30 border border-border/50 transition-all hover:border-[#6d28d9]/30 hover:bg-muted/50">
|
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-muted/30 border border-border/50 transition-all hover:border-[#e8466c]/30 hover:bg-muted/50">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/30">
|
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#e8466c] shadow-sm border border-border/30">
|
||||||
{CATEGORY_ICONS[category]}
|
{CATEGORY_ICONS[category]}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ interface MediaListItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categoryConfig: Record<MediaCategory, { label: string; color: string; bgColor: string; icon: any }> = {
|
const categoryConfig: Record<MediaCategory, { label: string; color: string; bgColor: string; icon: any }> = {
|
||||||
'Anime': { label: 'ANIME', color: 'text-purple-300', bgColor: 'bg-purple-500/30', icon: null },
|
'Anime': { label: 'ANIME', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: null },
|
||||||
'Movies': { label: 'MOVIE', color: 'text-blue-300', bgColor: 'bg-blue-500/30', icon: Film },
|
'Movies': { label: 'MOVIE', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Film },
|
||||||
'TV Series': { label: 'SERIES', color: 'text-green-300', bgColor: 'bg-green-500/30', icon: Tv },
|
'TV Series': { label: 'SERIES', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: Tv },
|
||||||
'Music': { label: 'MUSIC', color: 'text-pink-300', bgColor: 'bg-pink-500/30', icon: null },
|
'Music': { label: 'MUSIC', color: 'text-pink-400', bgColor: 'bg-pink-500/20', icon: null },
|
||||||
'Books': { label: 'BOOK', color: 'text-yellow-300', bgColor: 'bg-yellow-500/30', icon: null },
|
'Books': { label: 'BOOK', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: null },
|
||||||
'Games': { label: 'GAME', color: 'text-indigo-300', bgColor: 'bg-indigo-500/30', icon: Gamepad2 },
|
'Games': { label: 'GAME', color: 'text-indigo-400', bgColor: 'bg-indigo-500/20', icon: Gamepad2 },
|
||||||
'Consoles': { label: 'CONSOLE', color: 'text-orange-300', bgColor: 'bg-orange-500/30', icon: null },
|
'Consoles': { label: 'CONSOLE', color: 'text-orange-400', bgColor: 'bg-orange-500/20', icon: null },
|
||||||
'Adult': { label: 'ADULT', color: 'text-rose-300', bgColor: 'bg-rose-500/30', icon: Eye },
|
'Adult': { label: 'ADULT', color: 'text-rose-400', bgColor: 'bg-rose-500/20', icon: Eye },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MediaListItem({ media, onClick, isFavorite = false, onFavoriteToggle }: MediaListItemProps) {
|
export default function MediaListItem({ media, onClick, isFavorite = false, onFavoriteToggle }: MediaListItemProps) {
|
||||||
@@ -38,13 +38,13 @@ export default function MediaListItem({ media, onClick, isFavorite = false, onFa
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="group flex items-center px-4 py-2 hover:bg-white/[0.02] transition-colors cursor-pointer border-b border-white/[0.02] last:border-b-0"
|
className="group flex items-center px-4 py-2 hover:bg-muted/30 transition-colors cursor-pointer border-b border-border/30 last:border-b-0"
|
||||||
onClick={() => onClick(media)}
|
onClick={() => onClick(media)}
|
||||||
>
|
>
|
||||||
{/* TITLE Column: Poster + Title + Rating (like screenshot 2) */}
|
{/* TITLE Column: Poster + Title + Rating (like screenshot 2) */}
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-3 mr-4">
|
<div className="flex-1 min-w-0 flex items-center gap-3 mr-4">
|
||||||
{/* Poster Thumbnail */}
|
{/* Poster Thumbnail */}
|
||||||
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-[#1a1d26]">
|
<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}
|
||||||
@@ -55,7 +55,7 @@ export default function MediaListItem({ media, onClick, isFavorite = false, onFa
|
|||||||
|
|
||||||
{/* Title + Rating stacked */}
|
{/* Title + Rating stacked */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-medium text-gray-200 truncate group-hover:text-[#e8466c] transition-colors">
|
<h3 className="text-sm font-medium text-foreground truncate group-hover:text-[#e8466c] transition-colors">
|
||||||
{media.title}
|
{media.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-1 mt-0.5">
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
@@ -81,19 +81,19 @@ export default function MediaListItem({ media, onClick, isFavorite = false, onFa
|
|||||||
|
|
||||||
{/* GENRE Column */}
|
{/* GENRE Column */}
|
||||||
<div className="w-[140px] shrink-0 mr-4">
|
<div className="w-[140px] shrink-0 mr-4">
|
||||||
<span className="text-sm text-gray-500 truncate block">
|
<span className="text-sm text-muted-foreground truncate block">
|
||||||
{media.genres?.slice(0, 2).join(', ') || '-'}
|
{media.genres?.slice(0, 2).join(', ') || '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* YEAR Column */}
|
{/* YEAR Column */}
|
||||||
<div className="w-[60px] shrink-0 text-center mr-4">
|
<div className="w-[60px] shrink-0 text-center mr-4">
|
||||||
<span className="text-sm text-gray-400">{media.year}</span>
|
<span className="text-sm text-muted-foreground/80">{media.year}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PLAYS Column */}
|
{/* PLAYS Column */}
|
||||||
<div className="w-[50px] shrink-0 text-right mr-4">
|
<div className="w-[50px] shrink-0 text-right mr-4">
|
||||||
<span className="text-sm text-gray-400">{media.playCount || 0}</span>
|
<span className="text-sm text-muted-foreground/80">{media.playCount || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAVORITE Column (Heart) */}
|
{/* FAVORITE Column (Heart) */}
|
||||||
@@ -104,7 +104,7 @@ export default function MediaListItem({ media, onClick, isFavorite = false, onFa
|
|||||||
"p-1 rounded transition-colors",
|
"p-1 rounded transition-colors",
|
||||||
isFavorite
|
isFavorite
|
||||||
? "text-[#e8466c]"
|
? "text-[#e8466c]"
|
||||||
: "text-gray-600 hover:text-gray-500"
|
: "text-muted-foreground/40 hover:text-muted-foreground/60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Heart size={14} className={cn(isFavorite && "fill-current")} />
|
<Heart size={14} className={cn(isFavorite && "fill-current")} />
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ const categoryConfig: Record<MediaCategory, {
|
|||||||
bgColor: string;
|
bgColor: string;
|
||||||
icon: React.ElementType | null;
|
icon: React.ElementType | null;
|
||||||
}> = {
|
}> = {
|
||||||
'Anime': { label: 'ANIME', color: 'text-purple-300', bgColor: 'bg-purple-500/30', icon: null },
|
'Anime': { label: 'ANIME', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: null },
|
||||||
'Movies': { label: 'MOVIE', color: 'text-blue-300', bgColor: 'bg-blue-500/30', icon: Film },
|
'Movies': { label: 'MOVIE', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Film },
|
||||||
'TV Series': { label: 'SERIES', color: 'text-green-300', bgColor: 'bg-green-500/30', icon: Tv },
|
'TV Series': { label: 'SERIES', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: Tv },
|
||||||
'Music': { label: 'MUSIC', color: 'text-pink-300', bgColor: 'bg-pink-500/30', icon: Music },
|
'Music': { label: 'MUSIC', color: 'text-pink-400', bgColor: 'bg-pink-500/20', icon: Music },
|
||||||
'Books': { label: 'BOOK', color: 'text-yellow-300', bgColor: 'bg-yellow-500/30', icon: BookOpen },
|
'Books': { label: 'BOOK', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: BookOpen },
|
||||||
'Games': { label: 'GAME', color: 'text-indigo-300', bgColor: 'bg-indigo-500/30', icon: Gamepad2 },
|
'Games': { label: 'GAME', color: 'text-indigo-400', bgColor: 'bg-indigo-500/20', icon: Gamepad2 },
|
||||||
'Consoles': { label: 'CONSOLE', color: 'text-orange-300', bgColor: 'bg-orange-500/30', icon: Monitor },
|
'Consoles': { label: 'CONSOLE', color: 'text-orange-400', bgColor: 'bg-orange-500/20', icon: Monitor },
|
||||||
'Adult': { label: 'ADULT', color: 'text-rose-300', bgColor: 'bg-rose-500/30', icon: Eye },
|
'Adult': { label: 'ADULT', color: 'text-rose-400', bgColor: 'bg-rose-500/20', icon: Eye },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MediaTable({
|
export default function MediaTable({
|
||||||
@@ -104,7 +104,7 @@ export default function MediaTable({
|
|||||||
|
|
||||||
const SortIcon = ({ field }: { field: SortField }) => {
|
const SortIcon = ({ field }: { field: SortField }) => {
|
||||||
if (sortField !== field) {
|
if (sortField !== field) {
|
||||||
return <ArrowUpDown size={14} className="text-gray-600 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />;
|
return <ArrowUpDown size={14} className="text-muted-foreground/40 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />;
|
||||||
}
|
}
|
||||||
return sortDirection === 'asc'
|
return sortDirection === 'asc'
|
||||||
? <ArrowUp size={14} className="text-[#e8466c] ml-1" />
|
? <ArrowUp size={14} className="text-[#e8466c] ml-1" />
|
||||||
@@ -119,9 +119,9 @@ export default function MediaTable({
|
|||||||
return (
|
return (
|
||||||
<Table className="w-full">
|
<Table className="w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-b border-white/[0.03] hover:bg-transparent">
|
<TableRow className="border-b border-border/20 hover:bg-transparent">
|
||||||
<TableHead
|
<TableHead
|
||||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[45%]"
|
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')}
|
onClick={() => handleSort('title')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -129,7 +129,7 @@ export default function MediaTable({
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[80px]"
|
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')}
|
onClick={() => handleSort('category')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -137,7 +137,7 @@ export default function MediaTable({
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[18%]"
|
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')}
|
onClick={() => handleSort('genre')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -145,7 +145,7 @@ export default function MediaTable({
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[70px] text-center"
|
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')}
|
onClick={() => handleSort('rating')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
@@ -153,7 +153,7 @@ export default function MediaTable({
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[60px] text-center"
|
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')}
|
onClick={() => handleSort('year')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
@@ -161,7 +161,7 @@ export default function MediaTable({
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[60px] text-right"
|
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')}
|
onClick={() => handleSort('plays')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
@@ -180,13 +180,13 @@ export default function MediaTable({
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={media.id}
|
key={media.id}
|
||||||
className="border-b border-white/[0.02] hover:bg-white/[0.02] transition-colors cursor-pointer group"
|
className="border-b border-border/20 hover:bg-muted/30 transition-colors cursor-pointer group"
|
||||||
onClick={() => onMediaClick(media)}
|
onClick={() => onMediaClick(media)}
|
||||||
>
|
>
|
||||||
{/* Title Cell with Poster */}
|
{/* Title Cell with Poster */}
|
||||||
<TableCell className="py-2">
|
<TableCell className="py-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-[#1a1d26]">
|
<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}
|
||||||
@@ -195,7 +195,7 @@ export default function MediaTable({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-200 truncate group-hover:text-[#e8466c] transition-colors">
|
<div className="text-sm font-medium text-foreground truncate group-hover:text-[#e8466c] transition-colors">
|
||||||
{media.title}
|
{media.title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +216,7 @@ export default function MediaTable({
|
|||||||
|
|
||||||
{/* Genre */}
|
{/* Genre */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-sm text-gray-500 truncate block">
|
<span className="text-sm text-muted-foreground truncate block">
|
||||||
{media.genres?.join(', ') || '-'}
|
{media.genres?.join(', ') || '-'}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -225,7 +225,7 @@ export default function MediaTable({
|
|||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<Star size={12} className="text-[#e8466c] fill-[#e8466c]" />
|
<Star size={12} className="text-[#e8466c] fill-[#e8466c]" />
|
||||||
<span className="text-sm font-medium text-gray-300">
|
<span className="text-sm font-medium text-foreground/80">
|
||||||
{media.rating?.toFixed(1) || '-'}
|
{media.rating?.toFixed(1) || '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,12 +233,12 @@ export default function MediaTable({
|
|||||||
|
|
||||||
{/* Year */}
|
{/* Year */}
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<span className="text-sm text-gray-400">{media.year}</span>
|
<span className="text-sm text-muted-foreground/80">{media.year}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Plays */}
|
{/* Plays */}
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<span className="text-sm text-gray-400">{media.playCount || 0}</span>
|
<span className="text-sm text-muted-foreground/80">{media.playCount || 0}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Favorite */}
|
{/* Favorite */}
|
||||||
@@ -249,7 +249,7 @@ export default function MediaTable({
|
|||||||
"p-1 rounded transition-colors",
|
"p-1 rounded transition-colors",
|
||||||
isFavorite
|
isFavorite
|
||||||
? "text-[#e8466c]"
|
? "text-[#e8466c]"
|
||||||
: "text-gray-600 hover:text-gray-500"
|
: "text-muted-foreground/40 hover:text-muted-foreground/60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Heart size={14} className={cn(isFavorite && "fill-current")} />
|
<Heart size={14} className={cn(isFavorite && "fill-current")} />
|
||||||
|
|||||||
@@ -559,14 +559,14 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={customColors[key as keyof CustomColors] || '#6d28d9'}
|
value={customColors[key as keyof CustomColors] || '#e8466c'}
|
||||||
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
|
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
|
||||||
className="w-10 h-10 rounded-lg cursor-pointer border-0 p-0"
|
className="w-10 h-10 rounded-lg cursor-pointer border-0 p-0"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={customColors[key as keyof CustomColors] || ''}
|
value={customColors[key as keyof CustomColors] || ''}
|
||||||
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
|
onChange={(e) => handleColorChange(key as keyof CustomColors, e.target.value)}
|
||||||
placeholder="#6d28d9"
|
placeholder="#e8466c"
|
||||||
className="flex-1 text-xs"
|
className="flex-1 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,14 +123,14 @@ export default function MediaFilters({
|
|||||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||||
selectedGenre
|
selectedGenre
|
||||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Star size={14} className="mr-2" />
|
<Star size={14} className="mr-2" />
|
||||||
{selectedGenre || 'Genres'}
|
{selectedGenre || 'Genres'}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
Filter by Genre
|
Filter by Genre
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -153,14 +153,14 @@ export default function MediaFilters({
|
|||||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||||
selectedStudio
|
selectedStudio
|
||||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Building2 size={14} className="mr-2" />
|
<Building2 size={14} className="mr-2" />
|
||||||
{selectedStudio || 'Studios'}
|
{selectedStudio || 'Studios'}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
Filter by Studio
|
Filter by Studio
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -184,14 +184,14 @@ export default function MediaFilters({
|
|||||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||||
selectedPlatform
|
selectedPlatform
|
||||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Monitor size={14} className="mr-2" />
|
<Monitor size={14} className="mr-2" />
|
||||||
{selectedPlatform || 'Platforms'}
|
{selectedPlatform || 'Platforms'}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
Filter by Platform
|
Filter by Platform
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -215,14 +215,14 @@ export default function MediaFilters({
|
|||||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||||
selectedDeveloper
|
selectedDeveloper
|
||||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Users size={14} className="mr-2" />
|
<Users size={14} className="mr-2" />
|
||||||
{selectedDeveloper || 'Developers'}
|
{selectedDeveloper || 'Developers'}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
Filter by Developer
|
Filter by Developer
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -246,14 +246,14 @@ export default function MediaFilters({
|
|||||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||||
selectedCategory
|
selectedCategory
|
||||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FolderTree size={14} className="mr-2" />
|
<FolderTree size={14} className="mr-2" />
|
||||||
{selectedCategory || 'Series'}
|
{selectedCategory || 'Series'}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
Filter by Series
|
Filter by Series
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -277,14 +277,14 @@ export default function MediaFilters({
|
|||||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||||
selectedSource
|
selectedSource
|
||||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
: "border-border bg-transparent text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Database size={14} className="mr-2" />
|
<Database size={14} className="mr-2" />
|
||||||
{selectedSource || 'Source'}
|
{selectedSource || 'Source'}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
Filter by Source
|
Filter by Source
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -304,7 +304,7 @@ export default function MediaFilters({
|
|||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<button
|
<button
|
||||||
onClick={onClearAll}
|
onClick={onClearAll}
|
||||||
className="h-9 px-3 inline-flex items-center justify-center text-gray-400 hover:text-white hover:bg-white/5 rounded-lg transition-colors"
|
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" />
|
<X size={14} className="mr-2" />
|
||||||
Clear
|
Clear
|
||||||
|
|||||||
@@ -143,20 +143,20 @@ export default function AppSidebar({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar className="border-r border-white/5 bg-[#0d0f14]">
|
<Sidebar>
|
||||||
<SidebarHeader className="p-4">
|
<SidebarHeader className="p-4">
|
||||||
<NavLink to="/" className="flex items-center gap-3">
|
<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">
|
<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" />
|
<Database className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-bold text-white tracking-tight">{pageTitle}</span>
|
<span className="text-lg font-bold text-sidebar-foreground tracking-tight">{pageTitle}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent className="px-2">
|
<SidebarContent className="px-2">
|
||||||
{/* Main Navigation */}
|
{/* Main Navigation */}
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
<SidebarGroupLabel>
|
||||||
Navigation
|
Navigation
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -167,10 +167,9 @@ export default function AppSidebar({
|
|||||||
asChild
|
asChild
|
||||||
isActive={item.isActive}
|
isActive={item.isActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
'transition-colors w-full',
|
|
||||||
item.isActive
|
item.isActive
|
||||||
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
|
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
|
||||||
: 'text-gray-400 hover:bg-white/5 hover:text-white'
|
: ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NavLink to={item.to} className="flex items-center gap-2 w-full">
|
<NavLink to={item.to} className="flex items-center gap-2 w-full">
|
||||||
@@ -186,7 +185,7 @@ export default function AppSidebar({
|
|||||||
|
|
||||||
{/* Media Type Filters */}
|
{/* Media Type Filters */}
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
<SidebarGroupLabel>
|
||||||
Media Type
|
Media Type
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -199,10 +198,9 @@ export default function AppSidebar({
|
|||||||
onClick={() => handleFilterClick(filter.category)}
|
onClick={() => handleFilterClick(filter.category)}
|
||||||
isActive={isFilterActive}
|
isActive={isFilterActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
'transition-colors w-full justify-start gap-2',
|
|
||||||
isFilterActive
|
isFilterActive
|
||||||
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
|
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
|
||||||
: 'text-gray-400 hover:bg-white/5 hover:text-white'
|
: ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<filter.icon
|
<filter.icon
|
||||||
@@ -217,7 +215,7 @@ export default function AppSidebar({
|
|||||||
'ml-auto text-xs font-medium px-2 py-0.5 rounded-full shrink-0',
|
'ml-auto text-xs font-medium px-2 py-0.5 rounded-full shrink-0',
|
||||||
isFilterActive
|
isFilterActive
|
||||||
? 'bg-[#e8466c]/20 text-[#e8466c]'
|
? 'bg-[#e8466c]/20 text-[#e8466c]'
|
||||||
: 'bg-white/10 text-gray-500'
|
: 'bg-sidebar-accent text-sidebar-foreground/60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{filter.count}
|
{filter.count}
|
||||||
@@ -232,7 +230,7 @@ export default function AppSidebar({
|
|||||||
|
|
||||||
{/* Quick Filters */}
|
{/* Quick Filters */}
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
<SidebarGroupLabel>
|
||||||
Quick Filters
|
Quick Filters
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -240,7 +238,6 @@ export default function AppSidebar({
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
onClick={() => handleQuickFilter('most-played')}
|
onClick={() => handleQuickFilter('most-played')}
|
||||||
className="text-gray-400 hover:bg-white/5 hover:text-white w-full justify-start gap-2"
|
|
||||||
>
|
>
|
||||||
<Flame className="w-4 h-4 text-orange-400 shrink-0" />
|
<Flame className="w-4 h-4 text-orange-400 shrink-0" />
|
||||||
<span className="truncate">Most Played</span>
|
<span className="truncate">Most Played</span>
|
||||||
@@ -249,7 +246,6 @@ export default function AppSidebar({
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
onClick={() => handleQuickFilter('recently-added')}
|
onClick={() => handleQuickFilter('recently-added')}
|
||||||
className="text-gray-400 hover:bg-white/5 hover:text-white w-full justify-start gap-2"
|
|
||||||
>
|
>
|
||||||
<Clock className="w-4 h-4 text-cyan-400 shrink-0" />
|
<Clock className="w-4 h-4 text-cyan-400 shrink-0" />
|
||||||
<span className="truncate">Recently Added</span>
|
<span className="truncate">Recently Added</span>
|
||||||
@@ -260,13 +256,13 @@ export default function AppSidebar({
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter className="p-2">
|
<SidebarFooter className="p-2 space-y-1">
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="w-full justify-start gap-2 text-gray-400 hover:text-white hover:bg-white/5"
|
className="w-full justify-start gap-2 text-sidebar-foreground/60 hover:text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? (
|
{theme === 'dark' ? (
|
||||||
<>
|
<>
|
||||||
@@ -275,7 +271,7 @@ export default function AppSidebar({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Moon className="w-4 h-4 text-indigo-400" />
|
<Moon className="w-4 h-4 text-sidebar-foreground/60" />
|
||||||
<span>Dark Mode</span>
|
<span>Dark Mode</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -283,7 +279,7 @@ export default function AppSidebar({
|
|||||||
|
|
||||||
{/* User Profile */}
|
{/* User Profile */}
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-white/5 mt-2">
|
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-sidebar-accent">
|
||||||
<Avatar className="w-8 h-8">
|
<Avatar className="w-8 h-8">
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c] text-xs">
|
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c] text-xs">
|
||||||
@@ -295,20 +291,20 @@ export default function AppSidebar({
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">{user.name}</p>
|
<p className="text-sm font-medium text-sidebar-foreground truncate">{user.name}</p>
|
||||||
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
<p className="text-xs text-sidebar-foreground/50 truncate">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-white/5 mt-2">
|
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-sidebar-accent">
|
||||||
<Avatar className="w-8 h-8">
|
<Avatar className="w-8 h-8">
|
||||||
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c]">
|
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c]">
|
||||||
<User className="w-4 h-4" />
|
<User className="w-4 h-4" />
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white">Guest</p>
|
<p className="text-sm font-medium text-sidebar-foreground">Guest</p>
|
||||||
<p className="text-xs text-gray-500">Not logged in</p>
|
<p className="text-xs text-sidebar-foreground/50">Not logged in</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -318,7 +314,7 @@ export default function AppSidebar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full justify-start gap-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 mt-2"
|
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" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Logout</span>
|
<span>Logout</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
--mv-accent-light: #f47298;
|
--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%);
|
||||||
@@ -107,56 +107,56 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
||||||
|
|
||||||
/* MediaVault accent color - pink/coral */
|
/* MediaVault accent color - pink/coral */
|
||||||
--mv-accent: #e8466c;
|
--mv-accent: #e8466c;
|
||||||
--mv-accent-hover: #d13d60;
|
--mv-accent-hover: #d13d60;
|
||||||
--mv-accent-light: #f47298;
|
--mv-accent-light: #f47298;
|
||||||
|
|
||||||
/* Custom gradient colors for dark mode - more vibrant */
|
/* Custom gradient colors for dark mode - softer on eyes */
|
||||||
--gradient-purple: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 50%, #a78bfa 100%);
|
--gradient-purple: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
||||||
--gradient-blue: linear-gradient(135deg, #2563eb 0%, #3b82f6 50%, #60a5fa 100%);
|
--gradient-blue: linear-gradient(135deg, #3b82f6 0%, #60a5fa 50%, #93c5fd 100%);
|
||||||
--gradient-green: linear-gradient(135deg, #16a34a 0%, #22c55e 50%, #4ade80 100%);
|
--gradient-green: linear-gradient(135deg, #22c55e 0%, #4ade80 50%, #86efac 100%);
|
||||||
--gradient-yellow: linear-gradient(135deg, #ca8a04 0%, #eab308 50%, #facc15 100%);
|
--gradient-yellow: linear-gradient(135deg, #eab308 0%, #facc15 50%, #fde047 100%);
|
||||||
--gradient-pink: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
--gradient-pink: linear-gradient(135deg, #e8466c 0%, #f47298 50%, #f9a8c9 100%);
|
||||||
--gradient-orange: linear-gradient(135deg, #ea580c 0%, #f97316 50%, #fb923c 100%);
|
--gradient-orange: linear-gradient(135deg, #f97316 0%, #fb923c 50%, #fbbf24 100%);
|
||||||
--gradient-cyan: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 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(232, 70, 108, 0.08) 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(232, 70, 108, 0.05) 0%, transparent 50%);
|
radial-gradient(circle at bottom left, rgba(232, 70, 108, 0.04) 0%, transparent 50%);
|
||||||
--bg-gradient-mesh: linear-gradient(135deg, rgba(232, 70, 108, 0.03) 0%, rgba(244, 114, 152, 0.03) 50%, rgba(249, 168, 201, 0.03) 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 {
|
||||||
@@ -164,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;
|
||||||
|
|||||||
@@ -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 || [],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||