Refactor code structure for improved readability and maintainability
This commit is contained in:
147
README.md
147
README.md
@@ -32,7 +32,7 @@ yarn install
|
|||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
|
|
||||||
# 4. Get API keys (optional, but recommended)
|
# 4. Get API keys (optional, but recommended)
|
||||||
# See: docs/API_KEYS.md
|
# See: [docs/02-tecnico/apis.md](docs/02-tecnico/apis.md)
|
||||||
|
|
||||||
# 5. Run migrations
|
# 5. Run migrations
|
||||||
cd backend
|
cd backend
|
||||||
@@ -70,21 +70,33 @@ quasar/
|
|||||||
│ │ └── controllers/ # Request handlers
|
│ │ └── controllers/ # Request handlers
|
||||||
│ └── tests/ # Vitest unit tests (63+ tests)
|
│ └── tests/ # Vitest unit tests (63+ tests)
|
||||||
│
|
│
|
||||||
├── frontend/ # React + Vite + React Query
|
├── frontend/ # React 18 + Vite + TypeScript + TanStack
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── routes/ # Pages (/games, /roms, etc.)
|
│ │ ├── components/ # shadcn/ui components + custom components
|
||||||
│ │ ├── components/ # React components (Forms, Dialogs, Cards)
|
│ │ ├── pages/ # Application pages (Dashboard, Games, etc.)
|
||||||
│ │ └── hooks/ # TanStack Query hooks
|
│ │ ├── api/ # API services and types
|
||||||
│ └── tests/ # Vitest + React Testing Library (59+ tests)
|
│ │ ├── query/ # TanStack Query configuration
|
||||||
|
│ │ ├── form/ # TanStack Form + Zod configuration
|
||||||
|
│ │ ├── router/ # TanStack Router configuration
|
||||||
|
│ │ ├── types/ # TypeScript type definitions
|
||||||
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
|
│ │ ├── lib/ # Utility functions
|
||||||
|
│ │ ├── styles/ # Global styles and Tailwind config
|
||||||
|
│ │ └── layout/ # Layout components (Header, Sidebar, etc.)
|
||||||
|
│ ├── tests/ # Vitest + React Testing Library (59+ tests)
|
||||||
|
│ ├── public/ # Static assets
|
||||||
|
│ └── index.html # HTML entry point
|
||||||
│
|
│
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── e2e/ # Playwright E2E tests (15 tests)
|
│ ├── e2e/ # Playwright E2E tests (15 tests)
|
||||||
│ └── *.spec.ts # Config validation tests
|
│ └── *.spec.ts # Config validation tests
|
||||||
│
|
│
|
||||||
├── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
│ ├── API_KEYS.md # How to get API credentials
|
│ ├── README.md # Documentation index
|
||||||
│ ├── SECURITY.md # Security guidelines
|
│ ├── 01-conceptos/ # Fundamental concepts and requirements
|
||||||
│ └── ...
|
│ ├── 02-tecnico/ # Technical documentation
|
||||||
|
│ ├── 03-analisis/ # Comparative analysis
|
||||||
|
│ └── 04-operaciones/ # Operations and deployment
|
||||||
│
|
│
|
||||||
├── .gitea/
|
├── .gitea/
|
||||||
│ └── workflows/
|
│ └── workflows/
|
||||||
@@ -103,7 +115,7 @@ Copy `.env.example` to `.env.local` (or `.env.development`) and fill in:
|
|||||||
# Database (local SQLite)
|
# Database (local SQLite)
|
||||||
DATABASE_URL="file:./dev.db"
|
DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
# API Keys (get from docs/API_KEYS.md)
|
# API Keys (get from [docs/02-tecnico/apis.md](docs/02-tecnico/apis.md))
|
||||||
IGDB_CLIENT_ID=your_client_id
|
IGDB_CLIENT_ID=your_client_id
|
||||||
IGDB_CLIENT_SECRET=your_client_secret
|
IGDB_CLIENT_SECRET=your_client_secret
|
||||||
RAWG_API_KEY=your_api_key
|
RAWG_API_KEY=your_api_key
|
||||||
@@ -115,10 +127,12 @@ PORT=3000
|
|||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
```
|
```
|
||||||
|
|
||||||
For production, use Gitea Secrets. See **SECURITY.md** and **docs/API_KEYS.md**.
|
For production, use Gitea Secrets. See **SECURITY.md** and **[docs/02-tecnico/apis.md](docs/02-tecnico/apis.md)**.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
### General Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests (unit + config)
|
# Run all tests (unit + config)
|
||||||
yarn test
|
yarn test
|
||||||
@@ -139,6 +153,97 @@ yarn lint
|
|||||||
yarn format
|
yarn format
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Frontend Development and Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to frontend directory
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
yarn dev
|
||||||
|
# Frontend will be available at: http://localhost:5173
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# Preview production build
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# Run frontend-specific tests
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Run frontend tests with coverage
|
||||||
|
yarn test:coverage
|
||||||
|
|
||||||
|
# Lint frontend code
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# Format frontend code
|
||||||
|
yarn format
|
||||||
|
|
||||||
|
# Type check frontend
|
||||||
|
yarn type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Development and Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to backend directory
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
yarn dev
|
||||||
|
# Backend API will be available at: http://localhost:3000
|
||||||
|
|
||||||
|
# Run backend tests
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Run backend tests with coverage
|
||||||
|
yarn test:coverage
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
yarn test -- gamesController.spec.ts
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
yarn test:watch
|
||||||
|
|
||||||
|
# Lint backend code
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# Format backend code
|
||||||
|
yarn format
|
||||||
|
|
||||||
|
# Type check backend
|
||||||
|
yarn type-check
|
||||||
|
|
||||||
|
# Database operations
|
||||||
|
yarn prisma:migrate
|
||||||
|
yarn prisma:generate
|
||||||
|
yarn prisma:studio
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test backend API endpoints
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
|
||||||
|
# Test games endpoint
|
||||||
|
curl http://localhost:3000/api/games
|
||||||
|
|
||||||
|
# Test metadata search
|
||||||
|
curl "http://localhost:3000/api/metadata/search?q=Mario"
|
||||||
|
|
||||||
|
# Test ROM scanning
|
||||||
|
curl -X POST http://localhost:3000/api/roms/scan -d '{"path":"/path/to/roms"}'
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Backend won't start
|
### Backend won't start
|
||||||
@@ -158,7 +263,7 @@ Error: EADDRINUSE: address already in use :::3000
|
|||||||
### Metadata search returns no results
|
### Metadata search returns no results
|
||||||
|
|
||||||
```
|
```
|
||||||
→ Check that API keys are correct (docs/API_KEYS.md)
|
→ Check that API keys are correct ([docs/02-tecnico/apis.md](docs/02-tecnico/apis.md))
|
||||||
→ Check logs: tail -f backend/logs/*.log
|
→ Check logs: tail -f backend/logs/*.log
|
||||||
→ Test with: curl http://localhost:3000/api/metadata/search\?q\=Mario
|
→ Test with: curl http://localhost:3000/api/metadata/search\?q\=Mario
|
||||||
```
|
```
|
||||||
@@ -172,12 +277,12 @@ Error: EADDRINUSE: address already in use :::3000
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
For detailed architecture and decisions, see [docs/architecture.md](docs/architecture.md).
|
For detailed architecture and decisions, see [docs/01-conceptos/architecture.md](docs/01-conceptos/architecture.md).
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
- **Backend:** Node.js, Fastify, Prisma ORM, SQLite, TypeScript
|
- **Backend:** Node.js, Fastify, Prisma ORM, SQLite, TypeScript
|
||||||
- **Frontend:** React 18, Vite, TanStack Query, Tailwind CSS, shadcn/ui
|
- **Frontend:** React 18, Vite, TypeScript, TanStack Query, TanStack Router, TanStack Form, Zod, Tailwind CSS, shadcn/ui
|
||||||
- **Testing:** Vitest (unit), Playwright (E2E)
|
- **Testing:** Vitest (unit), Playwright (E2E)
|
||||||
- **APIs:** IGDB (OAuth), RAWG, TheGamesDB
|
- **APIs:** IGDB (OAuth), RAWG, TheGamesDB
|
||||||
|
|
||||||
@@ -193,10 +298,13 @@ For security guidelines, see [SECURITY.md](SECURITY.md).
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- **[SECURITY.md](SECURITY.md)** — Security policies and best practices
|
- **[SECURITY.md](SECURITY.md)** — Security policies and best practices
|
||||||
- **[docs/API_KEYS.md](docs/API_KEYS.md)** — How to obtain and configure API credentials
|
- **[docs/README.md](docs/README.md)** — Documentation index and navigation guide
|
||||||
- **[docs/architecture.md](docs/architecture.md)** — System architecture and design decisions
|
- **[docs/01-conceptos/requirements.md](docs/01-conceptos/requirements.md)** — Project requirements and use cases
|
||||||
- **[docs/data-model.md](docs/data-model.md)** — Database schema and entities
|
- **[docs/01-conceptos/architecture.md](docs/01-conceptos/architecture.md)** — System architecture and design decisions
|
||||||
- **[docs/requirements.md](docs/requirements.md)** — Project requirements and use cases
|
- **[docs/01-conceptos/data-model.md](docs/01-conceptos/data-model.md)** — Database schema and entities
|
||||||
|
- **[docs/02-tecnico/apis.md](docs/02-tecnico/apis.md)** — APIs configuration and integration guide
|
||||||
|
- **[docs/02-tecnico/frontend.md](docs/02-tecnico/frontend.md)** — Complete frontend architecture and implementation
|
||||||
|
- **[docs/03-analisis/competitive-analysis.md](docs/03-analisis/competitive-analysis.md)** — Market analysis and competitive research
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -252,5 +360,6 @@ MIT (or choose your license)
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Status:** MVP (v1.0.0) — Ready for self-hosted deployment.
|
**Status:** MVP (v1.0.0) — Ready for self-hosted deployment.
|
||||||
**Last updated:** 2026-02-12
|
**Last updated:** 2026-02-22
|
||||||
**Test coverage:** 122+ unit tests + 15 E2E tests ✅
|
**Test coverage:** 122+ unit tests + 15 E2E tests ✅
|
||||||
|
**Documentation:** Reorganized and consolidated ✅
|
||||||
|
|||||||
@@ -1,15 +1,222 @@
|
|||||||
# Comparativa de APIs — cobertura, límites, coste y calidad
|
# APIs del Sistema — Guía completa
|
||||||
|
|
||||||
**Introducción**
|
Este documento integra toda la información sobre APIs del sistema: obtención de claves, prioridades, estrategias, comparación y configuración.
|
||||||
Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots, géneros, desarrolladores), y datos de precio/ofertas. Las decisiones de integración deben priorizar cobertura, coste (preferencia: gratuito), calidad y facilidad de uso.
|
|
||||||
|
|
||||||
**Nota:** límites y condiciones pueden cambiar — verificar TOS antes de integración.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Resumen por API
|
## Tabla de Contenidos
|
||||||
|
|
||||||
### IGDB (Internet Games Database)
|
1. [APIs priorizadas (MVP)](#apis-priorizadas-mvp)
|
||||||
|
2. [Obtención de claves](#obtención-de-claves)
|
||||||
|
3. [Guía de integración](#guía-de-integración)
|
||||||
|
4. [Comparación detallada](#comparación-detallada)
|
||||||
|
5. [Estrategias técnicas](#estrategias-técnicas)
|
||||||
|
6. [Configuración y despliegue](#configuración-y-despliegue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APIs priorizadas (MVP)
|
||||||
|
|
||||||
|
### Prioridad Alta
|
||||||
|
|
||||||
|
1. **IGDB (Internet Game Database)** - Calidad superior, amplia cobertura
|
||||||
|
2. **RAWG (Rawg.io)** - Buena cobertura, datos de tiendas
|
||||||
|
|
||||||
|
### Prioridad Media
|
||||||
|
|
||||||
|
3. **TheGamesDB** - Artwork comunitario
|
||||||
|
4. **ScreenScraper** - Media específica para ROMs
|
||||||
|
|
||||||
|
### Prioridad Baja (para futuras versiones)
|
||||||
|
|
||||||
|
5. **PriceCharting** - Precios físicos
|
||||||
|
6. **IsThereAnyDeal** - Ofertas digitales
|
||||||
|
7. **MobyGames** - Datos históricos detallados
|
||||||
|
8. **eBay** - Datos de mercado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Obtención de claves
|
||||||
|
|
||||||
|
### IGDB (Internet Game Database)
|
||||||
|
|
||||||
|
IGDB usa **OAuth 2.0 via Twitch**. Steps:
|
||||||
|
|
||||||
|
1. Go to [Twitch Developer Console](https://dev.twitch.tv/console/apps)
|
||||||
|
2. Sign in with your Twitch account (create one if needed)
|
||||||
|
3. Click "Create Application"
|
||||||
|
- Name: "Quasar" (or your app name)
|
||||||
|
- Category: Select relevant category
|
||||||
|
- Accept terms, click Create
|
||||||
|
4. You'll see:
|
||||||
|
- **Client ID** — Copy this
|
||||||
|
- Click "New Secret" to generate **Client Secret** — Copy this
|
||||||
|
5. Go to Settings → OAuth Redirect URLs
|
||||||
|
- Add: `http://localhost:3000/oauth/callback` (development)
|
||||||
|
- For production: `https://yourdomain.com/oauth/callback`
|
||||||
|
6. In your `.env` file:
|
||||||
|
```
|
||||||
|
IGDB_CLIENT_ID=your_client_id
|
||||||
|
IGDB_CLIENT_SECRET=your_client_secret
|
||||||
|
```
|
||||||
|
7. Start Quasar, it will use IGDB automatically
|
||||||
|
|
||||||
|
**Rate Limit:** 4 requests/second
|
||||||
|
|
||||||
|
### RAWG (Rawg.io)
|
||||||
|
|
||||||
|
RAWG has a simpler **API Key** approach:
|
||||||
|
|
||||||
|
1. Go to [RAWG Settings](https://rawg.io/settings/account)
|
||||||
|
2. Sign up if needed, then login
|
||||||
|
3. Find "API Key" section
|
||||||
|
4. Click "Create new key" (if needed) or copy existing key
|
||||||
|
5. In your `.env` file:
|
||||||
|
```
|
||||||
|
RAWG_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
6. Start Quasar
|
||||||
|
|
||||||
|
**Rate Limit:** 20 requests/second (free tier)
|
||||||
|
|
||||||
|
**Note:** RAWG requires attribution in UI (include "Powered by RAWG" somewhere visible)
|
||||||
|
|
||||||
|
### TheGamesDB (thegamesdb.net)
|
||||||
|
|
||||||
|
TheGamesDB uses a simple **API Key**:
|
||||||
|
|
||||||
|
1. Go to [TheGamesDB API](https://thegamesdb.net/api)
|
||||||
|
2. Find "API Key" section (free registration required)
|
||||||
|
3. Register or login
|
||||||
|
4. Copy your API key
|
||||||
|
5. In your `.env` file:
|
||||||
|
```
|
||||||
|
THEGAMESDB_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
6. Start Quasar
|
||||||
|
|
||||||
|
**Rate Limit:** 1 request/second (free tier)
|
||||||
|
|
||||||
|
### ScreenScraper
|
||||||
|
|
||||||
|
ScreenScraper requiere cuenta y modelo de donación:
|
||||||
|
|
||||||
|
1. Go to [ScreenScraper](https://www.screenscraper.fr/)
|
||||||
|
2. Create account
|
||||||
|
3. Niveles de donación ofrecen límites distintos (ej.: 50.000 scrapes/día en nivel Bronze)
|
||||||
|
4. En tu `.env` file:
|
||||||
|
```
|
||||||
|
SCREENSCRAPER_USERNAME=your_username
|
||||||
|
SCREENSCRAPER_PASSWORD=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guía de integración
|
||||||
|
|
||||||
|
### IGDB
|
||||||
|
|
||||||
|
- **Obtener credenciales**: registrar una app en Twitch Developer Console para obtener `CLIENT_ID` y `CLIENT_SECRET`. Obtener token con grant type `client_credentials` (POST a `https://id.twitch.tv/oauth2/token`).
|
||||||
|
|
||||||
|
- **Endpoints principales**: `POST https://api.igdb.com/v4/games` (consulta flexible via body con sintaxis IGDB), `POST https://api.igdb.com/v4/covers`, `POST https://api.igdb.com/v4/platforms`.
|
||||||
|
|
||||||
|
- **Ejemplo (buscar)**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Obtener token
|
||||||
|
curl -X POST 'https://id.twitch.tv/oauth2/token?client_id=$IGDB_CLIENT_ID&client_secret=$IGDB_CLIENT_SECRET&grant_type=client_credentials'
|
||||||
|
|
||||||
|
# Buscar juegos
|
||||||
|
curl -X POST 'https://api.igdb.com/v4/games' \
|
||||||
|
-H "Client-ID: $IGDB_CLIENT_ID" \
|
||||||
|
-H "Authorization: Bearer $IGDB_TOKEN" \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
--data 'fields id,name,first_release_date,platforms.name,genres.name,cover.url; search "zelda"; limit 5;'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Respuesta (esquemática)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 12345,
|
||||||
|
"name": "Ejemplo",
|
||||||
|
"first_release_date": 1459468800,
|
||||||
|
"platforms": [{ "name": "Nintendo Switch" }],
|
||||||
|
"cover": { "url": "//images.igdb.com/...jpg" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Límites y manejo**: la API puede devolver `429` o cabeceras de límite; implementar retries exponenciales (ej. 3 intentos) y respetar `Retry-After`. Implementar circuit breaker si la API falla repetidamente.
|
||||||
|
- **Atribución**: mostrar origen de datos (ej. "Datos: IGDB") según términos del servicio.
|
||||||
|
|
||||||
|
### RAWG
|
||||||
|
|
||||||
|
- **Obtener credenciales**: registrarse en RAWG para obtener `RAWG_API_KEY` (https://rawg.io/apidocs).
|
||||||
|
- **Endpoints principales**: `GET https://api.rawg.io/api/games?key=API_KEY&search=...`, `GET https://api.rawg.io/api/games/{id}`.
|
||||||
|
- **Ejemplo**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://api.rawg.io/api/games?key=$RAWG_API_KEY&search=zelda&page_size=5'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Respuesta (esquemática)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 100,
|
||||||
|
"results": [
|
||||||
|
{ "id": 3498, "name": "GTA V", "released": "2013-09-17", "background_image": "https://..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Límites y manejo**: RAWG suele tener límites por clave/plan; cachear y fallback a otros proveedores si falla.
|
||||||
|
- **Atribución**: revisar condiciones y mostrar HTTP o texto de fuente si es requerido por el proveedor.
|
||||||
|
|
||||||
|
### TheGamesDB
|
||||||
|
|
||||||
|
- **Obtener credenciales**: crear cuenta y generar API Key en https://thegamesdb.net.
|
||||||
|
- **Endpoints**: búsqueda por nombre y detalles (`/v1/Games/ByGameName?name=...`, `/v1/Games/ByGameID?id=...`).
|
||||||
|
- **Ejemplo**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer $THEGAMESDB_KEY' 'https://api.thegamesdb.net/v1/Games/ByGameName?name=zelda'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrategia de fallback y normalización
|
||||||
|
|
||||||
|
- **Orden de prioridad**: IGDB → RAWG → TheGamesDB (configurable).
|
||||||
|
- **Normalización (mapping)**:
|
||||||
|
- `title` ← `name`
|
||||||
|
- `platform` ← `platforms[].name`
|
||||||
|
- `release_date` ← `first_release_date` / `released` → convertir a ISO 8601
|
||||||
|
- `genres` ← `genres[].name`
|
||||||
|
- `cover_url` ← `cover.url` / `background_image`
|
||||||
|
- `external_ids` ← `{ igdb: id, rawg: id, thegamesdb: id }`
|
||||||
|
|
||||||
|
- **Fallback**: si IGDB no tiene portada, intentar RAWG; si falla, usar TheGamesDB. Registrar la fuente usada.
|
||||||
|
|
||||||
|
### Caché y almacenamiento de artwork
|
||||||
|
|
||||||
|
- **Caché metadata**: LRU en memoria o Redis con TTL (por ejemplo 24h) para evitar sobrecargar APIs.
|
||||||
|
- **Almacenamiento de imágenes**: descargar y optimizar con `sharp` (crear versiones: thumb, medium), almacenar en `storage/artwork/{gameId}/cover.jpg` o S3.
|
||||||
|
- **Servicio proxy**: servir imágenes desde backend para no exponer keys ni URLs externas.
|
||||||
|
|
||||||
|
### Manejo de errores y resiliencia
|
||||||
|
|
||||||
|
- Implementar **retries** exponenciales con jitter (3 intentos).
|
||||||
|
- Implementar **circuit breaker** para desconectar llamadas a un proveedor fuera de servicio por N minutos.
|
||||||
|
- Limitar concurrencia por proveedor (p. ej. 5 llamadas simultáneas) y usar colas para trabajos masivos (enriquecimiento masivo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparación detallada
|
||||||
|
|
||||||
|
### Resumen por API
|
||||||
|
|
||||||
|
#### IGDB (Internet Games Database)
|
||||||
|
|
||||||
- **Resumen:** Base de datos muy completa (propiedad de Twitch/Amazon) con endpoints para juegos, covers, screenshots, plataformas, ratings, compañías y más.
|
- **Resumen:** Base de datos muy completa (propiedad de Twitch/Amazon) con endpoints para juegos, covers, screenshots, plataformas, ratings, compañías y más.
|
||||||
- **Autenticación / Requisitos:** OAuth vía Twitch (Client ID + Client Secret → token) — requiere cuenta Twitch y 2FA para registrar apps.
|
- **Autenticación / Requisitos:** OAuth vía Twitch (Client ID + Client Secret → token) — requiere cuenta Twitch y 2FA para registrar apps.
|
||||||
@@ -21,9 +228,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Gratuito para uso no comercial; acuerdos comerciales para partners (atribución en caso de partnership).
|
- **Costes / modelo:** Gratuito para uso no comercial; acuerdos comerciales para partners (atribución en caso de partnership).
|
||||||
- **Enlace:** https://api-docs.igdb.com/
|
- **Enlace:** https://api-docs.igdb.com/
|
||||||
|
|
||||||
---
|
#### RAWG
|
||||||
|
|
||||||
### RAWG
|
|
||||||
|
|
||||||
- **Resumen:** Gran base de datos (medio millón de juegos), buena para metadata general y enlaces a tiendas.
|
- **Resumen:** Gran base de datos (medio millón de juegos), buena para metadata general y enlaces a tiendas.
|
||||||
- **Autenticación / Requisitos:** API key en query string (`key=YOUR_API_KEY`).
|
- **Autenticación / Requisitos:** API key en query string (`key=YOUR_API_KEY`).
|
||||||
@@ -35,9 +240,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Free tier para proyectos personales; planes comerciales (pago mensual) para uso en productos con gran tráfico.
|
- **Costes / modelo:** Free tier para proyectos personales; planes comerciales (pago mensual) para uso en productos con gran tráfico.
|
||||||
- **Enlace:** https://rawg.io/apidocs
|
- **Enlace:** https://rawg.io/apidocs
|
||||||
|
|
||||||
---
|
#### TheGamesDB
|
||||||
|
|
||||||
### TheGamesDB
|
|
||||||
|
|
||||||
- **Resumen:** Base de datos comunitaria para juegos y artwork, con API pública v2.
|
- **Resumen:** Base de datos comunitaria para juegos y artwork, con API pública v2.
|
||||||
- **Autenticación / Requisitos:** Registro y uso de API key (ver docs); repositorio público del proyecto (GPLv3 para el código del servidor).
|
- **Autenticación / Requisitos:** Registro y uso de API key (ver docs); repositorio público del proyecto (GPLv3 para el código del servidor).
|
||||||
@@ -48,9 +251,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Cláusula clave:** No documentado públicamente — verificar con el equipo de TheGamesDB antes de uso comercial/redistribución.
|
- **Cláusula clave:** No documentado públicamente — verificar con el equipo de TheGamesDB antes de uso comercial/redistribución.
|
||||||
- **Enlace:** https://api.thegamesdb.net/
|
- **Enlace:** https://api.thegamesdb.net/
|
||||||
|
|
||||||
---
|
#### ScreenScraper
|
||||||
|
|
||||||
### ScreenScraper
|
|
||||||
|
|
||||||
- **Resumen:** Servicio francés orientado a frontends, con enorme cantidad de media y opciones de scraping.
|
- **Resumen:** Servicio francés orientado a frontends, con enorme cantidad de media y opciones de scraping.
|
||||||
- **Autenticación / Requisitos:** Cuenta en ScreenScraper; modelo de soporte/donación que habilita límites mayores.
|
- **Autenticación / Requisitos:** Cuenta en ScreenScraper; modelo de soporte/donación que habilita límites mayores.
|
||||||
@@ -62,9 +263,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Donación / suscripción para aumentar cuotas y velocidad.
|
- **Costes / modelo:** Donación / suscripción para aumentar cuotas y velocidad.
|
||||||
- **Enlace:** https://www.screenscraper.fr/
|
- **Enlace:** https://www.screenscraper.fr/
|
||||||
|
|
||||||
---
|
#### MobyGames
|
||||||
|
|
||||||
### MobyGames
|
|
||||||
|
|
||||||
- **Resumen:** Base histórica con screenshots, covers, reviews y credits; muy usada por investigación y metadata profunda.
|
- **Resumen:** Base histórica con screenshots, covers, reviews y credits; muy usada por investigación y metadata profunda.
|
||||||
- **Autenticación / Requisitos:** API y/o MobyPlus; la API requiere registro y suscripción.
|
- **Autenticación / Requisitos:** API y/o MobyPlus; la API requiere registro y suscripción.
|
||||||
@@ -76,9 +275,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Acceso vía suscripción / MobyPro; contactar para condiciones comerciales.
|
- **Costes / modelo:** Acceso vía suscripción / MobyPro; contactar para condiciones comerciales.
|
||||||
- **Enlace:** https://www.mobygames.com/api/subscribe/
|
- **Enlace:** https://www.mobygames.com/api/subscribe/
|
||||||
|
|
||||||
---
|
#### PriceCharting
|
||||||
|
|
||||||
### PriceCharting
|
|
||||||
|
|
||||||
- **Resumen:** Fuente especializada en historial de precios para juegos físicos y coleccionables.
|
- **Resumen:** Fuente especializada en historial de precios para juegos físicos y coleccionables.
|
||||||
- **Autenticación / Requisitos:** API documentada en el sitio; el acceso completo requiere suscripción / token pagado.
|
- **Autenticación / Requisitos:** API documentada en el sitio; el acceso completo requiere suscripción / token pagado.
|
||||||
@@ -90,9 +287,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Servicio comercial (licencias / API keys pagadas).
|
- **Costes / modelo:** Servicio comercial (licencias / API keys pagadas).
|
||||||
- **Enlace:** https://www.pricecharting.com/api-documentation
|
- **Enlace:** https://www.pricecharting.com/api-documentation
|
||||||
|
|
||||||
---
|
#### IsThereAnyDeal (Itad)
|
||||||
|
|
||||||
### IsThereAnyDeal (Itad)
|
|
||||||
|
|
||||||
- **Resumen:** Agregador de ofertas con histórico y mapeo de keys/tiendas; útil para tracking de ofertas digitales.
|
- **Resumen:** Agregador de ofertas con histórico y mapeo de keys/tiendas; útil para tracking de ofertas digitales.
|
||||||
- **Autenticación / Requisitos:** API Key (docs en https://docs.isthereanydeal.com/).
|
- **Autenticación / Requisitos:** API Key (docs en https://docs.isthereanydeal.com/).
|
||||||
@@ -104,9 +299,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Free tier; acuerdos comerciales para uso intensivo.
|
- **Costes / modelo:** Free tier; acuerdos comerciales para uso intensivo.
|
||||||
- **Enlace:** https://docs.isthereanydeal.com/
|
- **Enlace:** https://docs.isthereanydeal.com/
|
||||||
|
|
||||||
---
|
#### eBay
|
||||||
|
|
||||||
### eBay
|
|
||||||
|
|
||||||
- **Resumen:** Fuente de datos de mercado (listings, precios vendidos) para estimar valor real de mercado.
|
- **Resumen:** Fuente de datos de mercado (listings, precios vendidos) para estimar valor real de mercado.
|
||||||
- **Autenticación / Requisitos:** Registro en eBay Developers Program; claves y OAuth para endpoints de venta/completed items.
|
- **Autenticación / Requisitos:** Registro en eBay Developers Program; claves y OAuth para endpoints de venta/completed items.
|
||||||
@@ -118,9 +311,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Free para desarrolladores con límites; uso intensivo o comerciales pueden requerir acuerdos o certificaciones.
|
- **Costes / modelo:** Free para desarrolladores con límites; uso intensivo o comerciales pueden requerir acuerdos o certificaciones.
|
||||||
- **Enlace:** https://developer.ebay.com/
|
- **Enlace:** https://developer.ebay.com/
|
||||||
|
|
||||||
---
|
### Tabla resumida
|
||||||
|
|
||||||
## Tabla resumida
|
|
||||||
|
|
||||||
| API | Data types | Auth | Free / Paid | Fecha verificación | Licencia / Nota legal | Notes |
|
| API | Data types | Auth | Free / Paid | Fecha verificación | Licencia / Nota legal | Notes |
|
||||||
| -------------- | ------------------------------------------------------- | -------------------------------- | ------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
| -------------- | ------------------------------------------------------- | -------------------------------- | ------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||||
@@ -133,9 +324,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
| MobyGames | screenshots, credits, covers | Subscribe / API key | Paid / subscription | 2026-02-07 | Paid/Subscribe: https://www.mobygames.com/api/subscribe/ | Access via subscription; non-commercial rate limits documented |
|
| MobyGames | screenshots, credits, covers | Subscribe / API key | Paid / subscription | 2026-02-07 | Paid/Subscribe: https://www.mobygames.com/api/subscribe/ | Access via subscription; non-commercial rate limits documented |
|
||||||
| eBay | listings, sold data | eBay Dev keys / OAuth | Free (with limits) | 2026-02-07 | TOS: https://developer.ebay.com/ | Terms restrict distribution; API License Agreement |
|
| eBay | listings, sold data | eBay Dev keys / OAuth | Free (with limits) | 2026-02-07 | TOS: https://developer.ebay.com/ | Terms restrict distribution; API License Agreement |
|
||||||
|
|
||||||
---
|
### Conclusión y recomendación para MVP
|
||||||
|
|
||||||
## Conclusión y recomendación para MVP
|
|
||||||
|
|
||||||
Recomiendo un **set inicial de APIs (priorizado)**: **IGDB, RAWG, TheGamesDB, ScreenScraper, PriceCharting, IsThereAnyDeal.**
|
Recomiendo un **set inicial de APIs (priorizado)**: **IGDB, RAWG, TheGamesDB, ScreenScraper, PriceCharting, IsThereAnyDeal.**
|
||||||
|
|
||||||
@@ -144,13 +333,171 @@ Recomiendo un **set inicial de APIs (priorizado)**: **IGDB, RAWG, TheGamesDB, Sc
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Vacíos y verificación pendiente
|
## Estrategias técnicas
|
||||||
|
|
||||||
- **APIs que requieren suscripción / acuerdos comerciales:** PriceCharting (API premium, requiere suscripción), MobyGames (MobyPro/API requiere suscripción), EmuMovies (servicio comercial con TOS y cuentas), y en casos especiales eBay (certificaciones / acuerdos adicionales para ciertos permisos).
|
### Variables de entorno (ejemplos)
|
||||||
- **PriceCharting:** la documentación de la API existe pero el acceso completo está sujeto a registro/pago; no se publicó límite público durante la verificación.
|
|
||||||
- **MobyGames:** API y límites requieren suscripción/registro; hay que contactar para condiciones comerciales.
|
```
|
||||||
- **eBay:** múltiples APIs y límites por endpoint; requiere revisar caso de uso específico y cumplimiento del API License Agreement.
|
IGDB_CLIENT_ID=...
|
||||||
- **Notas:** Algunas APIs (ScreenScraper) usan modelos por donación/premium para aumentar cuotas; en APIs sin límites públicos, contactar al proveedor para confirmar condiciones.
|
IGDB_CLIENT_SECRET=...
|
||||||
|
RAWG_API_KEY=...
|
||||||
|
THEGAMESDB_API_KEY=...
|
||||||
|
SCREENSCRAPER_USERNAME=...
|
||||||
|
SCREENSCRAPER_PASSWORD=...
|
||||||
|
EXTERNAL_API_CONCURRENCY=5
|
||||||
|
```
|
||||||
|
|
||||||
|
> Nota: **Nunca** exponer estas claves en el cliente; siempre pasar por el backend.
|
||||||
|
|
||||||
|
### Normalización de datos
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NormalizedGame {
|
||||||
|
title: string;
|
||||||
|
platform: string;
|
||||||
|
release_date: string; // ISO 8601
|
||||||
|
genres: string[];
|
||||||
|
cover_url: string;
|
||||||
|
external_ids: {
|
||||||
|
igdb?: string;
|
||||||
|
rawg?: string;
|
||||||
|
thegamesdb?: string;
|
||||||
|
};
|
||||||
|
source: 'igdb' | 'rawg' | 'thegamesdb' | 'screenscraper';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo de implementación
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class MetadataService {
|
||||||
|
private apis = [
|
||||||
|
new IGDBService(),
|
||||||
|
new RAWGService(),
|
||||||
|
new TheGamesDBService(),
|
||||||
|
new ScreenScraperService(),
|
||||||
|
];
|
||||||
|
|
||||||
|
async searchGame(title: string): Promise<NormalizedGame> {
|
||||||
|
for (const api of this.apis) {
|
||||||
|
try {
|
||||||
|
const result = await api.search(title);
|
||||||
|
if (result) {
|
||||||
|
return this.normalize(result, api.getSource());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`${api.getSource()} failed:`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('All APIs failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(data: any, source: string): NormalizedGame {
|
||||||
|
return {
|
||||||
|
title: data.name || data.title,
|
||||||
|
platform: data.platforms?.[0]?.name || '',
|
||||||
|
release_date: this.normalizeDate(data.first_release_date || data.released),
|
||||||
|
genres: data.genres?.map((g: any) => g.name) || [],
|
||||||
|
cover_url: data.cover?.url || data.background_image || '',
|
||||||
|
external_ids: {
|
||||||
|
igdb: data.id,
|
||||||
|
rawg: data.id,
|
||||||
|
thegamesdb: data.id,
|
||||||
|
},
|
||||||
|
source: source as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuración y despliegue
|
||||||
|
|
||||||
|
### Testing Without Real Keys
|
||||||
|
|
||||||
|
Para desarrollo/testing:
|
||||||
|
|
||||||
|
- Dejar API keys como `your_*_here` en `.env.local`
|
||||||
|
- Quasar will gracefully degrade and show limited metadata
|
||||||
|
- Frontend will still work with manual game entry
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
Para producción:
|
||||||
|
|
||||||
|
1. Generar nuevas claves en cada servicio (no reutilizar claves de desarrollo)
|
||||||
|
2. Almacenar claves en **Gitea Secrets** (para pipelines CI/CD automatizados)
|
||||||
|
3. O usar variables de entorno en tu proveedor de hosting
|
||||||
|
4. Rotar claves cada 3 meses
|
||||||
|
5. Monitorear límites de rate en los dashboards de los servicios
|
||||||
|
|
||||||
|
### Gitea Actions CI/CD Setup
|
||||||
|
|
||||||
|
Para habilitar pruebas automatizadas con API keys en Gitea Actions:
|
||||||
|
|
||||||
|
#### 1. Store Secrets in Gitea
|
||||||
|
|
||||||
|
Navigate to your repository settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://your-gitea-instance/your-org/quasar/settings/secrets/actions
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these secrets:
|
||||||
|
|
||||||
|
- `IGDB_CLIENT_ID` (from Twitch Developer Console)
|
||||||
|
- `IGDB_CLIENT_SECRET` (from Twitch Developer Console)
|
||||||
|
- `RAWG_API_KEY` (from RAWG settings)
|
||||||
|
- `THEGAMESDB_API_KEY` (from TheGamesDB API)
|
||||||
|
- `SCREENSCRAPER_USERNAME` (from ScreenScraper)
|
||||||
|
- `SCREENSCRAPER_PASSWORD` (from ScreenScraper)
|
||||||
|
|
||||||
|
#### 2. Workflow Configuration
|
||||||
|
|
||||||
|
The `.gitea/workflows/ci.yml` workflow automatically:
|
||||||
|
|
||||||
|
- ✅ Installs dependencies
|
||||||
|
- ✅ Runs linting checks
|
||||||
|
- ✅ Executes backend tests (Vitest)
|
||||||
|
- ✅ Executes frontend tests (Vitest)
|
||||||
|
- ✅ Starts backend + frontend servers
|
||||||
|
- ✅ Runs E2E tests (Playwright) with real metadata APIs
|
||||||
|
- ✅ Uploads test reports on failure
|
||||||
|
|
||||||
|
#### 3. Testing Flow
|
||||||
|
|
||||||
|
1. **Push** code to `main` or `develop`
|
||||||
|
2. **Gitea Actions** picks up the `.gitea/workflows/ci.yml`
|
||||||
|
3. **Secrets are injected** as environment variables
|
||||||
|
4. **E2E tests** fetch real metadata from APIs (using injected secrets)
|
||||||
|
5. **Build fails** if any test fails (prevents broken code)
|
||||||
|
|
||||||
|
#### 4. Local Development
|
||||||
|
|
||||||
|
For local testing, use `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IGDB_CLIENT_ID=your_local_id
|
||||||
|
IGDB_CLIENT_SECRET=your_local_secret
|
||||||
|
RAWG_API_KEY=your_local_key
|
||||||
|
THEGAMESDB_API_KEY=your_local_key
|
||||||
|
SCREENSCRAPER_USERNAME=your_username
|
||||||
|
SCREENSCRAPER_PASSWORD=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** CI/CD uses Gitea Secrets (not `.env` files), so never commit real credentials.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**"IGDB_CLIENT_ID not found"** → Check `.env` file exists and has correct format
|
||||||
|
|
||||||
|
**"429 Too Many Requests"** → Rate limit exceeded, wait and retry
|
||||||
|
|
||||||
|
**"Invalid API Key"** → Copy key exactly (no spaces), verify it's active on service website
|
||||||
|
|
||||||
|
**"ScreenScraper authentication failed"** → Check donation level and account status
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
1195
docs/02-tecnico/frontend.md
Normal file
1195
docs/02-tecnico/frontend.md
Normal file
File diff suppressed because it is too large
Load Diff
546
docs/04-operaciones/deployment.md
Normal file
546
docs/04-operaciones/deployment.md
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
# Guía de Despliegue y Operaciones 🚀
|
||||||
|
|
||||||
|
Esta guía cubre el despliegue, configuración y operación de Quasar en producción.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabla de Contenidos
|
||||||
|
|
||||||
|
1. [Requisitos del Sistema](#requisitos-del-sistema)
|
||||||
|
2. [Configuración de Producción](#configuración-de-producción)
|
||||||
|
3. [Despliegue](#despliegue)
|
||||||
|
4. [Monitoreo y Mantenimiento](#monitoreo-y-mantenimiento)
|
||||||
|
5. [Actualizaciones](#actualizaciones)
|
||||||
|
6. [Backup y Recuperación](#backup-y-recuperación)
|
||||||
|
7. [Solución de Problemas](#solución-de-problemas)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos del Sistema
|
||||||
|
|
||||||
|
### Hardware Mínimo
|
||||||
|
|
||||||
|
- **CPU:** 2 cores
|
||||||
|
- **RAM:** 4GB
|
||||||
|
- **Almacenamiento:** 20GB (para ROMs y metadata)
|
||||||
|
- **Red:** Estable (para descargas de artwork)
|
||||||
|
|
||||||
|
### Software
|
||||||
|
|
||||||
|
- **Node.js 18+**
|
||||||
|
- **Yarn 4.x**
|
||||||
|
- **SQLite** (o PostgreSQL para producción)
|
||||||
|
- **Nginx** (recomendado para reverse proxy)
|
||||||
|
- **Certificado SSL** (HTTPS obligatorio)
|
||||||
|
|
||||||
|
### Dependencias Externas
|
||||||
|
|
||||||
|
- Claves API de IGDB, RAWG, TheGamesDB
|
||||||
|
- Acceso a servicios de descarga de imágenes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuración de Producción
|
||||||
|
|
||||||
|
### Variables de Entorno
|
||||||
|
|
||||||
|
Crear `.env.production` con:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="file:./production.db"
|
||||||
|
# Para PostgreSQL: postgresql://user:password@localhost:5432/quasar
|
||||||
|
|
||||||
|
# API Keys
|
||||||
|
IGDB_CLIENT_ID=your_production_client_id
|
||||||
|
IGDB_CLIENT_SECRET=your_production_client_secret
|
||||||
|
RAWG_API_KEY=your_production_api_key
|
||||||
|
THEGAMESDB_API_KEY=your_production_api_key
|
||||||
|
SCREENSCRAPER_USERNAME=your_screenscraper_username
|
||||||
|
SCREENSCRAPER_PASSWORD=your_screenscraper_password
|
||||||
|
|
||||||
|
# App Config
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Security
|
||||||
|
CORS_ORIGIN=https://yourdomain.com
|
||||||
|
JWT_SECRET=your_secure_jwt_secret_here
|
||||||
|
API_RATE_LIMIT=100
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
CACHE_TTL=86400
|
||||||
|
MAX_CONCURRENT_API_REQUESTS=5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuración de Nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name yourdomain.com;
|
||||||
|
|
||||||
|
# SSL Configuration
|
||||||
|
ssl_certificate /path/to/cert.pem;
|
||||||
|
ssl_certificate_key /path/to/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options DENY;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
root /var/www/quasar/frontend/dist;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 864s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
location /static/ {
|
||||||
|
root /var/www/quasar;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
### Opción 1: Docker (Recomendado)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile
|
||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json yarn.lock* ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
quasar-backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=file:./production.db
|
||||||
|
- IGDB_CLIENT_ID=${IGDB_CLIENT_ID}
|
||||||
|
- IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET}
|
||||||
|
- RAWG_API_KEY=${RAWG_API_KEY}
|
||||||
|
- THEGAMESDB_API_KEY=${THEGAMESDB_API_KEY}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./backend/prisma:/app/prisma
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
quasar-frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- '5173:5173'
|
||||||
|
depends_on:
|
||||||
|
- quasar-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- '443:443'
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
- ./ssl:/etc/nginx/ssl
|
||||||
|
depends_on:
|
||||||
|
- quasar-backend
|
||||||
|
- quasar-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opción 2: VPS Manual
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Setup server
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y nodejs yarn nginx sqlite3
|
||||||
|
|
||||||
|
# 2. Clone repository
|
||||||
|
git clone https://your-repo/quasar.git
|
||||||
|
cd quasar
|
||||||
|
|
||||||
|
# 3. Install dependencies
|
||||||
|
yarn install --production
|
||||||
|
|
||||||
|
# 4. Setup environment
|
||||||
|
cp .env.example .env.production
|
||||||
|
# Edit .env.production with real values
|
||||||
|
|
||||||
|
# 5. Build frontend
|
||||||
|
cd frontend
|
||||||
|
yarn build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 6. Setup database
|
||||||
|
cd backend
|
||||||
|
npx prisma migrate deploy
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 7. Configure nginx
|
||||||
|
sudo cp nginx.conf /etc/nginx/sites-available/quasar
|
||||||
|
sudo ln -s /etc/nginx/sites-available/quasar /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
|
||||||
|
# 8. Start services
|
||||||
|
cd backend
|
||||||
|
nohup yarn start > /var/log/quasar-backend.log 2>&1 &
|
||||||
|
cd ../frontend
|
||||||
|
nohup yarn start > /var/log/quasar-frontend.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoreo y Mantenimiento
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend health
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
|
||||||
|
# Database connection
|
||||||
|
curl http://localhost:3000/api/health/database
|
||||||
|
|
||||||
|
# API rate limits status
|
||||||
|
curl http://localhost:3000/api/health/rate-limits
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Configurar logrotate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/logrotate.d/quasar
|
||||||
|
/var/log/quasar/*.log {
|
||||||
|
daily
|
||||||
|
missingok
|
||||||
|
rotate 7
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoreo de API Keys
|
||||||
|
|
||||||
|
Crear script para verificar límites:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# check-api-limits.sh
|
||||||
|
|
||||||
|
# Check IGDB rate limits
|
||||||
|
curl -s -I "https://api.igdb.com/v4/games" | grep -i "x-ratelimit"
|
||||||
|
|
||||||
|
# Check RAWG usage
|
||||||
|
curl -s "https://api.rawg.io/api/games?key=$RAWG_API_KEY&search=test" | jq '.count'
|
||||||
|
|
||||||
|
# Log warnings
|
||||||
|
echo "$(date): API rate limits checked" >> /var/log/quasar/api-monitor.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Actualizaciones
|
||||||
|
|
||||||
|
### Proceso de Actualización
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Backup
|
||||||
|
./backup.sh
|
||||||
|
|
||||||
|
# 2. Stop services
|
||||||
|
sudo systemctl stop quasar-backend
|
||||||
|
sudo systemctl stop quasar-frontend
|
||||||
|
|
||||||
|
# 3. Pull latest code
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 4. Update dependencies
|
||||||
|
yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
# 5. Build frontend
|
||||||
|
cd frontend && yarn build && cd ..
|
||||||
|
|
||||||
|
# 6. Run migrations
|
||||||
|
cd backend && npx prisma migrate deploy && cd ..
|
||||||
|
|
||||||
|
# 7. Start services
|
||||||
|
sudo systemctl start quasar-backend
|
||||||
|
sudo systemctl start quasar-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actualizaciones de API Keys
|
||||||
|
|
||||||
|
1. Generar nuevas claves en cada servicio
|
||||||
|
2. Actualizar variables de entorno
|
||||||
|
3. Reiniciar servicios
|
||||||
|
4. Monitorear errores durante 24h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backup y Recuperación
|
||||||
|
|
||||||
|
### Script de Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# backup.sh
|
||||||
|
|
||||||
|
BACKUP_DIR="/backups/quasar"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
DB_FILE="quasar_$DATE.db"
|
||||||
|
ROMS_DIR="roms_$DATE"
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
cp backend/prisma/production.db "$BACKUP_DIR/$DB_FILE"
|
||||||
|
|
||||||
|
# Backup ROM metadata (not actual ROMs)
|
||||||
|
cp -r data/roms_metadata "$BACKUP_DIR/$ROMS_DIR"
|
||||||
|
|
||||||
|
# Backup configuration
|
||||||
|
cp .env.production "$BACKUP_DIR/env_$DATE"
|
||||||
|
|
||||||
|
# Compress backup
|
||||||
|
tar -czf "$BACKUP_DIR/backup_$DATE.tar.gz" -C "$BACKUP_DIR" "$DB_FILE" "$ROMS_DIR" "env_$DATE"
|
||||||
|
|
||||||
|
# Clean up old backups (keep last 7 days)
|
||||||
|
find "$BACKUP_DIR" -name "backup_*.tar.gz" -mtime +7 -delete
|
||||||
|
|
||||||
|
echo "Backup completed: $BACKUP_DIR/backup_$DATE.tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recuperación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# restore.sh
|
||||||
|
|
||||||
|
BACKUP_FILE=$1
|
||||||
|
BACKUP_DIR="/backups/quasar"
|
||||||
|
|
||||||
|
if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
|
||||||
|
echo "Backup file not found: $BACKUP_DIR/$BACKUP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
sudo systemctl stop quasar-backend
|
||||||
|
sudo systemctl stop quasar-frontend
|
||||||
|
|
||||||
|
# Extract backup
|
||||||
|
cd "$BACKUP_DIR"
|
||||||
|
tar -xzf "$BACKUP_FILE"
|
||||||
|
|
||||||
|
# Restore database
|
||||||
|
cp "$DB_FILE" backend/prisma/production.db
|
||||||
|
|
||||||
|
# Restore ROM metadata
|
||||||
|
cp -r "$ROMS_DIR"/* data/
|
||||||
|
|
||||||
|
# Restore configuration (optional)
|
||||||
|
# cp "env_$DATE" .env.production
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
sudo systemctl start quasar-backend
|
||||||
|
sudo systemctl start quasar-frontend
|
||||||
|
|
||||||
|
echo "Restore completed from: $BACKUP_FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solución de Problemas
|
||||||
|
|
||||||
|
### Problemas Comunes
|
||||||
|
|
||||||
|
#### 1. "Database connection failed"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database file
|
||||||
|
ls -la backend/prisma/production.db
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
sudo chown -R nodejs:nodejs backend/prisma/
|
||||||
|
|
||||||
|
# Check database integrity
|
||||||
|
sqlite3 backend/prisma/production.db "PRAGMA integrity_check;"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. "API rate limit exceeded"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current rate limits
|
||||||
|
curl -I "https://api.igdb.com/v4/games" | grep -i "x-ratelimit"
|
||||||
|
|
||||||
|
# Implement backoff strategy
|
||||||
|
# Check logs for specific API errors
|
||||||
|
tail -f /var/log/quasar/backend.log | grep "429"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. "Frontend cannot connect to backend"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backend is running
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
|
||||||
|
# Check CORS configuration
|
||||||
|
curl -H "Origin: https://yourdomain.com" -v http://localhost:3000/health
|
||||||
|
|
||||||
|
# Check nginx configuration
|
||||||
|
sudo nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. "ROM scanning fails"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check directory permissions
|
||||||
|
ls -la /path/to/roms/
|
||||||
|
|
||||||
|
# Check file formats
|
||||||
|
find /path/to/roms/ -name "*.zip" -o -name "*.7z" -o -name "*.rar"
|
||||||
|
|
||||||
|
# Check disk space
|
||||||
|
df -h
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diagnóstico Remoto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create diagnostic script
|
||||||
|
#!/bin/bash
|
||||||
|
# diagnostic.sh
|
||||||
|
|
||||||
|
echo "=== Quasar Diagnostic Report ==="
|
||||||
|
echo "Date: $(date)"
|
||||||
|
echo "Node.js version: $(node --version)"
|
||||||
|
echo "Yarn version: $(yarn --version)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== System Resources ==="
|
||||||
|
free -h
|
||||||
|
df -h
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Services Status ==="
|
||||||
|
systemctl status quasar-backend
|
||||||
|
systemctl status quasar-frontend
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Database Status ==="
|
||||||
|
sqlite3 backend/prisma/production.db "SELECT COUNT(*) FROM games;"
|
||||||
|
sqlite3 backend/prisma/production.db "SELECT COUNT(*) FROM rom_files;"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== API Keys Status ==="
|
||||||
|
echo "IGDB: ${IGDB_CLIENT_ID:0:10}..."
|
||||||
|
echo "RAWG: ${RAWG_API_KEY:0:10}..."
|
||||||
|
echo "TheGamesDB: ${THEGAMESDB_API_KEY:0:10}..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Recent Errors ==="
|
||||||
|
tail -20 /var/log/quasar/backend.log | grep -i "error"
|
||||||
|
tail -20 /var/log/quasar/frontend.log | grep -i "error"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Soporte
|
||||||
|
|
||||||
|
### Logs de Depuración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend logs
|
||||||
|
tail -f /var/log/quasar/backend.log
|
||||||
|
|
||||||
|
# Frontend logs
|
||||||
|
tail -f /var/log/quasar/frontend.log
|
||||||
|
|
||||||
|
# Nginx logs
|
||||||
|
tail -f /var/log/nginx/access.log
|
||||||
|
tail -f /var/log/nginx/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contacto
|
||||||
|
|
||||||
|
- **Issues:** Reportar en el repositorio de Gitea
|
||||||
|
- **Emergencias:** Email: support@yourdomain.com
|
||||||
|
- **Documentación:** Ver [docs/README.md](../../README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Última actualización: 2026-02-22_
|
||||||
140
docs/API_KEYS.md
140
docs/API_KEYS.md
@@ -1,140 +0,0 @@
|
|||||||
# Obtaining API Keys
|
|
||||||
|
|
||||||
This guide explains how to get credentials for each metadata service.
|
|
||||||
|
|
||||||
## IGDB (Internet Game Database)
|
|
||||||
|
|
||||||
IGDB uses **OAuth 2.0 via Twitch**. Steps:
|
|
||||||
|
|
||||||
1. Go to [Twitch Developer Console](https://dev.twitch.tv/console/apps)
|
|
||||||
2. Sign in with your Twitch account (create one if needed)
|
|
||||||
3. Click "Create Application"
|
|
||||||
- Name: "Quasar" (or your app name)
|
|
||||||
- Category: Select relevant category
|
|
||||||
- Accept terms, click Create
|
|
||||||
4. You'll see:
|
|
||||||
- **Client ID** — Copy this
|
|
||||||
- Click "New Secret" to generate **Client Secret** — Copy this
|
|
||||||
5. Go to Settings → OAuth Redirect URLs
|
|
||||||
- Add: `http://localhost:3000/oauth/callback` (development)
|
|
||||||
- For production: `https://yourdomain.com/oauth/callback`
|
|
||||||
6. In your `.env` file:
|
|
||||||
```
|
|
||||||
IGDB_CLIENT_ID=your_client_id
|
|
||||||
IGDB_CLIENT_SECRET=your_client_secret
|
|
||||||
```
|
|
||||||
7. Start Quasar, it will use IGDB automatically
|
|
||||||
|
|
||||||
**Rate Limit:** 4 requests/second
|
|
||||||
|
|
||||||
## RAWG (Rawg.io)
|
|
||||||
|
|
||||||
RAWG has a simpler **API Key** approach:
|
|
||||||
|
|
||||||
1. Go to [RAWG Settings](https://rawg.io/settings/account)
|
|
||||||
2. Sign up if needed, then login
|
|
||||||
3. Find "API Key" section
|
|
||||||
4. Click "Create new key" (if needed) or copy existing key
|
|
||||||
5. In your `.env` file:
|
|
||||||
```
|
|
||||||
RAWG_API_KEY=your_api_key_here
|
|
||||||
```
|
|
||||||
6. Start Quasar
|
|
||||||
|
|
||||||
**Rate Limit:** 20 requests/second (free tier)
|
|
||||||
|
|
||||||
**Note:** RAWG requires attribution in UI (include "Powered by RAWG" somewhere visible)
|
|
||||||
|
|
||||||
## TheGamesDB (thegamesdb.net)
|
|
||||||
|
|
||||||
TheGamesDB uses a simple **API Key**:
|
|
||||||
|
|
||||||
1. Go to [TheGamesDB API](https://thegamesdb.net/api)
|
|
||||||
2. Find "API Key" section (free registration required)
|
|
||||||
3. Register or login
|
|
||||||
4. Copy your API key
|
|
||||||
5. In your `.env` file:
|
|
||||||
```
|
|
||||||
THEGAMESDB_API_KEY=your_api_key_here
|
|
||||||
```
|
|
||||||
6. Start Quasar
|
|
||||||
|
|
||||||
**Rate Limit:** 1 request/second (free tier)
|
|
||||||
|
|
||||||
## Testing Without Real Keys
|
|
||||||
|
|
||||||
For development/testing:
|
|
||||||
|
|
||||||
- Leave API keys as `your_*_here` in `.env.local`
|
|
||||||
- Quasar will gracefully degrade and show limited metadata
|
|
||||||
- Frontend will still work with manual game entry
|
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
For production:
|
|
||||||
|
|
||||||
1. Generate new keys on each service (don't reuse dev keys)
|
|
||||||
2. Store keys in **Gitea Secrets** (for automated CI/CD pipelines)
|
|
||||||
3. Or use environment variables on your hosting provider
|
|
||||||
4. Rotate keys every 3 months
|
|
||||||
5. Monitor rate limits in service dashboards
|
|
||||||
|
|
||||||
## Gitea Actions CI/CD Setup
|
|
||||||
|
|
||||||
To enable automated testing with API keys in Gitea Actions:
|
|
||||||
|
|
||||||
### 1. Store Secrets in Gitea
|
|
||||||
|
|
||||||
Navigate to your repository settings:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://your-gitea-instance/your-org/quasar/settings/secrets/actions
|
|
||||||
```
|
|
||||||
|
|
||||||
Add these secrets:
|
|
||||||
|
|
||||||
- `IGDB_CLIENT_ID` (from Twitch Developer Console)
|
|
||||||
- `IGDB_CLIENT_SECRET` (from Twitch Developer Console)
|
|
||||||
- `RAWG_API_KEY` (from RAWG settings)
|
|
||||||
- `THEGAMESDB_API_KEY` (from TheGamesDB API)
|
|
||||||
|
|
||||||
### 2. Workflow Configuration
|
|
||||||
|
|
||||||
The `.gitea/workflows/ci.yml` workflow automatically:
|
|
||||||
|
|
||||||
- ✅ Installs dependencies
|
|
||||||
- ✅ Runs linting checks
|
|
||||||
- ✅ Executes backend tests (Vitest)
|
|
||||||
- ✅ Executes frontend tests (Vitest)
|
|
||||||
- ✅ Starts backend + frontend servers
|
|
||||||
- ✅ Runs E2E tests (Playwright) with real metadata APIs
|
|
||||||
- ✅ Uploads test reports on failure
|
|
||||||
|
|
||||||
### 3. Testing Flow
|
|
||||||
|
|
||||||
1. **Push** code to `main` or `develop`
|
|
||||||
2. **Gitea Actions** picks up the `.gitea/workflows/ci.yml`
|
|
||||||
3. **Secrets are injected** as `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET`, `RAWG_API_KEY`, `THEGAMESDB_API_KEY`
|
|
||||||
4. **E2E tests** fetch real metadata from APIs (using injected secrets)
|
|
||||||
5. **Build fails** if any test fails (prevents broken code)
|
|
||||||
|
|
||||||
### 4. Local Development
|
|
||||||
|
|
||||||
For local testing, use `.env.local`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
IGDB_CLIENT_ID=your_local_id
|
|
||||||
IGDB_CLIENT_SECRET=your_local_secret
|
|
||||||
RAWG_API_KEY=your_local_key
|
|
||||||
THEGAMESDB_API_KEY=your_local_key
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** CI/CD uses Gitea Secrets (not `.env` files), so never commit real credentials.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**"IGDB_CLIENT_ID not found"** → Check `.env` file exists and has correct format
|
|
||||||
|
|
||||||
**"429 Too Many Requests"** → Rate limit exceeded, wait and retry
|
|
||||||
|
|
||||||
**"Invalid API Key"** → Copy key exactly (no spaces), verify it's active on service website
|
|
||||||
92
docs/README.md
Normal file
92
docs/README.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Documentación del Proyecto Quasar 📚
|
||||||
|
|
||||||
|
Esta documentación está organizada en secciones lógicas para facilitar la navegación y mantenimiento.
|
||||||
|
|
||||||
|
## Estructura de la Documentación
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/
|
||||||
|
├── README.md # Este archivo (índice general)
|
||||||
|
├── 01-conceptos/ # Conceptos fundamentales y requisitos
|
||||||
|
│ ├── requirements.md # Requisitos funcionales y no funcionales
|
||||||
|
│ ├── architecture.md # Arquitectura técnica general
|
||||||
|
│ └── data-model.md # Modelo de datos y esquema
|
||||||
|
├── 02-tecnico/ # Documentación técnica detallada
|
||||||
|
│ ├── apis.md # APIs del sistema (consolidado)
|
||||||
|
│ ├── frontend.md # Documentación del frontend
|
||||||
|
│ └── lessons-learned.md # Lecciones aprendidas y recomendaciones
|
||||||
|
├── 03-analisis/ # Análisis comparativos y estudios
|
||||||
|
│ └── competitive-analysis.md # Análisis competitivo
|
||||||
|
└── 04-operaciones/ # Guías de operación y despliegue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guía de Navegación
|
||||||
|
|
||||||
|
### 🎯 Para nuevos desarrolladores
|
||||||
|
|
||||||
|
1. Comienza con [`01-conceptos/requirements.md`](01-conceptos/requirements.md) para entender el propósito
|
||||||
|
2. Lee [`01-conceptos/architecture.md`](01-conceptos/architecture.md) para la visión general
|
||||||
|
3. Revisa [`01-conceptos/data-model.md`](01-conceptos/data-model.md) para entender los datos
|
||||||
|
|
||||||
|
### 🔧 Para trabajo técnico
|
||||||
|
|
||||||
|
1. Consulta [`02-tecnico/apis.md`](02-tecnico/apis.md) para APIs y configuración
|
||||||
|
2. Revisa [`02-tecnico/frontend.md`](02-tecnico/frontend.md) para detalles del frontend
|
||||||
|
3. Lee [`02-tecnico/lessons-learned.md`](02-tecnico/lessons-learned.md) para buenas prácticas
|
||||||
|
|
||||||
|
### 📊 Para análisis y decisiones
|
||||||
|
|
||||||
|
1. Revisa [`03-analisis/competitive-analysis.md`](03-analisis/competitive-analysis.md) para contexto del mercado
|
||||||
|
|
||||||
|
### 🚀 Para operaciones y despliegue
|
||||||
|
|
||||||
|
1. Las guías de operación están en desarrollo (sección `04-operaciones/`)
|
||||||
|
|
||||||
|
## Convenciones
|
||||||
|
|
||||||
|
### Formato de enlaces
|
||||||
|
|
||||||
|
Todos los enlaces internos usan formato markdown estándar:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[texto de enlace](ruta/al/archivo.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nomenclatura de archivos
|
||||||
|
|
||||||
|
- Todos los usan `kebab-case.md`
|
||||||
|
- Los prefijos numéricos indican orden de lectura
|
||||||
|
|
||||||
|
### Estructura de documentos
|
||||||
|
|
||||||
|
- Cada documento tiene tabla de contenidos (TOC)
|
||||||
|
- Secciones numeradas para mejor navegación
|
||||||
|
- Ejemplos de código con formato sintáctico
|
||||||
|
|
||||||
|
## Estado Actual
|
||||||
|
|
||||||
|
| Sección | Estado | Comentarios |
|
||||||
|
| -------------- | ---------------- | ------------------------------------ |
|
||||||
|
| 01-conceptos | ✅ Completa | Documentación fundamental estable |
|
||||||
|
| 02-tecnico | ✅ Actualizada | APIs consolidados, frontend completo |
|
||||||
|
| 03-analisis | ✅ Completa | Análisis competitivo actualizado |
|
||||||
|
| 04-operaciones | 🚧 En desarrollo | Guías de operación pendientes |
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
- [ ] Añadir documentación de testing y CI/CD
|
||||||
|
- [ ] Crear índice temático para búsqueda rápida
|
||||||
|
- [ ] Documentar API REST detallada
|
||||||
|
|
||||||
|
## Contribuir
|
||||||
|
|
||||||
|
Al agregar nuevo contenido:
|
||||||
|
|
||||||
|
1. Coloca el documento en la sección adecuada
|
||||||
|
2. Sigue las convenciones de nomenclatura
|
||||||
|
3. Actualiza este README si agregas nuevas secciones
|
||||||
|
4. Revisa y actualiza referencias cruzadas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Última actualización: 2026-02-22_
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# Integración de APIs externas — Prioridad y guía práctica
|
|
||||||
|
|
||||||
## Objetivo
|
|
||||||
|
|
||||||
Definir APIs prioritarias para el MVP, cómo obtener credenciales, ejemplos de uso y estrategias de robustez (rate limit, retries, fallback y normalización de datos).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## APIs priorizadas (MVP)
|
|
||||||
|
|
||||||
1. **IGDB (prioridad alta)**
|
|
||||||
2. **RAWG (prioridad alta)**
|
|
||||||
3. **TheGamesDB (prioridad media)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## IGDB
|
|
||||||
|
|
||||||
- **Obtener credenciales**: registrar una app en Twitch Developer Console para obtener `CLIENT_ID` y `CLIENT_SECRET`. Obtener token con grant type `client_credentials` (POST a `https://id.twitch.tv/oauth2/token`).
|
|
||||||
|
|
||||||
- **Endpoints principales**: `POST https://api.igdb.com/v4/games` (consulta flexible via body con sintaxis IGDB), `POST https://api.igdb.com/v4/covers`, `POST https://api.igdb.com/v4/platforms`.
|
|
||||||
|
|
||||||
- **Ejemplo (buscar)**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Obtener token
|
|
||||||
curl -X POST 'https://id.twitch.tv/oauth2/token?client_id=$IGDB_CLIENT_ID&client_secret=$IGDB_CLIENT_SECRET&grant_type=client_credentials'
|
|
||||||
|
|
||||||
# Buscar juegos
|
|
||||||
curl -X POST 'https://api.igdb.com/v4/games' \
|
|
||||||
-H "Client-ID: $IGDB_CLIENT_ID" \
|
|
||||||
-H "Authorization: Bearer $IGDB_TOKEN" \
|
|
||||||
-H 'Accept: application/json' \
|
|
||||||
--data 'fields id,name,first_release_date,platforms.name,genres.name,cover.url; search "zelda"; limit 5;'
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Respuesta (esquemática)**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": 12345,
|
|
||||||
"name": "Ejemplo",
|
|
||||||
"first_release_date": 1459468800,
|
|
||||||
"platforms": [{ "name": "Nintendo Switch" }],
|
|
||||||
"cover": { "url": "//images.igdb.com/...jpg" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Límites y manejo**: la API puede devolver `429` o cabeceras de límite; implementar retries exponenciales (ej. 3 intentos) y respetar `Retry-After`. Implementar circuit breaker si la API falla repetidamente.
|
|
||||||
- **Atribución**: mostrar origen de datos (ej. "Datos: IGDB") según términos del servicio.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## RAWG
|
|
||||||
|
|
||||||
- **Obtener credenciales**: registrarse en RAWG para obtener `RAWG_API_KEY` (https://rawg.io/apidocs).
|
|
||||||
- **Endpoints principales**: `GET https://api.rawg.io/api/games?key=API_KEY&search=...`, `GET https://api.rawg.io/api/games/{id}`.
|
|
||||||
- **Ejemplo**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.rawg.io/api/games?key=$RAWG_API_KEY&search=zelda&page_size=5'
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Respuesta (esquemática)**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"count": 100,
|
|
||||||
"results": [
|
|
||||||
{ "id": 3498, "name": "GTA V", "released": "2013-09-17", "background_image": "https://..." }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Límites y manejo**: RAWG suele tener límites por clave/plan; cachear y fallback a otros proveedores si falla.
|
|
||||||
- **Atribución**: revisar condiciones y mostrar HTTP o texto de fuente si es requerido por el proveedor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TheGamesDB
|
|
||||||
|
|
||||||
- **Obtener credenciales**: crear cuenta y generar API Key en https://thegamesdb.net.
|
|
||||||
- **Endpoints**: búsqueda por nombre y detalles (`/v1/Games/ByGameName?name=...`, `/v1/Games/ByGameID?id=...`).
|
|
||||||
- **Ejemplo**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H 'Authorization: Bearer $THEGAMESDB_KEY' 'https://api.thegamesdb.net/v1/Games/ByGameName?name=zelda'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Estrategia de fallback y normalización
|
|
||||||
|
|
||||||
- **Orden de prioridad**: IGDB → RAWG → TheGamesDB (configurable).
|
|
||||||
- **Normalización (mapping)**:
|
|
||||||
- `title` ← `name`
|
|
||||||
- `platform` ← `platforms[].name`
|
|
||||||
- `release_date` ← `first_release_date` / `released` → convertir a ISO 8601
|
|
||||||
- `genres` ← `genres[].name`
|
|
||||||
- `cover_url` ← `cover.url` / `background_image`
|
|
||||||
- `external_ids` ← `{ igdb: id, rawg: id, thegamesdb: id }`
|
|
||||||
|
|
||||||
- **Fallback**: si IGDB no tiene portada, intentar RAWG; si falla, usar TheGamesDB. Registrar la fuente usada.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Caché y almacenamiento de artwork
|
|
||||||
|
|
||||||
- **Caché metadata**: LRU en memoria o Redis con TTL (por ejemplo 24h) para evitar sobrecargar APIs.
|
|
||||||
- **Almacenamiento de imágenes**: descargar y optimizar con `sharp` (crear versiones: thumb, medium), almacenar en `storage/artwork/{gameId}/cover.jpg` o S3.
|
|
||||||
- **Servicio proxy**: servir imágenes desde backend para no exponer keys ni URLs externas.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Manejo de errores y resiliencia
|
|
||||||
|
|
||||||
- Implementar **retries** exponenciales con jitter (3 intentos).
|
|
||||||
- Implementar **circuit breaker** para desconectar llamadas a un proveedor fuera de servicio por N minutos.
|
|
||||||
- Limitar concurrencia por proveedor (p. ej. 5 llamadas simultáneas) y usar colas para trabajos masivos (enriquecimiento masivo).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Variables de entorno (ejemplos)
|
|
||||||
|
|
||||||
```
|
|
||||||
IGDB_CLIENT_ID=...
|
|
||||||
IGDB_CLIENT_SECRET=...
|
|
||||||
RAWG_API_KEY=...
|
|
||||||
THEGAMESDB_API_KEY=...
|
|
||||||
EXTERNAL_API_CONCURRENCY=5
|
|
||||||
```
|
|
||||||
|
|
||||||
> Nota: **Nunca** exponer estas claves en el cliente; siempre pasar por el backend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fuentes
|
|
||||||
|
|
||||||
- IGDB API docs, RAWG API docs, TheGamesDB API docs.
|
|
||||||
- Patrones: retries, circuit breakers (ej. libraries: `p-retry`, `cockatiel`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Metadatos**
|
|
||||||
Autor: Quasar (investigación automatizada)
|
|
||||||
Última actualización: 2026-02-07
|
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -1,49 +1,73 @@
|
|||||||
# Frontend - Quasar Game Library
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
React + Vite + React Query UI for Quasar.
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
## Setup
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
```bash
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
cd frontend
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
yarn install
|
|
||||||
yarn dev # Start Vite dev server on http://localhost:5173
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
```bash
|
```js
|
||||||
yarn test # Run Vitest in watch mode
|
// eslint.config.js
|
||||||
yarn test:run # Run once
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn build # Build for production (dist/)
|
|
||||||
yarn preview # Preview production build locally
|
|
||||||
```
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── routes/ # Page components
|
|
||||||
├── components/ # Reusable components
|
|
||||||
├── hooks/ # TanStack Query hooks
|
|
||||||
├── lib/ # API client, utilities
|
|
||||||
├── types/ # TypeScript types
|
|
||||||
└── styles.css # Tailwind CSS
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Integration
|
|
||||||
|
|
||||||
Backend API runs on `http://localhost:3000`.
|
|
||||||
|
|
||||||
Frontend proxies requests via `.env`:
|
|
||||||
|
|
||||||
```
|
|
||||||
VITE_API_URL=http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
All API calls go through `src/lib/api.ts`.
|
|
||||||
|
|||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -1,33 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Quasar</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Quasar</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Quasar</title>
|
<title>frontend</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,38 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "quasar-frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "yarn@4.12.0",
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"lint": "eslint .",
|
||||||
"test": "vitest",
|
"preview": "vite preview"
|
||||||
"test:run": "vitest run",
|
|
||||||
"lint": "echo \"No lint configured\"",
|
|
||||||
"format": "prettier --write ."
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.0",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@tanstack/react-query": "^4.34.0",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"react": "^18.2.0",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"react-dom": "^18.2.0",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"react-hook-form": "^7.48.0",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"zod": "^3.22.0"
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
|
"@tanstack/react-form": "^1.28.3",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@tanstack/react-router": "^1.162.2",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.575.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.0.0",
|
"@eslint/js": "^9.39.1",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@testing-library/user-event": "^14.5.0",
|
"@tailwindcss/postcss": "^4.2.0",
|
||||||
"@types/react": "^18.2.21",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"autoprefixer": "^10.4.14",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"jsdom": "^22.1.0",
|
"autoprefixer": "^10.4.24",
|
||||||
"postcss": "^8.4.24",
|
"class-variance-authority": "^0.7.1",
|
||||||
"tailwindcss": "^3.4.7",
|
"clsx": "^2.1.1",
|
||||||
"typescript": "^5.2.2",
|
"eslint": "^9.39.1",
|
||||||
"vite": "^5.1.0",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"vitest": "^0.34.1"
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -1,13 +1,38 @@
|
|||||||
import React from 'react';
|
import { Button } from '@/components/ui/button';
|
||||||
import Navbar from './components/layout/Navbar';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="min-h-screen bg-background">
|
||||||
<Navbar />
|
<div className="container mx-auto p-6">
|
||||||
<main>
|
<Card>
|
||||||
<h1>Quasar</h1>
|
<CardHeader>
|
||||||
</main>
|
<CardTitle>Quasar - Biblioteca de Videojuegos</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Aplicación para gestionar tu colección personal de videojuegos
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button>Botón Primario</Button>
|
||||||
|
<Button variant="secondary">Botón Secundario</Button>
|
||||||
|
<Button variant="outline">Botón Outline</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input placeholder="Buscar juegos..." />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge variant="default">Etiqueta</Badge>
|
||||||
|
<Badge variant="secondary">Secundaria</Badge>
|
||||||
|
<Badge variant="outline">Outline</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
165
frontend/src/api/client.ts
Normal file
165
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
// Función para unir clases de Tailwind
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuración base de la API
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
// Interceptor para manejar errores comunes
|
||||||
|
async function handleApiError(response: Response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
// Manejar errores de autenticación
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error('No autorizado. Por favor inicia sesión.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar errores de validación
|
||||||
|
if (response.status === 422) {
|
||||||
|
const fieldErrors = errorData.errors || {};
|
||||||
|
const errorMessage = Object.entries(fieldErrors)
|
||||||
|
.map(([field, errors]) => `${field}: ${Array.isArray(errors) ? errors.join(', ') : errors}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new Error(errorMessage || 'Error de validación');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar errores de servidor
|
||||||
|
if (response.status >= 500) {
|
||||||
|
throw new Error('Error del servidor. Por favor intenta de nuevo más tarde.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar otros errores
|
||||||
|
throw new Error(errorData.message || 'Error en la solicitud');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función genérica para peticiones GET
|
||||||
|
export async function apiGet<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
|
||||||
|
const url = new URL(`${API_BASE_URL}${endpoint}`);
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
url.searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleApiError(response);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función genérica para peticiones POST
|
||||||
|
export async function apiPost<T>(endpoint: string, data: any): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleApiError(response);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función genérica para peticiones PUT
|
||||||
|
export async function apiPut<T>(endpoint: string, data: any): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleApiError(response);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función genérica para peticiones DELETE
|
||||||
|
export async function apiDelete<T>(endpoint: string): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleApiError(response);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para subir archivos
|
||||||
|
export async function apiUpload<T>(
|
||||||
|
endpoint: string,
|
||||||
|
file: File,
|
||||||
|
additionalData?: Record<string, any>
|
||||||
|
): Promise<T> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
if (additionalData) {
|
||||||
|
Object.entries(additionalData).forEach(([key, value]) => {
|
||||||
|
formData.append(key, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleApiError(response);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para peticiones con paginación
|
||||||
|
export async function apiGetPaginated<T>(
|
||||||
|
endpoint: string,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 10,
|
||||||
|
filters?: Record<string, any>
|
||||||
|
): Promise<{
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
...filters,
|
||||||
|
};
|
||||||
|
|
||||||
|
return apiGet(endpoint, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para buscar
|
||||||
|
export async function apiSearch<T>(
|
||||||
|
endpoint: string,
|
||||||
|
query: string,
|
||||||
|
filters?: Record<string, any>
|
||||||
|
): Promise<T[]> {
|
||||||
|
return apiGet(endpoint, {
|
||||||
|
search: query,
|
||||||
|
...filters,
|
||||||
|
});
|
||||||
|
}
|
||||||
80
frontend/src/api/games.ts
Normal file
80
frontend/src/api/games.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { apiGet, apiPost, apiPut, apiDelete, apiGetPaginated, apiSearch } from './client';
|
||||||
|
import type { Game, GameFilters, GameFormData, PaginatedResponse } from '@/types';
|
||||||
|
|
||||||
|
// Obtener todos los juegos con paginación
|
||||||
|
export const getGames = async (page: number = 1, limit: number = 10, filters?: GameFilters) => {
|
||||||
|
return apiGetPaginated<Game>('/api/games', page, limit, filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener un juego por ID
|
||||||
|
export const getGameById = async (id: number) => {
|
||||||
|
return apiGet<Game>(`/api/games/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crear un nuevo juego
|
||||||
|
export const createGame = async (data: GameFormData) => {
|
||||||
|
return apiPost<Game>('/api/games', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actualizar un juego existente
|
||||||
|
export const updateGame = async (id: number, data: GameFormData) => {
|
||||||
|
return apiPut<Game>(`/api/games/${id}`, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Eliminar un juego
|
||||||
|
export const deleteGame = async (id: number) => {
|
||||||
|
return apiDelete(`/api/games/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Buscar juegos
|
||||||
|
export const searchGames = async (query: string, filters?: GameFilters) => {
|
||||||
|
return apiSearch<Game>('/api/games', query, filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener juegos por plataforma
|
||||||
|
export const getGamesByPlatform = async (
|
||||||
|
platformId: number,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 10
|
||||||
|
) => {
|
||||||
|
return apiGetPaginated<Game>(`/api/games/platform/${platformId}`, page, limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener juegos por etiqueta
|
||||||
|
export const getGamesByTag = async (tagId: number, page: number = 1, limit: number = 10) => {
|
||||||
|
return apiGetPaginated<Game>(`/api/games/tag/${tagId}`, page, limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener juegos sin ROM
|
||||||
|
export const getGamesWithoutRom = async (page: number = 1, limit: number = 10) => {
|
||||||
|
return apiGetPaginated<Game>('/api/games/without-rom', page, limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener juegos metadata pendiente
|
||||||
|
export const getGamesWithPendingMetadata = async (page: number = 1, limit: number = 10) => {
|
||||||
|
return apiGetPaginated<Game>('/api/games/pending-metadata', page, limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actualizar metadata de un juego
|
||||||
|
export const updateGameMetadata = async (id: number, metadata: any) => {
|
||||||
|
return apiPut<Game>(`/api/games/${id}/metadata`, metadata);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enriquecer metadata de un juego
|
||||||
|
export const enrichGameMetadata = async (id: number, source: 'igdb' | 'rawg' | 'thegamesdb') => {
|
||||||
|
return apiPost<Game>(`/api/games/${id}/enrich`, { source });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener estadísticas de juegos
|
||||||
|
export const getGameStats = async () => {
|
||||||
|
return apiGet<{
|
||||||
|
totalGames: number;
|
||||||
|
gamesWithRom: number;
|
||||||
|
gamesWithoutRom: number;
|
||||||
|
gamesWithMetadata: number;
|
||||||
|
gamesWithoutMetadata: number;
|
||||||
|
totalPlatforms: number;
|
||||||
|
totalTags: number;
|
||||||
|
averageRating: number;
|
||||||
|
}>('/api/games/stats');
|
||||||
|
};
|
||||||
76
frontend/src/api/import.ts
Normal file
76
frontend/src/api/import.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { apiGet, apiPost, apiPut, apiDelete, apiUpload } from './client';
|
||||||
|
import type { ImportRomFormData } from '@/types';
|
||||||
|
|
||||||
|
// Iniciar escaneo de directorio
|
||||||
|
export const startDirectoryScan = async (directoryPath: string) => {
|
||||||
|
return apiPost('/api/import/scan', { directoryPath });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener estado del escaneo
|
||||||
|
export const getScanStatus = async (scanId: string) => {
|
||||||
|
return apiGet(`/api/import/scan/${scanId}/status`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener lista de ROMs detectadas
|
||||||
|
export const getDetectedRoms = async (scanId: string) => {
|
||||||
|
return apiGet(`/api/import/scan/${scanId}/roms`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Procesar ROMs detectadas
|
||||||
|
export const processDetectedRoms = async (scanId: string, romIds: number[]) => {
|
||||||
|
return apiPost(`/api/import/scan/${scanId}/process`, { romIds });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Importar ROM manualmente
|
||||||
|
export const importRom = async (data: ImportRomFormData & { file: File }) => {
|
||||||
|
const { file, ...rest } = data;
|
||||||
|
return apiUpload('/api/import/rom', file, rest);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener historial de importaciones
|
||||||
|
export const getImportHistory = async (page: number = 1, limit: number = 10) => {
|
||||||
|
return apiGet(`/api/import/history?page=${page}&limit=${limit}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener estadísticas de importación
|
||||||
|
export const getImportStats = async () => {
|
||||||
|
return apiGet<{
|
||||||
|
totalImports: number;
|
||||||
|
successfulImports: number;
|
||||||
|
failedImports: number;
|
||||||
|
totalRomsScanned: number;
|
||||||
|
totalRomsImported: number;
|
||||||
|
averageProcessingTime: number;
|
||||||
|
lastImportDate?: string;
|
||||||
|
}>('/api/import/stats');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancelar escaneo en progreso
|
||||||
|
export const cancelScan = async (scanId: string) => {
|
||||||
|
return apiDelete(`/api/import/scan/${scanId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reintentar importación fallida
|
||||||
|
export const retryImport = async (importId: number) => {
|
||||||
|
return apiPost(`/api/import/retry/${importId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Eliminar registro de importación
|
||||||
|
export const deleteImportRecord = async (importId: number) => {
|
||||||
|
return apiDelete(`/api/import/history/${importId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener información de archivo ROM
|
||||||
|
export const getRomInfo = async (romPath: string) => {
|
||||||
|
return apiGet(`/api/import/rom-info?path=${encodeURIComponent(romPath)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verificar integridad de ROM
|
||||||
|
export const verifyRomIntegrity = async (romPath: string, romType: string) => {
|
||||||
|
return apiPost('/api/import/verify-rom', { romPath, romType });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener directorios disponibles para escaneo
|
||||||
|
export const getAvailableDirectories = async () => {
|
||||||
|
return apiGet<string[]>('/api/import/directories');
|
||||||
|
};
|
||||||
52
frontend/src/api/platforms.ts
Normal file
52
frontend/src/api/platforms.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { apiGet, apiPost, apiPut, apiDelete, apiGetPaginated, apiSearch } from './client';
|
||||||
|
import type { Platform, PlatformFilters, PlatformFormData } from '@/types';
|
||||||
|
|
||||||
|
// Obtener todas las plataformas con paginación
|
||||||
|
export const getPlatforms = async (
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 10,
|
||||||
|
filters?: PlatformFilters
|
||||||
|
) => {
|
||||||
|
return apiGetPaginated<Platform>('/api/platforms', page, limit, filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener todas las plataformas (sin paginación para selectores)
|
||||||
|
export const getAllPlatforms = async () => {
|
||||||
|
return apiGet<Platform[]>('/api/platforms/all');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener una plataforma por ID
|
||||||
|
export const getPlatformById = async (id: number) => {
|
||||||
|
return apiGet<Platform>(`/api/platforms/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crear una nueva plataforma
|
||||||
|
export const createPlatform = async (data: PlatformFormData) => {
|
||||||
|
return apiPost<Platform>('/api/platforms', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actualizar una plataforma existente
|
||||||
|
export const updatePlatform = async (id: number, data: PlatformFormData) => {
|
||||||
|
return apiPut<Platform>(`/api/platforms/${id}`, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Eliminar una plataforma
|
||||||
|
export const deletePlatform = async (id: number) => {
|
||||||
|
return apiDelete(`/api/platforms/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Buscar plataformas
|
||||||
|
export const searchPlatforms = async (query: string, filters?: PlatformFilters) => {
|
||||||
|
return apiSearch<Platform>('/api/platforms', query, filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener estadísticas de plataformas
|
||||||
|
export const getPlatformStats = async () => {
|
||||||
|
return apiGet<{
|
||||||
|
totalPlatforms: number;
|
||||||
|
totalGames: number;
|
||||||
|
averageGamesPerPlatform: number;
|
||||||
|
mostPopularPlatform: string;
|
||||||
|
leastPopularPlatform: string;
|
||||||
|
}>('/api/platforms/stats');
|
||||||
|
};
|
||||||
107
frontend/src/api/settings.ts
Normal file
107
frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { apiGet, apiPost, apiPut } from './client';
|
||||||
|
import type { SettingsFormData } from '@/types';
|
||||||
|
|
||||||
|
// Obtener configuración actual
|
||||||
|
export const getSettings = async () => {
|
||||||
|
return apiGet('/api/settings');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actualizar configuración
|
||||||
|
export const updateSettings = async (data: SettingsFormData) => {
|
||||||
|
return apiPut('/api/settings', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Probar conexión con IGDB
|
||||||
|
export const testIgdbConnection = async (apiKey: string) => {
|
||||||
|
return apiPost('/api/settings/test-igdb', { apiKey });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Probar conexión con RAWG
|
||||||
|
export const testRawgConnection = async (apiKey: string) => {
|
||||||
|
return apiPost('/api/settings/test-rawg', { apiKey });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Probar conexión con TheGamesDB
|
||||||
|
export const testThegamesdbConnection = async (apiKey: string) => {
|
||||||
|
return apiPost('/api/settings/test-thegamesdb', { apiKey });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener estado de servicios externos
|
||||||
|
export const getExternalServicesStatus = async () => {
|
||||||
|
return apiGet<{
|
||||||
|
igdb: { connected: boolean; lastChecked?: string };
|
||||||
|
rawg: { connected: boolean; lastChecked?: string };
|
||||||
|
thegamesdb: { connected: boolean; lastChecked?: string };
|
||||||
|
}>('/api/settings/services-status');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener configuración de importación automática
|
||||||
|
export const getAutoImportConfig = async () => {
|
||||||
|
return apiGet('/api/settings/auto-import');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actualizar configuración de importación automática
|
||||||
|
export const updateAutoImportConfig = async (enabled: boolean, directory?: string) => {
|
||||||
|
return apiPut('/api/settings/auto-import', { enabled, directory });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener configuración de exportación
|
||||||
|
export const getExportConfig = async () => {
|
||||||
|
return apiGet('/api/settings/export');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actualizar configuración de exportación
|
||||||
|
export const updateExportConfig = async (format: 'csv' | 'json', fields: string[]) => {
|
||||||
|
return apiPut('/api/settings/export', { format, fields });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exportar datos
|
||||||
|
export const exportData = async (format: 'csv' | 'json', filters?: Record<string, any>) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('format', format);
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
params.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/settings/export?${params.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Error al exportar datos');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener estadísticas del sistema
|
||||||
|
export const getSystemStats = async () => {
|
||||||
|
return apiGet<{
|
||||||
|
totalGames: number;
|
||||||
|
totalPlatforms: number;
|
||||||
|
totalTags: number;
|
||||||
|
totalRoms: number;
|
||||||
|
totalSize: number;
|
||||||
|
averageRating: number;
|
||||||
|
recentActivity: Array<{
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
}>;
|
||||||
|
}>('/api/settings/system-stats');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limpiar caché
|
||||||
|
export const clearCache = async () => {
|
||||||
|
return apiPost('/api/settings/clear-cache');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener versión del sistema
|
||||||
|
export const getSystemVersion = async () => {
|
||||||
|
return apiGet('/api/settings/version');
|
||||||
|
};
|
||||||
68
frontend/src/api/tags.ts
Normal file
68
frontend/src/api/tags.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { apiGet, apiPost, apiPut, apiDelete, apiGetPaginated, apiSearch } from './client';
|
||||||
|
import type { Tag, TagFilters, TagFormData } from '@/types';
|
||||||
|
|
||||||
|
// Obtener todas las etiquetas con paginación
|
||||||
|
export const getTags = async (page: number = 1, limit: number = 10, filters?: TagFilters) => {
|
||||||
|
return apiGetPaginated<Tag>('/api/tags', page, limit, filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener todas las etiquetas (sin paginación para selectores)
|
||||||
|
export const getAllTags = async () => {
|
||||||
|
return apiGet<Tag[]>('/api/tags/all');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener una etiqueta por ID
|
||||||
|
export const getTagById = async (id: number) => {
|
||||||
|
return apiGet<Tag>(`/api/tags/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crear una nueva etiqueta
|
||||||
|
export const createTag = async (data: TagFormData) => {
|
||||||
|
return apiPost<Tag>('/api/tags', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actualizar una etiqueta existente
|
||||||
|
export const updateTag = async (id: number, data: TagFormData) => {
|
||||||
|
return apiPut<Tag>(`/api/tags/${id}`, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Eliminar una etiqueta
|
||||||
|
export const deleteTag = async (id: number) => {
|
||||||
|
return apiDelete(`/api/tags/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Buscar etiquetas
|
||||||
|
export const searchTags = async (query: string, filters?: TagFilters) => {
|
||||||
|
return apiSearch<Tag>('/api/tags', query, filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener etiquetas más usadas
|
||||||
|
export const getPopularTags = async (limit: number = 10) => {
|
||||||
|
return apiGet<Tag[]>(`/api/tags/popular?limit=${limit}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener etiquetas por juego
|
||||||
|
export const getTagsByGame = async (gameId: number) => {
|
||||||
|
return apiGet<Tag[]>(`/api/tags/game/${gameId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Asignar etiquetas a un juego
|
||||||
|
export const assignTagsToGame = async (gameId: number, tagIds: number[]) => {
|
||||||
|
return apiPost(`/api/games/${gameId}/tags`, { tagIds });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Eliminar etiquetas de un juego
|
||||||
|
export const removeTagsFromGame = async (gameId: number, tagIds: number[]) => {
|
||||||
|
return apiPost(`/api/games/${gameId}/tags/remove`, { tagIds });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener estadísticas de etiquetas
|
||||||
|
export const getTagStats = async () => {
|
||||||
|
return apiGet<{
|
||||||
|
totalTags: number;
|
||||||
|
totalTaggedGames: number;
|
||||||
|
averageTagsPerGame: number;
|
||||||
|
mostUsedTag: string;
|
||||||
|
leastUsedTag: string;
|
||||||
|
}>('/api/tags/stats');
|
||||||
|
};
|
||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -1,38 +0,0 @@
|
|||||||
import { Game } from '../../types/game';
|
|
||||||
|
|
||||||
interface GameCardProps {
|
|
||||||
game: Game;
|
|
||||||
onEdit?: (game: Game) => void;
|
|
||||||
onDelete?: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GameCard({ game, onEdit, onDelete }: GameCardProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div className="rounded border border-gray-300 p-4 shadow-sm hover:shadow-md">
|
|
||||||
<h3 className="mb-2 text-lg font-semibold">{game.title}</h3>
|
|
||||||
<p className="mb-2 text-sm text-gray-600">{game.slug}</p>
|
|
||||||
{game.description && <p className="mb-3 text-sm text-gray-700">{game.description}</p>}
|
|
||||||
<p className="mb-4 text-xs text-gray-500">
|
|
||||||
Added: {new Date(game.createdAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{onEdit && (
|
|
||||||
<button
|
|
||||||
onClick={() => onEdit(game)}
|
|
||||||
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onDelete && (
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(game.id)}
|
|
||||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { Game, CreateGameInput } from '../../types/game';
|
|
||||||
|
|
||||||
const gameFormSchema = z.object({
|
|
||||||
title: z.string().min(1, 'Title is required'),
|
|
||||||
platformId: z.string().min(1, 'Platform is required'),
|
|
||||||
description: z.string().optional().nullable(),
|
|
||||||
priceCents: z.number().optional(),
|
|
||||||
currency: z.string().optional().default('USD'),
|
|
||||||
store: z.string().optional(),
|
|
||||||
date: z.string().optional(),
|
|
||||||
condition: z.enum(['Loose', 'CIB', 'New']).optional(),
|
|
||||||
notes: z.string().optional().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type GameFormData = z.infer<typeof gameFormSchema>;
|
|
||||||
|
|
||||||
interface GameFormProps {
|
|
||||||
initialData?: Game;
|
|
||||||
onSubmit: (data: CreateGameInput | Game) => void | Promise<void>;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GameForm({
|
|
||||||
initialData,
|
|
||||||
onSubmit,
|
|
||||||
isLoading = false,
|
|
||||||
}: GameFormProps): JSX.Element {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<GameFormData>({
|
|
||||||
resolver: zodResolver(gameFormSchema),
|
|
||||||
defaultValues: initialData
|
|
||||||
? {
|
|
||||||
title: initialData.title,
|
|
||||||
description: initialData.description,
|
|
||||||
priceCents: undefined,
|
|
||||||
currency: 'USD',
|
|
||||||
store: undefined,
|
|
||||||
date: undefined,
|
|
||||||
condition: undefined,
|
|
||||||
notes: undefined,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = (data: GameFormData) => {
|
|
||||||
onSubmit(data as CreateGameInput);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="title" className="block text-sm font-medium">
|
|
||||||
Title *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('title')}
|
|
||||||
id="title"
|
|
||||||
type="text"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
{errors.title && <p className="text-red-600 text-sm">{errors.title.message}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="platformId" className="block text-sm font-medium">
|
|
||||||
Platform *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('platformId')}
|
|
||||||
id="platformId"
|
|
||||||
type="text"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
{errors.platformId && <p className="text-red-600 text-sm">{errors.platformId.message}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="condition" className="block text-sm font-medium">
|
|
||||||
Condition
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
{...register('condition')}
|
|
||||||
id="condition"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<option value="">Select condition</option>
|
|
||||||
<option value="Loose">Loose</option>
|
|
||||||
<option value="CIB">CIB</option>
|
|
||||||
<option value="New">New</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="description" className="block text-sm font-medium">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
{...register('description')}
|
|
||||||
id="description"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="priceCents" className="block text-sm font-medium">
|
|
||||||
Price (cents)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('priceCents', { valueAsNumber: true })}
|
|
||||||
id="priceCents"
|
|
||||||
type="number"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="currency" className="block text-sm font-medium">
|
|
||||||
Currency
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('currency')}
|
|
||||||
id="currency"
|
|
||||||
type="text"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
defaultValue="USD"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="store" className="block text-sm font-medium">
|
|
||||||
Store
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('store')}
|
|
||||||
id="store"
|
|
||||||
type="text"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="date" className="block text-sm font-medium">
|
|
||||||
Purchase Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('date')}
|
|
||||||
id="date"
|
|
||||||
type="date"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="notes" className="block text-sm font-medium">
|
|
||||||
Notes
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
{...register('notes')}
|
|
||||||
id="notes"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-400"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Saving...' : 'Save Game'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
88
frontend/src/components/layout/Header.tsx
Normal file
88
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Link, useLocation } from '@tanstack/react-router';
|
||||||
|
import { Menu, Search, Settings, Gamepad2, Home, FileText } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onMenuToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/', icon: Home },
|
||||||
|
{ name: 'Juegos', href: '/games', icon: Gamepad2 },
|
||||||
|
{ name: 'Importar ROMs', href: '/import', icon: FileText },
|
||||||
|
{ name: 'Configuración', href: '/settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Header({ onMenuToggle }: HeaderProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="container flex h-14 items-center">
|
||||||
|
{/* Botón de menú para móviles */}
|
||||||
|
<Button variant="ghost" size="sm" className="mr-4 md:hidden" onClick={onMenuToggle}>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Toggle menu</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Logo y título */}
|
||||||
|
<div className="mr-4 hidden md:flex">
|
||||||
|
<Link to="/" className="mr-6 flex items-center space-x-2">
|
||||||
|
<Gamepad2 className="h-6 w-6" />
|
||||||
|
<span className="hidden font-bold sm:inline-block">Quasar</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navegación principal */}
|
||||||
|
<nav className="flex items-center space-x-6 text-sm font-medium">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = location.pathname === item.href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
'transition-colors hover:text-foreground/80',
|
||||||
|
isActive ? 'text-foreground' : 'text-foreground/60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center space-x-1">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Barra de búsqueda */}
|
||||||
|
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||||
|
<div className="w-full flex-1 md:w-auto md:flex-none">
|
||||||
|
{/* Placeholder para barra de búsqueda */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="relative h-8 w-full justify-start rounded-[0.5rem] bg-background text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-64"
|
||||||
|
>
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
Buscar juegos...
|
||||||
|
<kbd className="pointer-events-none absolute right-[0.3rem] top-[0.3rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usuario y acciones */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Configuración</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/layout/Layout.tsx
Normal file
38
frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Outlet } from '@tanstack/react-router';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
sidebarOpen?: boolean;
|
||||||
|
onSidebarChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ sidebarOpen = false, onSidebarChange }: LayoutProps) {
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(sidebarOpen);
|
||||||
|
|
||||||
|
const handleSidebarToggle = (open: boolean) => {
|
||||||
|
setIsSidebarOpen(open);
|
||||||
|
onSidebarChange?.(open);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar isOpen={isSidebarOpen} onClose={() => handleSidebarToggle(false)} />
|
||||||
|
|
||||||
|
{/* Contenido principal */}
|
||||||
|
<div className="md:pl-64">
|
||||||
|
{/* Header */}
|
||||||
|
<Header onMenuToggle={() => handleSidebarToggle(true)} />
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="container mx-auto p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Navbar(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<nav style={{ padding: 12 }}>
|
|
||||||
<a href="/roms" style={{ marginRight: 12 }}>
|
|
||||||
ROMs
|
|
||||||
</a>
|
|
||||||
<a href="/games">Games</a>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,119 @@
|
|||||||
import React from 'react';
|
import { Link, useLocation } from '@tanstack/react-router';
|
||||||
|
import { Home, Gamepad2, FileText, Settings, Database, Tag, Download, Upload } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/', icon: Home, count: null },
|
||||||
|
{ name: 'Juegos', href: '/games', icon: Gamepad2, count: null },
|
||||||
|
{
|
||||||
|
name: 'Plataformas',
|
||||||
|
href: '/platforms',
|
||||||
|
icon: Database,
|
||||||
|
count: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Etiquetas',
|
||||||
|
href: '/tags',
|
||||||
|
icon: Tag,
|
||||||
|
count: null,
|
||||||
|
},
|
||||||
|
{ name: 'Importar ROMs', href: '/import', icon: Download, count: null },
|
||||||
|
{ name: 'Exportar', href: '/export', icon: Upload, count: null },
|
||||||
|
{ name: 'Configuración', href: '/settings', icon: Settings, count: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
export default function Sidebar(): JSX.Element {
|
|
||||||
return (
|
return (
|
||||||
<aside style={{ padding: 12 }}>
|
<>
|
||||||
<div>Sidebar (placeholder)</div>
|
{/* Overlay para móviles */}
|
||||||
</aside>
|
{isOpen && <div className="fixed inset-0 z-40 bg-black/50 md:hidden" onClick={onClose} />}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed left-0 top-0 z-50 h-full w-64 transform border-r bg-background transition-transform duration-300 ease-in-out md:relative md:translate-x-0',
|
||||||
|
isOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Header del sidebar */}
|
||||||
|
<div className="flex h-14 items-center border-b px-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Gamepad2 className="h-6 w-6" />
|
||||||
|
<span className="font-bold">Quasar</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="ml-auto md:hidden" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenido del sidebar */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<nav className="p-4 space-y-1">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = location.pathname === item.href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||||
|
isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
{item.count !== null && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{item.count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Sección adicional con información */}
|
||||||
|
<div className="mt-8 p-4 border-t">
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
|
||||||
|
Información
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 text-xs text-muted-foreground">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Versión</span>
|
||||||
|
<span>1.0.0</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Última actualización</span>
|
||||||
|
<span>2024-01-15</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Footer del sidebar */}
|
||||||
|
<div className="flex h-14 items-center border-t px-4">
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-muted-foreground">
|
||||||
|
<span>© 2024 Quasar</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useEnrichMetadata } from '../../hooks/useRoms';
|
|
||||||
import { EnrichedGame } from '../../types/rom';
|
|
||||||
|
|
||||||
interface MetadataSearchDialogProps {
|
|
||||||
romId: string;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSelect: (game: EnrichedGame) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceLabels: Record<string, string> = {
|
|
||||||
igdb: 'IGDB',
|
|
||||||
rawg: 'RAWG',
|
|
||||||
thegamesdb: 'TGDB',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MetadataSearchDialog({
|
|
||||||
romId,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
onSelect,
|
|
||||||
}: MetadataSearchDialogProps): JSX.Element | null {
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [results, setResults] = useState<EnrichedGame[]>([]);
|
|
||||||
const [searched, setSearched] = useState(false);
|
|
||||||
const enrichMutation = useEnrichMetadata();
|
|
||||||
|
|
||||||
const handleSearch = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSearched(false);
|
|
||||||
|
|
||||||
if (!query.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const searchResults = await enrichMutation.mutateAsync(query);
|
|
||||||
setResults(searchResults);
|
|
||||||
setSearched(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Search failed:', err);
|
|
||||||
setResults([]);
|
|
||||||
setSearched(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (game: EnrichedGame) => {
|
|
||||||
onSelect(game);
|
|
||||||
onOpenChange(false);
|
|
||||||
setQuery('');
|
|
||||||
setResults([]);
|
|
||||||
setSearched(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-auto">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-lg font-semibold">Search Metadata</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
aria-label="close"
|
|
||||||
className="text-gray-500 hover:text-gray-700 text-xl"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="mb-6">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search game title"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
disabled={enrichMutation.isPending}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={enrichMutation.isPending}
|
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
|
|
||||||
>
|
|
||||||
{enrichMutation.isPending ? 'Searching...' : 'Search'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{searched && results.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-gray-500">No results found for "{query}"</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{results.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{results.map((game, index) => (
|
|
||||||
<div
|
|
||||||
key={`${game.source}-${game.externalIds[game.source as keyof typeof game.externalIds]}`}
|
|
||||||
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition"
|
|
||||||
>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{game.coverUrl && (
|
|
||||||
<img
|
|
||||||
src={game.coverUrl}
|
|
||||||
alt={game.title}
|
|
||||||
className="w-16 h-24 object-cover rounded"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<h3 className="font-semibold text-lg">{game.title}</h3>
|
|
||||||
<span className="bg-gray-200 text-gray-800 text-xs px-2 py-1 rounded">
|
|
||||||
{sourceLabels[game.source]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{game.releaseDate && (
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Released: {new Date(game.releaseDate).getFullYear()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(game.genres || game.platforms) && (
|
|
||||||
<div className="text-sm text-gray-600 mt-1">
|
|
||||||
{game.genres && <p>Genres: {game.genres.join(', ')}</p>}
|
|
||||||
{game.platforms && <p>Platforms: {game.platforms.join(', ')}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{game.description && (
|
|
||||||
<p className="text-sm text-gray-700 mt-2 line-clamp-2">{game.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleSelect(game)}
|
|
||||||
className="bg-green-600 text-white px-3 py-2 rounded-md hover:bg-green-700 font-medium h-fit whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Select
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searched && results.length === 0 && (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="bg-gray-200 text-gray-800 px-4 py-2 rounded-md hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { RomFile } from '../../types/rom';
|
|
||||||
|
|
||||||
interface RomCardProps {
|
|
||||||
rom: RomFile;
|
|
||||||
onLinkMetadata?: (romId: string) => void;
|
|
||||||
onDelete?: (romId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RomCard({ rom, onLinkMetadata, onDelete }: RomCardProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div className="border border-gray-300 rounded-lg p-4 hover:shadow-md transition">
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<h3 className="font-semibold text-lg flex-1 break-all">{rom.filename}</h3>
|
|
||||||
<span
|
|
||||||
className={`text-xs px-2 py-1 rounded font-medium whitespace-nowrap ml-2 ${
|
|
||||||
rom.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{rom.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 text-sm text-gray-600 mb-3">
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">Size:</span> {formatBytes(rom.size)}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">Checksum:</span> {rom.checksum.substring(0, 8)}...
|
|
||||||
</p>
|
|
||||||
{rom.game && (
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">Game:</span> {rom.game.title}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{!rom.game && onLinkMetadata && (
|
|
||||||
<button
|
|
||||||
onClick={() => onLinkMetadata(rom.id)}
|
|
||||||
className="flex-1 bg-blue-600 text-white px-3 py-2 text-sm rounded-md hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Link Metadata
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onDelete && (
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(rom.id)}
|
|
||||||
className="flex-1 bg-red-600 text-white px-3 py-2 text-sm rounded-md hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useScanDirectory } from '../../hooks/useRoms';
|
|
||||||
|
|
||||||
interface ScanDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScanDialog({ isOpen, onOpenChange }: ScanDialogProps): JSX.Element | null {
|
|
||||||
const [path, setPath] = useState('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const scanMutation = useScanDirectory();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setSuccess(false);
|
|
||||||
|
|
||||||
if (!path.trim()) {
|
|
||||||
setError('Please enter a directory path');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scanMutation.mutateAsync(path);
|
|
||||||
setSuccess(true);
|
|
||||||
setPath('');
|
|
||||||
setTimeout(() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
setSuccess(false);
|
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to scan directory');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-md w-full">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-lg font-semibold">Scan ROMs Directory</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="text-gray-500 hover:text-gray-700 text-xl"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="path" className="block text-sm font-medium mb-1">
|
|
||||||
Directory Path
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="path"
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter ROM directory path"
|
|
||||||
value={path}
|
|
||||||
onChange={(e) => setPath(e.target.value)}
|
|
||||||
disabled={scanMutation.isPending}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
|
|
||||||
<strong>Error:</strong> {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm">
|
|
||||||
Scan completed!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={scanMutation.isPending}
|
|
||||||
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
|
|
||||||
>
|
|
||||||
{scanMutation.isPending ? 'Scanning...' : 'Scan Directory'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="flex-1 bg-gray-200 text-gray-800 px-4 py-2 rounded-md hover:bg-gray-300 font-medium"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
69
frontend/src/components/ui/alert.tsx
Normal file
69
frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { AlertTriangle, CheckCircle, Info, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:-top-1 [&>svg]:-left-1 [&>svg]:h-4 [&>svg]:w-4 [&>svg]:text-foreground',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground border',
|
||||||
|
destructive:
|
||||||
|
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||||
|
success: 'border-green-500/50 text-green-700 dark:border-green-500 [&>svg]:text-green-700',
|
||||||
|
warning:
|
||||||
|
'border-yellow-500/50 text-yellow-700 dark:border-yellow-500 [&>svg]:text-yellow-700',
|
||||||
|
info: 'border-blue-500/50 text-blue-700 dark:border-blue-500 [&>svg]:text-blue-700',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||||
|
));
|
||||||
|
Alert.displayName = 'Alert';
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
AlertTitle.displayName = 'AlertTitle';
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDescription.displayName = 'AlertDescription';
|
||||||
|
|
||||||
|
const AlertIcon = ({ variant }: { variant?: VariantProps<typeof alertVariants>['variant'] }) => {
|
||||||
|
switch (variant) {
|
||||||
|
case 'destructive':
|
||||||
|
return <XCircle className="h-4 w-4" />;
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircle className="h-4 w-4" />;
|
||||||
|
case 'warning':
|
||||||
|
return <AlertTriangle className="h-4 w-4" />;
|
||||||
|
case 'info':
|
||||||
|
return <Info className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <Info className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertIcon };
|
||||||
31
frontend/src/components/ui/badge.tsx
Normal file
31
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
47
frontend/src/components/ui/button.tsx
Normal file
47
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
55
frontend/src/components/ui/card.tsx
Normal file
55
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
26
frontend/src/components/ui/checkbox.tsx
Normal file
26
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
));
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
23
frontend/src/components/ui/input.tsx
Normal file
23
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
19
frontend/src/components/ui/label.tsx
Normal file
19
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
44
frontend/src/components/ui/scroll-area.tsx
Normal file
44
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative overflow-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'flex touch-none select-none transition-colors',
|
||||||
|
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||||
|
orientation === 'horizontal' && 'h-2.5 w-full border-t border-t-transparent p-[1px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
151
frontend/src/components/ui/select.tsx
Normal file
151
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
91
frontend/src/components/ui/table.tsx
Normal file
91
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Table.displayName = 'Table';
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = 'TableHeader';
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableBody.displayName = 'TableBody';
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableFooter.displayName = 'TableFooter';
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableRow.displayName = 'TableRow';
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = 'TableHead';
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = 'TableCell';
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableCaption.displayName = 'TableCaption';
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||||
23
frontend/src/components/ui/textarea.tsx
Normal file
23
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
98
frontend/src/form/config.tsx
Normal file
98
frontend/src/form/config.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Esquemas de validación comunes
|
||||||
|
export const commonSchemas = {
|
||||||
|
// Validación de strings
|
||||||
|
string: (min = 1, max = 255) =>
|
||||||
|
z.string().min(min, 'Este campo es requerido').max(max, 'Máximo 255 caracteres'),
|
||||||
|
|
||||||
|
// Validación de emails
|
||||||
|
email: z.string().email('Por favor ingresa un email válido').min(1, 'Este campo es requerido'),
|
||||||
|
|
||||||
|
// Validación de números
|
||||||
|
number: (min = 0, max = Number.MAX_SAFE_INTEGER) =>
|
||||||
|
z.number().min(min, 'Debe ser un número positivo').max(max, 'Número demasiado grande'),
|
||||||
|
|
||||||
|
// Validación de fechas
|
||||||
|
date: z
|
||||||
|
.date()
|
||||||
|
.refine(
|
||||||
|
(date) => date instanceof Date && !isNaN(date.getTime()),
|
||||||
|
'Por favor ingresa una fecha válida'
|
||||||
|
),
|
||||||
|
|
||||||
|
// Validación de booleanos
|
||||||
|
boolean: z.boolean(),
|
||||||
|
|
||||||
|
// Validación de arrays
|
||||||
|
array: (min = 1, max = 100) =>
|
||||||
|
z
|
||||||
|
.array(z.unknown())
|
||||||
|
.min(min, 'Al menos un elemento requerido')
|
||||||
|
.max(max, 'Máximo 100 elementos'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Función para manejar errores de formulario
|
||||||
|
export function getFieldError(
|
||||||
|
fieldErrors: Record<string, string[]>,
|
||||||
|
fieldName: string
|
||||||
|
): string | undefined {
|
||||||
|
const errors = fieldErrors[fieldName];
|
||||||
|
return errors?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para manejar errores de formulario global
|
||||||
|
export function getGlobalError(fieldErrors: Record<string, string[]>): string | undefined {
|
||||||
|
const globalErrors = Object.values(fieldErrors).flat();
|
||||||
|
return globalErrors?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esquemas específicos para la aplicación
|
||||||
|
export const appSchemas = {
|
||||||
|
// Esquema para crear/editar juegos
|
||||||
|
gameSchema: z.object({
|
||||||
|
title: commonSchemas.string(1, 100),
|
||||||
|
description: commonSchemas.string(0, 2000),
|
||||||
|
platformId: commonSchemas.number(1, Number.MAX_SAFE_INTEGER),
|
||||||
|
releaseYear: commonSchemas.number(1900, new Date().getFullYear() + 5),
|
||||||
|
rating: z.number().min(0).max(10).optional(),
|
||||||
|
tags: commonSchemas.array(0, 50).optional(),
|
||||||
|
romFile: z.string().optional(),
|
||||||
|
metadataSource: z.enum(['igdb', 'rawg', 'thegamesdb', 'manual']).optional(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Esquema para importar ROMs
|
||||||
|
importRomSchema: z.object({
|
||||||
|
romPath: commonSchemas.string(1, 500),
|
||||||
|
platformId: commonSchemas.number(1, Number.MAX_SAFE_INTEGER),
|
||||||
|
autoScan: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Esquema para configuración
|
||||||
|
settingsSchema: z.object({
|
||||||
|
igdbApiKey: commonSchemas.string(0, 100).optional(),
|
||||||
|
rawgApiKey: commonSchemas.string(0, 100).optional(),
|
||||||
|
thegamesdbApiKey: commonSchemas.string(0, 100).optional(),
|
||||||
|
defaultRomDirectory: commonSchemas.string(0, 500).optional(),
|
||||||
|
autoImportEnabled: z.boolean().default(true),
|
||||||
|
metadataSourcePriority: z
|
||||||
|
.array(z.enum(['igdb', 'rawg', 'thegamesdb']))
|
||||||
|
.default(['igdb', 'rawg', 'thegamesdb']),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Esquema para plataformas
|
||||||
|
platformSchema: z.object({
|
||||||
|
name: commonSchemas.string(1, 50),
|
||||||
|
description: commonSchemas.string(0, 500),
|
||||||
|
manufacturer: commonSchemas.string(0, 100),
|
||||||
|
releaseYear: commonSchemas.number(1900, new Date().getFullYear() + 5),
|
||||||
|
romExtension: z.string().optional(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Esquema para etiquetas
|
||||||
|
tagSchema: z.object({
|
||||||
|
name: commonSchemas.string(1, 30),
|
||||||
|
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color inválido. Use formato #RRGGBB'),
|
||||||
|
description: commonSchemas.string(0, 200),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { api } from '../lib/api';
|
|
||||||
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
|
||||||
|
|
||||||
const GAMES_QUERY_KEY = ['games'];
|
|
||||||
|
|
||||||
export function useGames() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: GAMES_QUERY_KEY,
|
|
||||||
queryFn: () => api.games.list(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateGame() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateGameInput) => api.games.create(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateGame() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, data }: { id: string; data: UpdateGameInput }) => api.games.update(id, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteGame() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => api.games.delete(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { api } from '../lib/api';
|
|
||||||
import { RomFile, EnrichedGame } from '../types/rom';
|
|
||||||
|
|
||||||
const ROMS_QUERY_KEY = ['roms'];
|
|
||||||
const GAMES_QUERY_KEY = ['games'];
|
|
||||||
|
|
||||||
export function useRoms() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ROMS_QUERY_KEY,
|
|
||||||
queryFn: () => api.roms.list(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScanDirectory() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (dir: string) => api.import.scan(dir),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
// Invalidar cache de ROMs después de scan
|
|
||||||
queryClient.invalidateQueries({ queryKey: ROMS_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEnrichMetadata() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (query: string) => api.metadata.search(query),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLinkGameToRom() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ romId, gameId }: { romId: string; gameId: string }) =>
|
|
||||||
api.roms.linkGame(romId, gameId),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Invalidar ambos caches después de vincular
|
|
||||||
queryClient.invalidateQueries({ queryKey: ROMS_QUERY_KEY });
|
|
||||||
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteRom() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => api.roms.delete(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ROMS_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
68
frontend/src/index.css
Normal file
68
frontend/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
|
||||||
import { RomFile, EnrichedGame, ScanResult } from '../types/rom';
|
|
||||||
|
|
||||||
const API_BASE = '/api';
|
|
||||||
|
|
||||||
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options?.headers,
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
games: {
|
|
||||||
list: () => request<Game[]>('/games'),
|
|
||||||
create: (data: CreateGameInput) =>
|
|
||||||
request<Game>('/games', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
update: (id: string, data: UpdateGameInput) =>
|
|
||||||
request<Game>(`/games/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
delete: (id: string) =>
|
|
||||||
request<void>(`/games/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
roms: {
|
|
||||||
list: () => request<RomFile[]>('/roms'),
|
|
||||||
getById: (id: string) => request<RomFile>(`/roms/${id}`),
|
|
||||||
linkGame: (romId: string, gameId: string) =>
|
|
||||||
request<RomFile>(`/roms/${romId}/game`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ gameId }),
|
|
||||||
}),
|
|
||||||
delete: (id: string) => request<void>(`/roms/${id}`, { method: 'DELETE' }),
|
|
||||||
},
|
|
||||||
|
|
||||||
metadata: {
|
|
||||||
search: (query: string) =>
|
|
||||||
request<EnrichedGame[]>('/metadata/search?q=' + encodeURIComponent(query)),
|
|
||||||
},
|
|
||||||
|
|
||||||
import: {
|
|
||||||
scan: (dir: string) =>
|
|
||||||
request<ScanResult>('/import/scan', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ dir }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
export const queryClient = new QueryClient();
|
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,32 +1,10 @@
|
|||||||
import React from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import './styles/globals.css';
|
||||||
import { queryClient } from './lib/queryClient';
|
import App from './App.tsx';
|
||||||
import App from './App';
|
|
||||||
import './styles.css';
|
|
||||||
|
|
||||||
const rootEl = document.getElementById('root');
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
if (rootEl) {
|
<App />
|
||||||
createRoot(rootEl).render(
|
</StrictMode>
|
||||||
<React.StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { queryClient } from './lib/queryClient';
|
|
||||||
import App from './App';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
);
|
||||||
|
|||||||
232
frontend/src/pages/DashboardPage.tsx
Normal file
232
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Gamepad2,
|
||||||
|
Database,
|
||||||
|
Tag,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// Datos simulados para el dashboard
|
||||||
|
const stats = {
|
||||||
|
totalGames: 156,
|
||||||
|
gamesWithRom: 128,
|
||||||
|
gamesWithoutRom: 28,
|
||||||
|
gamesWithMetadata: 142,
|
||||||
|
gamesWithoutMetadata: 14,
|
||||||
|
totalPlatforms: 12,
|
||||||
|
totalTags: 45,
|
||||||
|
averageRating: 7.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recentGames = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'The Legend of Zelda: Breath of the Wild',
|
||||||
|
platform: 'Nintendo Switch',
|
||||||
|
rating: 9.5,
|
||||||
|
addedDate: '2024-01-10',
|
||||||
|
},
|
||||||
|
{ id: 2, title: 'God of War', platform: 'PlayStation 4', rating: 9.2, addedDate: '2024-01-09' },
|
||||||
|
{ id: 3, title: 'Cyberpunk 2077', platform: 'PC', rating: 8.7, addedDate: '2024-01-08' },
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'Super Mario Odyssey',
|
||||||
|
platform: 'Nintendo Switch',
|
||||||
|
rating: 9.0,
|
||||||
|
addedDate: '2024-01-07',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Red Dead Redemption 2',
|
||||||
|
platform: 'PlayStation 4',
|
||||||
|
rating: 9.3,
|
||||||
|
addedDate: '2024-01-06',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pendingTasks = [
|
||||||
|
{ id: 1, title: 'Enriquecer metadata para 14 juegos', priority: 'high', type: 'metadata' },
|
||||||
|
{ id: 2, title: 'Importar 28 ROMs detectadas', priority: 'medium', type: 'import' },
|
||||||
|
{ id: 3, title: 'Actualizar información de 5 juegos', priority: 'low', type: 'update' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Encabezado */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">Resumen general de tu colección de videojuegos</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Estadísticas principales */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total de Juegos</CardTitle>
|
||||||
|
<Gamepad2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.totalGames}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+12% desde el mes pasado</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Juegos con ROM</CardTitle>
|
||||||
|
<Download className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.gamesWithRom}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">de {stats.totalGames} juegos</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Juegos con Metadata</CardTitle>
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.gamesWithMetadata}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{stats.gamesWithoutMetadata} pendientes</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Plataformas</CardTitle>
|
||||||
|
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.totalPlatforms}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{stats.totalTags} etiquetas</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* Tareas pendientes */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tareas Pendientes</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Acciones necesarias para mantener tu colección actualizada
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{pendingTasks.map((task) => (
|
||||||
|
<div key={task.id} className="flex items-center space-x-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{task.priority === 'high' && <AlertCircle className="h-5 w-5 text-red-500" />}
|
||||||
|
{task.priority === 'medium' && <Clock className="h-5 w-5 text-yellow-500" />}
|
||||||
|
{task.priority === 'low' && <TrendingUp className="h-5 w-5 text-green-500" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">{task.title}</p>
|
||||||
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{task.type}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
task.priority === 'high'
|
||||||
|
? 'destructive'
|
||||||
|
: task.priority === 'medium'
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
Ver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<Button className="w-full" variant="outline">
|
||||||
|
Ver todas las tareas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Juegos recientes */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Juegos Recientes</CardTitle>
|
||||||
|
<CardDescription>Últimos juegos añadidos a tu colección</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentGames.map((game) => (
|
||||||
|
<div key={game.id} className="flex items-center space-x-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="h-10 w-10 bg-gray-200 rounded-md flex items-center justify-center">
|
||||||
|
<Gamepad2 className="h-6 w-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">{game.title}</p>
|
||||||
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{game.platform}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-sm text-gray-500">★</span>
|
||||||
|
<span className="text-sm font-medium ml-1">{game.rating}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">{game.addedDate}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<Button className="w-full" variant="outline">
|
||||||
|
Ver todos los juegos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Acciones rápidas */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Acciones Rápidas</CardTitle>
|
||||||
|
<CardDescription>Acciones comunes para gestionar tu colección</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Button className="h-auto p-4 flex flex-col items-center space-y-2" variant="outline">
|
||||||
|
<Download className="h-6 w-6" />
|
||||||
|
<span>Importar ROMs</span>
|
||||||
|
</Button>
|
||||||
|
<Button className="h-auto p-4 flex flex-col items-center space-y-2" variant="outline">
|
||||||
|
<Upload className="h-6 w-6" />
|
||||||
|
<span>Exportar Datos</span>
|
||||||
|
</Button>
|
||||||
|
<Button className="h-auto p-4 flex flex-col items-center space-y-2" variant="outline">
|
||||||
|
<Database className="h-6 w-6" />
|
||||||
|
<span>Gestionar Plataformas</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
350
frontend/src/pages/GameDetailPage.tsx
Normal file
350
frontend/src/pages/GameDetailPage.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from '@tanstack/react-router';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Download,
|
||||||
|
Star,
|
||||||
|
Calendar,
|
||||||
|
HardDrive,
|
||||||
|
Tag,
|
||||||
|
ExternalLink,
|
||||||
|
Info,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Game } from '@/types';
|
||||||
|
|
||||||
|
// Datos simulados para el detalle del juego
|
||||||
|
const mockGame: Game = {
|
||||||
|
id: 1,
|
||||||
|
title: 'The Legend of Zelda: Breath of the Wild',
|
||||||
|
description:
|
||||||
|
'The Legend of Zelda: Breath of the Wild es un juego de acción-aventura desarrollado y publicado por Nintendo para la consola Nintendo Switch y la consola Wii U. Es el decimonoveno juego principal de la serie The Legend of Zelda y fue lanzado mundialmente en marzo de 2017. El juego presenta un mundo abierto con un diseño de física y química avanzado que permite a los jugadores resolver problemas de man creativas.',
|
||||||
|
platformId: 1,
|
||||||
|
platform: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Nintendo Switch',
|
||||||
|
description: 'Consola híbrida de Nintendo',
|
||||||
|
manufacturer: 'Nintendo',
|
||||||
|
releaseYear: 2017,
|
||||||
|
romExtension: 'nsp',
|
||||||
|
},
|
||||||
|
releaseYear: 2017,
|
||||||
|
rating: 9.5,
|
||||||
|
tags: [
|
||||||
|
{ id: 1, name: 'Aventura', color: '#3B82F6', description: 'Juegos de aventura' },
|
||||||
|
{ id: 2, name: 'Acción', color: '#EF4444', description: 'Juegos de acción' },
|
||||||
|
{ id: 3, name: 'Open World', color: '#10B981', description: 'Mundo abierto' },
|
||||||
|
],
|
||||||
|
romFile: {
|
||||||
|
id: 1,
|
||||||
|
filename: 'zelda_botw.nsp',
|
||||||
|
path: '/roms/nintendo-switch',
|
||||||
|
size: 14485760,
|
||||||
|
checksum: 'abc123def456',
|
||||||
|
gameId: 1,
|
||||||
|
},
|
||||||
|
metadataSource: 'manual',
|
||||||
|
igdbId: '12345',
|
||||||
|
rawgId: '67890',
|
||||||
|
thegamesdbId: '54321',
|
||||||
|
createdAt: new Date('2024-01-10'),
|
||||||
|
updatedAt: new Date('2024-01-15'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GameDetailPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { gameId } = useParams({ from: '/games/$gameId' });
|
||||||
|
const [game] = useState<Game>(mockGame);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
navigate({ to: `/games/${game.id}/edit` });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
// TODO: Implementar eliminación del juego
|
||||||
|
console.log('Eliminar juego:', game.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Encabezado con botón de retroceso */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" onClick={() => navigate({ to: '/games' })} className="gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{game.title}</h1>
|
||||||
|
<p className="text-muted-foreground">Detalles del juego</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Acciones */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleEdit} className="gap-2">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Descargar ROM
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="gap-2 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
{/* Información principal */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
{/* Descripción */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Info className="h-5 w-5" />
|
||||||
|
Descripción
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">{game.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Información técnica */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<HardDrive className="h-5 w-5" />
|
||||||
|
Información Técnica
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">Plataforma</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">{game.platform?.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">Año de lanzamiento</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{game.releaseYear || 'Desconocido'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">Tamaño del archivo</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{game.romFile ? formatFileSize(game.romFile.size) : 'No disponible'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">Checksum</Label>
|
||||||
|
<p className="text-sm font-mono text-muted-foreground">
|
||||||
|
{game.romFile ? game.romFile.checksum : 'No disponible'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Etiquetas */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Tag className="h-5 w-5" />
|
||||||
|
Etiquetas
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{game.tags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag.id}
|
||||||
|
variant="secondary"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
className="text-white"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{game.tags.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No hay etiquetas asignadas</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel lateral */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Estado del juego */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Estado</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Calificación</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||||
|
<span className="font-medium">{game.rating || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">ROM</span>
|
||||||
|
<Badge variant={game.romFile ? 'default' : 'secondary'}>
|
||||||
|
{game.romFile ? 'Disponible' : 'No disponible'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Metadata</span>
|
||||||
|
<Badge variant={game.metadataSource ? 'default' : 'secondary'}>
|
||||||
|
{game.metadataSource || 'Manual'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Fuentes externas */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Fuentes Externas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">IGDB</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{game.igdbId ? (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline">Conectado</Badge>
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">No conectado</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">RAWG</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{game.rawgId ? (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline">Conectado</Badge>
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">No conectado</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">TheGamesDB</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{game.thegamesdbId ? (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline">Conectado</Badge>
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">No conectado</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Fechas */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Información de Registro</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">Fecha de creación</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{game.createdAt.toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">Última actualización</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{game.updatedAt.toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Acciones adicionales */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Acciones Rápidas</CardTitle>
|
||||||
|
<CardDescription>Acciones adicionales para gestionar este juego</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Buscar Metadata
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Añadir Fecha de Compra
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
Gestionar Etiquetas
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Exportar Datos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Alerta de información */}
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Esta es una vista previa del juego. Algunas características pueden no estar completamente
|
||||||
|
implementadas en esta versión de demostración.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
435
frontend/src/pages/GamesPage.tsx
Normal file
435
frontend/src/pages/GamesPage.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Search, Filter, Plus, Eye, Edit, Trash2, Star } from 'lucide-react';
|
||||||
|
import { Game } from '@/types';
|
||||||
|
|
||||||
|
// Datos simulados para la lista de juegos
|
||||||
|
const mockGames: Game[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'The Legend of Zelda: Breath of the Wild',
|
||||||
|
description: 'Un juego de acción-aventura desarrollado por Nintendo',
|
||||||
|
platformId: 1,
|
||||||
|
platform: { id: 1, name: 'Nintendo Switch', description: 'Consola híbrida de Nintendo' },
|
||||||
|
releaseYear: 2017,
|
||||||
|
rating: 9.5,
|
||||||
|
tags: [{ id: 1, name: 'Aventura', color: '#3B82F6', description: 'Juegos de aventura' }],
|
||||||
|
romFile: {
|
||||||
|
id: 1,
|
||||||
|
filename: 'zelda_botw.nsp',
|
||||||
|
path: '/roms/nintendo-switch',
|
||||||
|
size: 14485760,
|
||||||
|
checksum: 'abc123',
|
||||||
|
},
|
||||||
|
metadataSource: 'manual',
|
||||||
|
createdAt: new Date('2024-01-10'),
|
||||||
|
updatedAt: new Date('2024-01-10'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'God of War',
|
||||||
|
description: 'Un juego de acción-aventura basado en la mitología nórdica',
|
||||||
|
platformId: 2,
|
||||||
|
platform: { id: 2, name: 'PlayStation 4', description: 'Consola de Sony' },
|
||||||
|
releaseYear: 2018,
|
||||||
|
rating: 9.2,
|
||||||
|
tags: [{ id: 2, name: 'Acción', color: '#EF4444', description: 'Juegos de acción' }],
|
||||||
|
romFile: {
|
||||||
|
id: 2,
|
||||||
|
filename: 'god_of_war.iso',
|
||||||
|
path: '/roms/ps4',
|
||||||
|
size: 45234120,
|
||||||
|
checksum: 'def456',
|
||||||
|
},
|
||||||
|
metadataSource: 'igdb',
|
||||||
|
createdAt: new Date('2024-01-09'),
|
||||||
|
updatedAt: new Date('2024-01-09'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Cyberpunk 2077',
|
||||||
|
description: 'Un RPG de acción en mundo abierto ambientado en Night City',
|
||||||
|
platformId: 3,
|
||||||
|
platform: { id: 3, name: 'PC', description: 'Plataforma de computadora personal' },
|
||||||
|
releaseYear: 2020,
|
||||||
|
rating: 8.7,
|
||||||
|
tags: [{ id: 3, name: 'RPG', color: '#10B981', description: 'Juegos de rol' }],
|
||||||
|
romFile: undefined,
|
||||||
|
metadataSource: 'rawg',
|
||||||
|
createdAt: new Date('2024-01-08'),
|
||||||
|
updatedAt: new Date('2024-01-08'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const platforms = [
|
||||||
|
{ id: 1, name: 'Nintendo Switch' },
|
||||||
|
{ id: 2, name: 'PlayStation 4' },
|
||||||
|
{ id: 3, name: 'PC' },
|
||||||
|
{ id: 4, name: 'Xbox One' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tags = [
|
||||||
|
{ id: 1, name: 'Aventura', color: '#3B82F6' },
|
||||||
|
{ id: 2, name: 'Acción', color: '#EF4444' },
|
||||||
|
{ id: 3, name: 'RPG', color: '#10B981' },
|
||||||
|
{ id: 4, name: 'Estrategia', color: '#F59E0B' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GamesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedPlatform, setSelectedPlatform] = useState<string>('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [sortBy, setSortBy] = useState<string>('title');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [games] = useState<Game[]>(mockGames);
|
||||||
|
|
||||||
|
// Filtrar y ordenar juegos
|
||||||
|
const filteredGames = games
|
||||||
|
.filter((game) => {
|
||||||
|
const matchesSearch =
|
||||||
|
game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
game.description?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesPlatform = !selectedPlatform || game.platformId.toString() === selectedPlatform;
|
||||||
|
const matchesTags =
|
||||||
|
selectedTags.length === 0 ||
|
||||||
|
selectedTags.some((tagId) => game.tags.some((tag) => tag.id.toString() === tagId));
|
||||||
|
|
||||||
|
return matchesSearch && matchesPlatform && matchesTags;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
let aValue: string | number = '';
|
||||||
|
let bValue: string | number = '';
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'title':
|
||||||
|
aValue = a.title?.toLowerCase() || '';
|
||||||
|
bValue = b.title?.toLowerCase() || '';
|
||||||
|
break;
|
||||||
|
case 'platform':
|
||||||
|
aValue = a.platform?.name?.toLowerCase() || '';
|
||||||
|
bValue = b.platform?.name?.toLowerCase() || '';
|
||||||
|
break;
|
||||||
|
case 'releaseYear':
|
||||||
|
aValue = a.releaseYear || 0;
|
||||||
|
bValue = b.releaseYear || 0;
|
||||||
|
break;
|
||||||
|
case 'rating':
|
||||||
|
aValue = a.rating || 0;
|
||||||
|
bValue = b.rating || 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
aValue = a.title?.toLowerCase() || '';
|
||||||
|
bValue = b.title?.toLowerCase() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortOrder === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
if (sortBy === field) {
|
||||||
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortBy(field);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagToggle = (tagId: string) => {
|
||||||
|
setSelectedTags((prev) =>
|
||||||
|
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchTerm('');
|
||||||
|
setSelectedPlatform('');
|
||||||
|
setSelectedTags([]);
|
||||||
|
setSortBy('title');
|
||||||
|
setSortOrder('asc');
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGameClick = (gameId: number) => {
|
||||||
|
navigate({ to: `/games/${gameId}` });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Encabezado */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Juegos</h1>
|
||||||
|
<p className="text-muted-foreground">Gestiona tu colección de videojuegos</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => navigate({ to: '/games/create' })}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo Juego
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtros */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5" />
|
||||||
|
Filtros y Búsqueda
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* Búsqueda */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="search">Buscar</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="search"
|
||||||
|
placeholder="Buscar juegos..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plataforma */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="platform">Plataforma</Label>
|
||||||
|
<Select value={selectedPlatform} onValueChange={setSelectedPlatform}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Todas las plataformas" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Todas las plataformas</SelectItem>
|
||||||
|
{platforms.map((platform) => (
|
||||||
|
<SelectItem key={platform.id} value={platform.id.toString()}>
|
||||||
|
{platform.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ordenar por */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sort">Ordenar por</Label>
|
||||||
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="title">Título</SelectItem>
|
||||||
|
<SelectItem value="releaseYear">Año de lanzamiento</SelectItem>
|
||||||
|
<SelectItem value="rating">Calificación</SelectItem>
|
||||||
|
<SelectItem value="platform">Plataforma</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Acciones */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label> </Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={clearFilters}>
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtros de etiquetas */}
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Label>Etiquetas</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag.id}
|
||||||
|
variant={selectedTags.includes(tag.id.toString()) ? 'default' : 'outline'}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={
|
||||||
|
selectedTags.includes(tag.id.toString()) ? { backgroundColor: tag.color } : {}
|
||||||
|
}
|
||||||
|
onClick={() => handleTagToggle(tag.id.toString())}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tabla de juegos */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lista de Juegos ({filteredGames.length})</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{filteredGames.length === 0 ? 'No se encontraron juegos' : 'Juegos en tu colección'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleSort('title')}
|
||||||
|
>
|
||||||
|
Título {sortBy === 'title' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Plataforma</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleSort('releaseYear')}
|
||||||
|
>
|
||||||
|
Año {sortBy === 'releaseYear' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleSort('rating')}
|
||||||
|
>
|
||||||
|
Calificación {sortBy === 'rating' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Etiquetas</TableHead>
|
||||||
|
<TableHead>ROM</TableHead>
|
||||||
|
<TableHead className="w-[100px]">Acciones</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredGames.map((game) => (
|
||||||
|
<TableRow
|
||||||
|
key={game.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleGameClick(game.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">{game.title}</TableCell>
|
||||||
|
<TableCell>{game.platform?.name}</TableCell>
|
||||||
|
<TableCell>{game.releaseYear || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{game.rating ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||||
|
<span>{game.rating}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{game.tags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag.id}
|
||||||
|
variant="secondary"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
className="text-white"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{game.romFile ? (
|
||||||
|
<Badge variant="default">✓</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">-</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate({ to: `/games/${game.id}` });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate({ to: `/games/${game.id}/edit` });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// TODO: Implementar eliminación
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paginación */}
|
||||||
|
<div className="flex items-center justify-between space-x-2 py-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Mostrando {filteredGames.length} juegos
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm">Página {currentPage}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(currentPage + 1)}
|
||||||
|
disabled={filteredGames.length < 10}
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/src/query/client.tsx
Normal file
36
frontend/src/query/client.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutos
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
// No reintentar si es un error de 404 o 401
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const status = (error as any).status;
|
||||||
|
if (status === 404 || status === 401) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reintentar hasta 3 veces para otros errores
|
||||||
|
return failureCount < 3;
|
||||||
|
},
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
// No reintentar si es un error de 404 o 401
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const status = (error as any).status;
|
||||||
|
if (status === 404 || status === 401) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reintentar hasta 2 veces para otros errores
|
||||||
|
return failureCount < 2;
|
||||||
|
},
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
99
frontend/src/router.tsx
Normal file
99
frontend/src/router.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
// Layout principal
|
||||||
|
const Layout = lazy(() => import('./components/layout/Layout'));
|
||||||
|
|
||||||
|
// Páginas
|
||||||
|
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||||
|
const GamesPage = lazy(() => import('./pages/GamesPage'));
|
||||||
|
const GameDetailPage = lazy(() => import('./pages/GameDetailPage'));
|
||||||
|
const CreateGamePage = lazy(() => import('./pages/CreateGamePage'));
|
||||||
|
const ImportRomPage = lazy(() => import('./pages/ImportRomPage'));
|
||||||
|
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
||||||
|
const PlatformsPage = lazy(() => import('./pages/PlatformsPage'));
|
||||||
|
const TagsPage = lazy(() => import('./pages/TagsPage'));
|
||||||
|
const ExportPage = lazy(() => import('./pages/ExportPage'));
|
||||||
|
|
||||||
|
// Ruta raíz
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: Layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rutas principales
|
||||||
|
const dashboardRoute = createRoute({
|
||||||
|
path: '/',
|
||||||
|
component: DashboardPage,
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gamesRoute = createRoute({
|
||||||
|
path: '/games',
|
||||||
|
component: GamesPage,
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gameDetailRoute = createRoute({
|
||||||
|
path: '/games/$gameId',
|
||||||
|
component: GameDetailPage,
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGameRoute = createRoute({
|
||||||
|
path: '/games/create',
|
||||||
|
component: CreateGamePage,
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
const importRoute = createRoute({
|
||||||
|
path: '/import',
|
||||||
|
component: ImportRomPage,
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsRoute = createRoute({
|
||||||
|
path: '/settings',
|
||||||
|
component: SettingsPage,
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
const platformsRoute = createRoute({
|
||||||
|
path: '/platforms',
|
||||||
|
component: PlatformsPage,
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagsRoute = createRoute({
|
||||||
|
path: '/tags',
|
||||||
|
component: TagsPage,
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportRoute = createRoute({
|
||||||
|
path: '/export',
|
||||||
|
component: ExportPage,
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ruta de not found
|
||||||
|
const notFoundRoute = createRoute({
|
||||||
|
path: '*',
|
||||||
|
component: () => <div>Página no encontrada</div>,
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crear router
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree: rootRoute.addChildren([
|
||||||
|
dashboardRoute,
|
||||||
|
gamesRoute,
|
||||||
|
gameDetailRoute,
|
||||||
|
createGameRoute,
|
||||||
|
importRoute,
|
||||||
|
settingsRoute,
|
||||||
|
platformsRoute,
|
||||||
|
tagsRoute,
|
||||||
|
exportRoute,
|
||||||
|
notFoundRoute,
|
||||||
|
]),
|
||||||
|
});
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useGames, useCreateGame, useUpdateGame, useDeleteGame } from '../hooks/useGames';
|
|
||||||
import GameForm from '../components/games/GameForm';
|
|
||||||
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
|
||||||
|
|
||||||
export default function Games(): JSX.Element {
|
|
||||||
const { data: games, isLoading, error } = useGames();
|
|
||||||
const createMutation = useCreateGame();
|
|
||||||
const updateMutation = useUpdateGame();
|
|
||||||
const deleteMutation = useDeleteGame();
|
|
||||||
|
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
||||||
const [selectedGame, setSelectedGame] = useState<Game | null>(null);
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleCreate = async (data: CreateGameInput | Game) => {
|
|
||||||
try {
|
|
||||||
await createMutation.mutateAsync(data as CreateGameInput);
|
|
||||||
setIsFormOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to create game:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (data: CreateGameInput | Game) => {
|
|
||||||
if (!selectedGame) return;
|
|
||||||
try {
|
|
||||||
await updateMutation.mutateAsync({
|
|
||||||
id: selectedGame.id,
|
|
||||||
data: data as UpdateGameInput,
|
|
||||||
});
|
|
||||||
setSelectedGame(null);
|
|
||||||
setIsFormOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update game:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteMutation.mutateAsync(id);
|
|
||||||
setDeleteConfirm(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete game:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenForm = (game?: Game) => {
|
|
||||||
if (game) {
|
|
||||||
setSelectedGame(game);
|
|
||||||
} else {
|
|
||||||
setSelectedGame(null);
|
|
||||||
}
|
|
||||||
setIsFormOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
|
||||||
setIsFormOpen(false);
|
|
||||||
setSelectedGame(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<h2 className="text-xl font-bold text-red-600">Error</h2>
|
|
||||||
<p>{error instanceof Error ? error.message : 'Failed to load games'}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-bold">Games</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => handleOpenForm()}
|
|
||||||
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:bg-gray-400"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Add Game
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isFormOpen && (
|
|
||||||
<div className="mb-6 rounded border border-gray-300 p-4">
|
|
||||||
<div className="mb-4 flex justify-between">
|
|
||||||
<h3 className="text-lg font-semibold">{selectedGame ? 'Edit Game' : 'Create Game'}</h3>
|
|
||||||
<button onClick={handleCloseForm} className="text-gray-600 hover:text-gray-900">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<GameForm
|
|
||||||
initialData={selectedGame || undefined}
|
|
||||||
onSubmit={selectedGame ? handleUpdate : handleCreate}
|
|
||||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && !games ? (
|
|
||||||
<p className="text-gray-600">Loading games...</p>
|
|
||||||
) : !games || games.length === 0 ? (
|
|
||||||
<p className="text-gray-600">No games found. Create one to get started!</p>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse border border-gray-300">
|
|
||||||
<thead className="bg-gray-100">
|
|
||||||
<tr>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Title</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Slug</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Created</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-center">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{games.map((game) => (
|
|
||||||
<tr key={game.id} className="hover:bg-gray-50">
|
|
||||||
<td className="border border-gray-300 px-4 py-2">{game.title}</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2">{game.slug}</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2">
|
|
||||||
{new Date(game.createdAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2 text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => handleOpenForm(game)}
|
|
||||||
className="mr-2 rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700"
|
|
||||||
disabled={updateMutation.isPending || deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
{deleteConfirm === game.id ? (
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(game.id)}
|
|
||||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(null)}
|
|
||||||
className="rounded bg-gray-600 px-3 py-1 text-sm text-white hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(game.id)}
|
|
||||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
|
||||||
disabled={updateMutation.isPending || deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Home(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Home</h2>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
useRoms,
|
|
||||||
useScanDirectory,
|
|
||||||
useEnrichMetadata,
|
|
||||||
useLinkGameToRom,
|
|
||||||
useDeleteRom,
|
|
||||||
} from '../hooks/useRoms';
|
|
||||||
import ScanDialog from '../components/roms/ScanDialog';
|
|
||||||
import MetadataSearchDialog from '../components/roms/MetadataSearchDialog';
|
|
||||||
import { EnrichedGame, RomFile } from '../types/rom';
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Roms(): JSX.Element {
|
|
||||||
const { data: roms, isLoading, error } = useRoms();
|
|
||||||
const scanMutation = useScanDirectory();
|
|
||||||
const enrichMutation = useEnrichMetadata();
|
|
||||||
const linkMutation = useLinkGameToRom();
|
|
||||||
const deleteMutation = useDeleteRom();
|
|
||||||
|
|
||||||
const [isScanDialogOpen, setIsScanDialogOpen] = useState(false);
|
|
||||||
const [isMetadataDialogOpen, setIsMetadataDialogOpen] = useState(false);
|
|
||||||
const [selectedRomId, setSelectedRomId] = useState<string | null>(null);
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleDeleteRom = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteMutation.mutateAsync(id);
|
|
||||||
setDeleteConfirm(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete ROM:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMetadataSelect = async (game: EnrichedGame) => {
|
|
||||||
if (!selectedRomId || !game.externalIds) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Find the first available external ID to link with
|
|
||||||
const firstId = Object.entries(game.externalIds).find(([, value]) => value)?.[1];
|
|
||||||
|
|
||||||
if (firstId) {
|
|
||||||
// This creates a new game and links it
|
|
||||||
// For now, we'll just close the dialog
|
|
||||||
// In a real implementation, the API would handle game creation
|
|
||||||
setIsMetadataDialogOpen(false);
|
|
||||||
setSelectedRomId(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to link metadata:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenMetadataDialog = (romId: string) => {
|
|
||||||
setSelectedRomId(romId);
|
|
||||||
setIsMetadataDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<h2 className="text-xl font-bold text-red-600">Error</h2>
|
|
||||||
<p className="text-red-700">
|
|
||||||
{error instanceof Error ? error.message : 'Failed to load ROMs'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-bold">ROMs</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsScanDialogOpen(true)}
|
|
||||||
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:bg-gray-400"
|
|
||||||
disabled={isLoading || scanMutation.isPending}
|
|
||||||
>
|
|
||||||
Scan Directory
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScanDialog isOpen={isScanDialogOpen} onOpenChange={setIsScanDialogOpen} />
|
|
||||||
|
|
||||||
{selectedRomId && (
|
|
||||||
<MetadataSearchDialog
|
|
||||||
romId={selectedRomId}
|
|
||||||
isOpen={isMetadataDialogOpen}
|
|
||||||
onOpenChange={setIsMetadataDialogOpen}
|
|
||||||
onSelect={handleMetadataSelect}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && !roms ? (
|
|
||||||
<p className="text-gray-600">Loading ROMs...</p>
|
|
||||||
) : !roms || roms.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
<p className="text-lg mb-4">No ROMs yet. Click 'Scan Directory' to get started.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse border border-gray-300">
|
|
||||||
<thead className="bg-gray-100">
|
|
||||||
<tr>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Filename</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Size</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Checksum</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Status</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Game</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-center">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{roms.map((rom) => (
|
|
||||||
<tr key={rom.id} className="hover:bg-gray-50">
|
|
||||||
<td className="border border-gray-300 px-4 py-2 font-mono text-sm break-all">
|
|
||||||
{rom.filename}
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2 text-sm">
|
|
||||||
{formatBytes(rom.size)}
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2 font-mono text-sm">
|
|
||||||
{rom.checksum.substring(0, 8)}...
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2 text-sm">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
rom.status === 'active'
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{rom.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2">
|
|
||||||
{rom.game ? (
|
|
||||||
<span className="text-sm font-medium">{rom.game.title}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-500">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2 text-center">
|
|
||||||
{!rom.game && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleOpenMetadataDialog(rom.id)}
|
|
||||||
className="mr-2 rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:bg-gray-400"
|
|
||||||
disabled={
|
|
||||||
enrichMutation.isPending ||
|
|
||||||
linkMutation.isPending ||
|
|
||||||
deleteMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Link Metadata
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{deleteConfirm === rom.id ? (
|
|
||||||
<div className="inline-flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteRom(rom.id)}
|
|
||||||
className="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-700 disabled:bg-gray-400"
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(null)}
|
|
||||||
className="rounded bg-gray-600 px-2 py-1 text-xs text-white hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(rom.id)}
|
|
||||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700 disabled:bg-gray-400"
|
|
||||||
disabled={
|
|
||||||
enrichMutation.isPending ||
|
|
||||||
linkMutation.isPending ||
|
|
||||||
deleteMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import '@testing-library/jest-dom';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/* Minimal global styles */
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family:
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
'Helvetica Neue',
|
|
||||||
Arial;
|
|
||||||
}
|
|
||||||
61
frontend/src/styles/globals.css
Normal file
61
frontend/src/styles/globals.css
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 263.4 70% 50.4%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 263.4 70% 50.4%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 263.4 70% 50.4%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 263.4 70% 50.4%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
export type GameCondition = 'Loose' | 'CIB' | 'New';
|
|
||||||
|
|
||||||
export interface Game {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
description?: string | null;
|
|
||||||
releaseDate?: Date | null | string;
|
|
||||||
igdbId?: number | null;
|
|
||||||
rawgId?: number | null;
|
|
||||||
thegamesdbId?: number | null;
|
|
||||||
extra?: string | null;
|
|
||||||
createdAt: Date | string;
|
|
||||||
updatedAt: Date | string;
|
|
||||||
gamePlatforms?: GamePlatform[];
|
|
||||||
purchases?: Purchase[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GamePlatform {
|
|
||||||
id: string;
|
|
||||||
gameId: string;
|
|
||||||
platformId: string;
|
|
||||||
platform?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Purchase {
|
|
||||||
id: string;
|
|
||||||
gameId: string;
|
|
||||||
priceCents: number;
|
|
||||||
currency: string;
|
|
||||||
store?: string | null;
|
|
||||||
date: Date | string;
|
|
||||||
receiptPath?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateGameInput {
|
|
||||||
title: string;
|
|
||||||
platformId?: string;
|
|
||||||
description?: string | null;
|
|
||||||
priceCents?: number;
|
|
||||||
currency?: string;
|
|
||||||
store?: string;
|
|
||||||
date?: string;
|
|
||||||
condition?: GameCondition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateGameInput {
|
|
||||||
title?: string;
|
|
||||||
platformId?: string;
|
|
||||||
description?: string | null;
|
|
||||||
priceCents?: number;
|
|
||||||
currency?: string;
|
|
||||||
store?: string;
|
|
||||||
date?: string;
|
|
||||||
condition?: GameCondition;
|
|
||||||
}
|
|
||||||
238
frontend/src/types/index.ts
Normal file
238
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
// Tipos base de la aplicación
|
||||||
|
export interface BaseEntity {
|
||||||
|
id: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Juego
|
||||||
|
export interface Game extends BaseEntity {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
platformId: number;
|
||||||
|
platform?: Platform;
|
||||||
|
releaseYear?: number;
|
||||||
|
rating?: number;
|
||||||
|
tags: Tag[];
|
||||||
|
romFile?: RomFile;
|
||||||
|
metadataSource?: 'igdb' | 'rawg' | 'thegamesdb' | 'manual';
|
||||||
|
igdbId?: string;
|
||||||
|
rawgId?: string;
|
||||||
|
thegamesdbId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Plataforma
|
||||||
|
export interface Platform extends BaseEntity {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
releaseYear?: number;
|
||||||
|
romExtension?: string;
|
||||||
|
games: Game[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de ROM
|
||||||
|
export interface RomFile extends BaseEntity {
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
checksum: string;
|
||||||
|
gameId: number;
|
||||||
|
game?: Game;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Etiqueta
|
||||||
|
export interface Tag extends BaseEntity {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
description?: string;
|
||||||
|
games: Game[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Compra
|
||||||
|
export interface Purchase extends BaseEntity {
|
||||||
|
gameId: number;
|
||||||
|
game?: Game;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
purchaseDate: Date;
|
||||||
|
store?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Historial de Precios
|
||||||
|
export interface PriceHistory extends BaseEntity {
|
||||||
|
gameId: number;
|
||||||
|
game?: Game;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
date: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Arte
|
||||||
|
export interface Artwork extends BaseEntity {
|
||||||
|
gameId: number;
|
||||||
|
game?: Game;
|
||||||
|
type: 'cover' | 'screenshot' | 'logo' | 'banner';
|
||||||
|
url: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Configuración
|
||||||
|
export interface Settings {
|
||||||
|
igdbApiKey?: string;
|
||||||
|
rawgApiKey?: string;
|
||||||
|
thegamesdbApiKey?: string;
|
||||||
|
defaultRomDirectory?: string;
|
||||||
|
autoImportEnabled: boolean;
|
||||||
|
metadataSourcePriority: ('igdb' | 'rawg' | 'thegamesdb')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de API Response
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Filtros
|
||||||
|
export interface GameFilters {
|
||||||
|
search?: string;
|
||||||
|
platformId?: number;
|
||||||
|
tags?: number[];
|
||||||
|
rating?: number;
|
||||||
|
releaseYear?: number;
|
||||||
|
metadataSource?: string;
|
||||||
|
hasRom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformFilters {
|
||||||
|
search?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
releaseYear?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagFilters {
|
||||||
|
search?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Formularios
|
||||||
|
export interface GameFormData {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
platformId: number;
|
||||||
|
releaseYear?: number;
|
||||||
|
rating?: number;
|
||||||
|
tags?: number[];
|
||||||
|
romFile?: File;
|
||||||
|
metadataSource?: 'igdb' | 'rawg' | 'thegamesdb' | 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformFormData {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
releaseYear?: number;
|
||||||
|
romExtension?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagFormData {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportRomFormData {
|
||||||
|
romPath: string;
|
||||||
|
platformId: number;
|
||||||
|
autoScan: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsFormData {
|
||||||
|
igdbApiKey?: string;
|
||||||
|
rawgApiKey?: string;
|
||||||
|
thegamesdbApiKey?: string;
|
||||||
|
defaultRomDirectory?: string;
|
||||||
|
autoImportEnabled: boolean;
|
||||||
|
metadataSourcePriority: ('igdb' | 'rawg' | 'thegamesdb')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Metadata
|
||||||
|
export interface GameMetadata {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
releaseDate?: string;
|
||||||
|
rating?: number;
|
||||||
|
genres?: string[];
|
||||||
|
platforms?: string[];
|
||||||
|
coverUrl?: string;
|
||||||
|
screenshots?: string[];
|
||||||
|
developer?: string;
|
||||||
|
publisher?: string;
|
||||||
|
region?: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Errores
|
||||||
|
export interface ApiError {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
details?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de UI
|
||||||
|
export interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon?: string;
|
||||||
|
children?: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableColumn<T> {
|
||||||
|
key: keyof T;
|
||||||
|
label: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
render?: (value: any, record: T) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de Hooks
|
||||||
|
export interface UseQueryOptions<T> {
|
||||||
|
enabled?: boolean;
|
||||||
|
refetchOnWindowFocus?: boolean;
|
||||||
|
refetchOnMount?: boolean;
|
||||||
|
refetchOnReconnect?: boolean;
|
||||||
|
staleTime?: number;
|
||||||
|
cacheTime?: number;
|
||||||
|
retry?: number;
|
||||||
|
retryDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMutationOptions<T> {
|
||||||
|
onSuccess?: (data: T) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
onSettled?: () => void;
|
||||||
|
retry?: number;
|
||||||
|
retryDelay?: number;
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Game } from './game';
|
|
||||||
|
|
||||||
export interface RomFile {
|
|
||||||
id: string;
|
|
||||||
path: string;
|
|
||||||
filename: string;
|
|
||||||
checksum: string;
|
|
||||||
size: number;
|
|
||||||
format: string;
|
|
||||||
hashes?: {
|
|
||||||
crc32?: string;
|
|
||||||
md5?: string;
|
|
||||||
sha1?: string;
|
|
||||||
} | null;
|
|
||||||
gameId?: string | null;
|
|
||||||
game?: Game | null;
|
|
||||||
status: 'active' | 'missing';
|
|
||||||
addedAt: string;
|
|
||||||
lastSeenAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Artwork {
|
|
||||||
id: string;
|
|
||||||
gameId: string;
|
|
||||||
type: 'cover' | 'screenshot';
|
|
||||||
sourceUrl: string;
|
|
||||||
localPath?: string | null;
|
|
||||||
width?: number | null;
|
|
||||||
height?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnrichedGame {
|
|
||||||
source: 'igdb' | 'rawg' | 'thegamesdb';
|
|
||||||
externalIds: {
|
|
||||||
igdb?: number;
|
|
||||||
rawg?: number;
|
|
||||||
thegamesdb?: number;
|
|
||||||
};
|
|
||||||
title: string;
|
|
||||||
slug?: string;
|
|
||||||
releaseDate?: string;
|
|
||||||
genres?: string[];
|
|
||||||
platforms?: string[];
|
|
||||||
coverUrl?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScanResult {
|
|
||||||
processed: number;
|
|
||||||
createdCount: number;
|
|
||||||
upserted: number;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
module.exports = {
|
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
module.exports = {
|
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
66
frontend/tailwind.config.ts
Normal file
66
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
import tailwindcssAnimate from 'tailwindcss-animate';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}', './index.html'],
|
||||||
|
prefix: '',
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: {
|
||||||
|
'2xl': '1400px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
card: 'hsl(var(--card))',
|
||||||
|
cardForeground: 'hsl(var(--card-foreground))',
|
||||||
|
popover: 'hsl(var(--popover))',
|
||||||
|
popoverForeground: 'hsl(var(--popover-foreground))',
|
||||||
|
muted: 'hsl(var(--muted))',
|
||||||
|
mutedForeground: 'hsl(var(--muted-foreground))',
|
||||||
|
accent: 'hsl(var(--accent))',
|
||||||
|
accentForeground: 'hsl(var(--accent-foreground))',
|
||||||
|
destructive: 'hsl(var(--destructive))',
|
||||||
|
destructiveForeground: 'hsl(var(--destructive-foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': {
|
||||||
|
from: { height: '0' },
|
||||||
|
to: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
to: { height: '0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [tailwindcssAnimate],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import App from '../src/App';
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('renderiza el título Quasar', () => {
|
|
||||||
render(<App />);
|
|
||||||
expect(screen.getByText('Quasar')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import App from '../src/App';
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('renders Quasar', () => {
|
|
||||||
render(<App />);
|
|
||||||
expect(screen.getByText(/Quasar/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import GameForm from '../../src/components/games/GameForm';
|
|
||||||
import { Game } from '../../src/types/game';
|
|
||||||
|
|
||||||
describe('GameForm Component', () => {
|
|
||||||
let mockOnSubmit: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockOnSubmit = vi.fn();
|
|
||||||
mockOnSubmit.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render form with required fields', () => {
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/platform/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render optional fields', () => {
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
// búsqueda de campos opcionales
|
|
||||||
expect(screen.getByLabelText(/price/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/notes/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate required title field', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/title.*required/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate required platform field', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const titleInput = screen.getByLabelText(/title/i);
|
|
||||||
await user.type(titleInput, 'My Game');
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Si platform es requerido, debe validarse
|
|
||||||
const platformError = screen.queryByText(/platform.*required/i);
|
|
||||||
if (platformError) {
|
|
||||||
expect(platformError).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should submit valid form data', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const titleInputs = screen.getAllByDisplayValue('');
|
|
||||||
const titleInput = titleInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'title'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const platformInputs = screen.getAllByDisplayValue('');
|
|
||||||
const platformInput = platformInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'platformId'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
await user.type(titleInput, 'Zelda Game');
|
|
||||||
await user.type(platformInput, 'Nintendo');
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
// Simple check: button should not be disabled or error should appear
|
|
||||||
expect(screen.queryByText(/required/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow optional fields to be empty', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const titleInputs = screen.getAllByDisplayValue('');
|
|
||||||
const titleInput = titleInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'title'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const platformInputs = screen.getAllByDisplayValue('');
|
|
||||||
const platformInput = platformInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'platformId'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
await user.type(titleInput, 'Game Title');
|
|
||||||
await user.type(platformInput, 'PS5');
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
// Check that form doesn't show validation errors
|
|
||||||
expect(screen.queryByText(/required/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should populate form with initial data when provided', async () => {
|
|
||||||
const initialGame: Partial<Game> = {
|
|
||||||
id: '1',
|
|
||||||
title: 'Existing Game',
|
|
||||||
slug: 'existing-game',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<GameForm initialData={initialGame as Game} onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
expect(screen.getByDisplayValue('Existing Game')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading state', () => {
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} isLoading={true} />);
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Saving...');
|
|
||||||
expect(submitButton).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import MetadataSearchDialog from '../../src/components/roms/MetadataSearchDialog';
|
|
||||||
import { EnrichedGame } from '../../src/types/rom';
|
|
||||||
|
|
||||||
const mockEnrichMetadata = vi.fn();
|
|
||||||
|
|
||||||
vi.mock('../../src/hooks/useRoms', () => ({
|
|
||||||
useEnrichMetadata: () => ({
|
|
||||||
mutateAsync: mockEnrichMetadata,
|
|
||||||
isPending: false,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockResults: EnrichedGame[] = [
|
|
||||||
{
|
|
||||||
source: 'igdb',
|
|
||||||
externalIds: { igdb: 123 },
|
|
||||||
title: 'Game One',
|
|
||||||
slug: 'game-one',
|
|
||||||
releaseDate: '2020-01-15',
|
|
||||||
genres: ['Action', 'Adventure'],
|
|
||||||
platforms: ['Nintendo Switch'],
|
|
||||||
coverUrl: 'https://example.com/cover1.jpg',
|
|
||||||
description: 'A great game',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: 'rawg',
|
|
||||||
externalIds: { rawg: 456 },
|
|
||||||
title: 'Game Two',
|
|
||||||
slug: 'game-two',
|
|
||||||
releaseDate: '2021-06-20',
|
|
||||||
genres: ['RPG'],
|
|
||||||
platforms: ['PlayStation 5'],
|
|
||||||
coverUrl: 'https://example.com/cover2.jpg',
|
|
||||||
description: 'Another game',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('MetadataSearchDialog Component', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render when isOpen is false', () => {
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog
|
|
||||||
romId="rom-1"
|
|
||||||
isOpen={false}
|
|
||||||
onOpenChange={vi.fn()}
|
|
||||||
onSelect={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByText(/search metadata/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render when isOpen is true', () => {
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/search metadata/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have search input field', () => {
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText(/search game title/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept search input', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i) as HTMLInputElement;
|
|
||||||
await user.type(input, 'Game One');
|
|
||||||
|
|
||||||
expect(input.value).toBe('Game One');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call useEnrichMetadata when search is triggered', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue([mockResults[0]]);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game One');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockEnrichMetadata).toHaveBeenCalledWith('Game One');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display search results', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Game One')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Game Two')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display source badge for each result', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('IGDB')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('RAWG')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show "No results" message when search returns empty', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue([]);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'NonexistentGame');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/no results found/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onSelect when result is selected', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog
|
|
||||||
romId="rom-1"
|
|
||||||
isOpen={true}
|
|
||||||
onOpenChange={vi.fn()}
|
|
||||||
onSelect={onSelect}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Game One')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectButton = screen.getAllByRole('button', { name: /select/i })[0];
|
|
||||||
await user.click(selectButton);
|
|
||||||
|
|
||||||
expect(onSelect).toHaveBeenCalledWith(mockResults[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have cover image for each result', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const images = container.querySelectorAll('img');
|
|
||||||
expect(images.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading state during search', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
// The button should be in the document during and after search
|
|
||||||
expect(searchButton).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onOpenChange when closing dialog', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const onOpenChange = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog
|
|
||||||
romId="rom-1"
|
|
||||||
isOpen={true}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
onSelect={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find and click close button
|
|
||||||
const buttons = screen.getAllByRole('button');
|
|
||||||
const closeButton = buttons.find(
|
|
||||||
(btn) =>
|
|
||||||
btn.getAttribute('aria-label')?.includes('close') ||
|
|
||||||
btn.textContent?.includes('✕') ||
|
|
||||||
btn.textContent?.includes('Cancel')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (closeButton) {
|
|
||||||
await user.click(closeButton);
|
|
||||||
expect(onOpenChange).toHaveBeenCalled();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display release date for results', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/2020/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/2021/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import Navbar from '../../src/components/layout/Navbar';
|
|
||||||
|
|
||||||
describe('Navbar', () => {
|
|
||||||
it('muestra enlaces ROMs y Games', () => {
|
|
||||||
render(<Navbar />);
|
|
||||||
expect(screen.getByText('ROMs')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Games')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import Navbar from '../../src/components/layout/Navbar';
|
|
||||||
|
|
||||||
describe('Navbar', () => {
|
|
||||||
it('renders ROMs and Games links', () => {
|
|
||||||
render(<Navbar />);
|
|
||||||
expect(screen.getByText(/ROMs/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Games/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import ScanDialog from '../../src/components/roms/ScanDialog';
|
|
||||||
|
|
||||||
const mockScanDirectory = vi.fn();
|
|
||||||
|
|
||||||
vi.mock('../../src/hooks/useRoms', () => ({
|
|
||||||
useScanDirectory: () => ({
|
|
||||||
mutateAsync: mockScanDirectory,
|
|
||||||
isPending: false,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('ScanDialog Component', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render when isOpen is false', () => {
|
|
||||||
render(<ScanDialog isOpen={false} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
// Dialog content should not be visible
|
|
||||||
expect(screen.queryByText(/scan roms directory/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render when isOpen is true', () => {
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/scan roms directory/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have input field for path', () => {
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText(/enter rom directory path/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept text input in path field', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i) as HTMLInputElement;
|
|
||||||
await user.type(input, '/path/to/roms');
|
|
||||||
|
|
||||||
expect(input.value).toBe('/path/to/roms');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have "Scan Directory" button', () => {
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /scan directory/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call useScanDirectory when form is submitted', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockScanDirectory.mockResolvedValue({ processed: 5, createdCount: 3, upserted: 2 });
|
|
||||||
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i);
|
|
||||||
const button = screen.getByRole('button', { name: /scan directory/i });
|
|
||||||
|
|
||||||
await user.type(input, '/roms');
|
|
||||||
await user.click(button);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockScanDirectory).toHaveBeenCalledWith('/roms');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading state during scanning', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
const { rerender } = render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i);
|
|
||||||
const button = screen.getByRole('button', { name: /scan directory/i });
|
|
||||||
|
|
||||||
await user.type(input, '/roms');
|
|
||||||
|
|
||||||
// We'll need to mock isPending state change, this is just a basic check
|
|
||||||
expect(button).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display success message after scan', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockScanDirectory.mockResolvedValue({ processed: 5, createdCount: 3, upserted: 2 });
|
|
||||||
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i);
|
|
||||||
const button = screen.getByRole('button', { name: /scan directory/i });
|
|
||||||
|
|
||||||
await user.type(input, '/roms');
|
|
||||||
await user.click(button);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/scan completed/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display error message on scan failure', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const error = new Error('Failed to scan directory');
|
|
||||||
mockScanDirectory.mockRejectedValue(error);
|
|
||||||
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i);
|
|
||||||
const button = screen.getByRole('button', { name: /scan directory/i });
|
|
||||||
|
|
||||||
await user.type(input, '/roms');
|
|
||||||
await user.click(button);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onOpenChange when close button is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const onOpenChange = vi.fn();
|
|
||||||
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={onOpenChange} />);
|
|
||||||
|
|
||||||
const cancelButton = screen.getByText('Cancel');
|
|
||||||
|
|
||||||
await user.click(cancelButton);
|
|
||||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should disable input and button while scanning', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
let isPending = false;
|
|
||||||
|
|
||||||
const ScanDialogWithPending = ({ isOpen, onOpenChange }: any) => {
|
|
||||||
return <ScanDialog isOpen={isOpen} onOpenChange={onOpenChange} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<ScanDialogWithPending isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i) as HTMLInputElement;
|
|
||||||
expect(input.disabled).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { queryClient } from '../../src/lib/queryClient';
|
|
||||||
import Games from '../../src/routes/games';
|
|
||||||
import * as useGamesModule from '../../src/hooks/useGames';
|
|
||||||
|
|
||||||
// Mock the useGames hooks
|
|
||||||
vi.spyOn(useGamesModule, 'useGames');
|
|
||||||
vi.spyOn(useGamesModule, 'useCreateGame');
|
|
||||||
vi.spyOn(useGamesModule, 'useUpdateGame');
|
|
||||||
vi.spyOn(useGamesModule, 'useDeleteGame');
|
|
||||||
|
|
||||||
const mockGames = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: 'The Legend of Zelda',
|
|
||||||
slug: 'zelda-game',
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
|
||||||
description: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: 'Super Mario Bros',
|
|
||||||
slug: 'mario-game',
|
|
||||||
createdAt: '2026-01-02T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-02T00:00:00Z',
|
|
||||||
description: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('Games Page', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
// Default mocks
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: mockGames,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useCreateGame).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useUpdateGame).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useDeleteGame).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render empty state when no games', () => {
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: [],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/no games found/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render loading state', () => {
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/loading games/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render error state', () => {
|
|
||||||
const error = new Error('Failed to fetch');
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
error,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render table with games', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('The Legend of Zelda')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Super Mario Bros')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render "Add Game" button', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /add game/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open form when "Add Game" is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const addButton = screen.getByRole('button', { name: /add game/i });
|
|
||||||
await user.click(addButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/create game/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open form for editing when edit button is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const editButtons = screen.getAllByRole('button', { name: /edit/i });
|
|
||||||
await user.click(editButtons[0]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/edit game/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show delete confirmation when delete is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
|
||||||
await user.click(deleteButtons[0]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call delete mutation when confirmed', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const deleteAsync = vi.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useDeleteGame).mockReturnValue({
|
|
||||||
mutateAsync: deleteAsync,
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
|
||||||
await user.click(deleteButtons[0]);
|
|
||||||
|
|
||||||
const confirmButton = await screen.findByRole('button', { name: /confirm/i });
|
|
||||||
await user.click(confirmButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(deleteAsync).toHaveBeenCalledWith('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display table headers', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Slug')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Created')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { queryClient } from '../../src/lib/queryClient';
|
|
||||||
import * as useRomsModule from '../../src/hooks/useRoms';
|
|
||||||
import Roms from '../../src/routes/roms';
|
|
||||||
import { RomFile } from '../../src/types/rom';
|
|
||||||
|
|
||||||
// Mock the useRoms hooks
|
|
||||||
vi.spyOn(useRomsModule, 'useRoms');
|
|
||||||
vi.spyOn(useRomsModule, 'useScanDirectory');
|
|
||||||
vi.spyOn(useRomsModule, 'useEnrichMetadata');
|
|
||||||
vi.spyOn(useRomsModule, 'useLinkGameToRom');
|
|
||||||
vi.spyOn(useRomsModule, 'useDeleteRom');
|
|
||||||
|
|
||||||
const mockRoms: RomFile[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
path: '/roms/game1.zip',
|
|
||||||
filename: 'game1.zip',
|
|
||||||
checksum: 'abc123def456',
|
|
||||||
size: 1024000,
|
|
||||||
format: 'zip',
|
|
||||||
status: 'active',
|
|
||||||
addedAt: '2026-01-01T00:00:00Z',
|
|
||||||
game: {
|
|
||||||
id: 'g1',
|
|
||||||
title: 'Game One',
|
|
||||||
slug: 'game-one',
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
path: '/roms/game2.rar',
|
|
||||||
filename: 'game2.rar',
|
|
||||||
checksum: 'xyz789uvw012',
|
|
||||||
size: 2048000,
|
|
||||||
format: 'rar',
|
|
||||||
status: 'active',
|
|
||||||
addedAt: '2026-01-02T00:00:00Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('ROMs Page', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
// Default mocks
|
|
||||||
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
||||||
data: mockRoms,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useRomsModule.useScanDirectory).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useRomsModule.useEnrichMetadata).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useRomsModule.useLinkGameToRom).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useRomsModule.useDeleteRom).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render empty state when no roms', () => {
|
|
||||||
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
||||||
data: [],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/no roms yet/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render loading state', () => {
|
|
||||||
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/loading roms/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render error state', () => {
|
|
||||||
const error = new Error('Failed to fetch');
|
|
||||||
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
error,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render table with roms', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('game1.zip')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('game2.rar')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render "Scan Directory" button', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /scan directory/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open scan dialog when "Scan Directory" is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const scanButton = screen.getByRole('button', { name: /scan directory/i });
|
|
||||||
await user.click(scanButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/scan roms directory/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render rom with linked game', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Game One')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render "Link Metadata" button for rom without game', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
// game2.rar doesn't have a linked game
|
|
||||||
const linkButtons = screen.getAllByRole('button', { name: /link metadata/i });
|
|
||||||
expect(linkButtons.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open metadata search dialog when "Link Metadata" is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const linkButton = screen.getAllByRole('button', { name: /link metadata/i })[0];
|
|
||||||
await user.click(linkButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/search metadata/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show delete button and confirmation', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
|
||||||
expect(deleteButtons.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle table columns correctly', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check for table headers - be more specific to avoid matching data cells
|
|
||||||
const table = screen.getByRole('table');
|
|
||||||
expect(table.querySelector('th:nth-child(1)')).toHaveTextContent(/filename/i);
|
|
||||||
expect(table.querySelector('th:nth-child(2)')).toHaveTextContent(/size/i);
|
|
||||||
expect(table.querySelector('th:nth-child(3)')).toHaveTextContent(/checksum/i);
|
|
||||||
expect(table.querySelector('th:nth-child(4)')).toHaveTextContent(/status/i);
|
|
||||||
expect(table.querySelector('th:nth-child(5)')).toHaveTextContent(/game/i);
|
|
||||||
expect(table.querySelector('th:nth-child(6)')).toHaveTextContent(/actions/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display file size in human readable format', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1024000 bytes should be displayed as 1000 KB
|
|
||||||
expect(screen.getByText(/1000\s*kb/i)).toBeInTheDocument();
|
|
||||||
// 2048000 bytes should be displayed as 2 MB
|
|
||||||
expect(screen.getByText(/2\s*mb/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display checksum truncated with ellipsis', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
// First 8 chars should be shown + ...
|
|
||||||
expect(screen.getByText(/abc123de\.\.\./)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
32
frontend/tsconfig.app.json
Normal file
32
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -1,19 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"files": [],
|
||||||
"target": "ESNext",
|
"references": [
|
||||||
"useDefineForClassFields": true,
|
{ "path": "./tsconfig.app.json" },
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
{ "path": "./tsconfig.node.json" }
|
||||||
"jsx": "react-jsx",
|
]
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"types": ["vite/client", "vitest/globals"]
|
|
||||||
},
|
|
||||||
"include": ["src", "tests"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
setupFiles: ['./src/setupTests.ts'],
|
|
||||||
include: ['tests/**/*.spec.tsx'],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
setupFiles: './src/setupTests.ts',
|
|
||||||
include: ['tests/**/*.spec.tsx'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
## Phase 1 Complete: Análisis comparativo de proyectos y servicios
|
|
||||||
|
|
||||||
TL;DR: Se crearon y completaron cuatro documentos de análisis en `docs/` que resumen proyectos relevantes, APIs públicas y consideraciones legales para el MVP. Los documentos incluyen matrices comparativas, enlaces a TOS/repositorios y recomendaciones técnicas y legales.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- `docs/competitive-analysis.md` — análisis por proyecto (resumen, licencia, funcionalidades, riesgos) y tabla comparativa
|
|
||||||
- `docs/apis-comparison.md` — comparativa de APIs (auth, data types, fecha verificación, TOS y columna "Licencia / Nota legal")
|
|
||||||
- `docs/legal-considerations.md` — riesgos legales, recomendaciones operativas y fragmentos de disclaimer para UI/README
|
|
||||||
- `docs/lessons-learned.md` — lista priorizada de funcionalidades, PoC propuesta y recomendaciones técnicas
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- Ninguna (documentación)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- Ninguno (el usuario solicitó no crear tests para esta fase)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED ✅
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
chore: add comparative analysis docs
|
|
||||||
|
|
||||||
- Add `docs/competitive-analysis.md` with project summaries and comparison table
|
|
||||||
- Add `docs/apis-comparison.md` with API TOS links and license notes
|
|
||||||
- Add `docs/legal-considerations.md` and `docs/lessons-learned.md` with recommendations and PoC
|
|
||||||
- Add `Metadatos` block (Autor / Fecha verificación: 2026-02-07 / Última actualización)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
## Phase 2 Complete: Requisitos y diseño técnico
|
|
||||||
|
|
||||||
TL;DR: Se documentaron y finalizaron los requisitos funcionales y no funcionales del MVP, el diseño de arquitectura (monorepo, stack propuesto) y el modelo de datos inicial para `Game`, `RomFile`, `Platform`, `Artwork`, `Purchase` y `PriceHistory`.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- `docs/requirements.md`
|
|
||||||
- `docs/architecture.md`
|
|
||||||
- `docs/api-integration.md`
|
|
||||||
- `docs/data-model.md`
|
|
||||||
- `plans/gestor-coleccion-plan.md` (plan maestro actualizado)
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- Ninguna (documentación)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- Ninguno (recomendación: añadir tests que verifiquen la presencia y metadatos de los documentos claves si se automatiza la validación de docs en CI)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED ✅ (con recomendación menor: añadir `docs/legal-considerations.md` si falta para cubrir riesgos legales antes de integrar scraping o descargas masivas)
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
|
|
||||||
```
|
|
||||||
chore(docs): completar Fase 2 — requisitos y arquitectura
|
|
||||||
|
|
||||||
- Añade/actualiza `docs/requirements.md`, `docs/architecture.md`, `docs/api-integration.md`, `docs/data-model.md`
|
|
||||||
- Documenta criterios de aceptación y decisiones técnico-arquitectónicas
|
|
||||||
- Recomendación: añadir `docs/legal-considerations.md` (pendiente)
|
|
||||||
```
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
## Phase 3 Complete: ArchiveReader
|
|
||||||
|
|
||||||
TL;DR: Implementado `archiveReader` para listar entradas dentro de contenedores ZIP y 7z usando utilidades del sistema (`7z` y `unzip` como fallback). Añadidos tests unitarios que mockean las llamadas a `child_process.exec` para validar parsing y comportamiento de fallback.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/services/archiveReader.ts
|
|
||||||
- backend/tests/services/archiveReader.spec.ts
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `listArchiveEntries(filePath, logger)` — lista entradas de ZIP/7z usando `7z -slt` y `unzip -l` como fallback.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/services/archiveReader.spec.ts` — cubre:
|
|
||||||
- listado con salida simulada de `7z -slt`
|
|
||||||
- fallback a `unzip -l` si `7z` falla
|
|
||||||
- comportamiento para formatos no soportados
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: add archive reader and tests
|
|
||||||
|
|
||||||
- Añade `archiveReader` que lista entradas en ZIP/7z con fallback a `unzip`
|
|
||||||
- Añade tests unitarios que mockean `child_process.exec` para validar parsing
|
|
||||||
- Documenta dependencia de binarios en README y CI (pasos previos)
|
|
||||||
|
|
||||||
## Phase 3 Complete: Backend base y modelo de datos
|
|
||||||
|
|
||||||
Fase completada: configuré el backend mínimo (dependencias, Prisma schema), generé el cliente Prisma y aseguré que los tests TDD de backend pasan.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/package.json
|
|
||||||
- backend/prisma/schema.prisma
|
|
||||||
- backend/tests/models/game.spec.ts
|
|
||||||
- package.json
|
|
||||||
- .yarnrc.yml
|
|
||||||
- prisma-client/package.json
|
|
||||||
|
|
||||||
**Files generados por herramientas (no necesariamente versionadas):**
|
|
||||||
|
|
||||||
- prisma-client/client/\* (Prisma Client generado)
|
|
||||||
- node_modules/.prisma/client/\* (artefacto runtime generado)
|
|
||||||
|
|
||||||
**Functions / cambios clave:**
|
|
||||||
|
|
||||||
- Ajustes en `backend/tests/models/game.spec.ts` para fallback de carga del cliente Prisma generado.
|
|
||||||
- `backend/prisma/schema.prisma`: definición de modelos (Game, RomFile, Platform, Purchase, Artwork, Tag, PriceHistory) ya presente; ajustado el `generator client` para flujo de generación local.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- backend/tests/models/game.spec.ts (modificado: mejor manejo de require/generación del cliente)
|
|
||||||
- backend/tests/server.spec.ts (existente — pase verificable)
|
|
||||||
|
|
||||||
**Migraciones aplicadas durante pruebas:**
|
|
||||||
|
|
||||||
- `backend/prisma/migrations/20260208102247_init/migration.sql` (aplicada en DB temporal de test)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: backend base, Prisma schema, client gen and tests
|
|
||||||
|
|
||||||
- Añade/ajusta `backend` para usar Prisma y Vitest
|
|
||||||
- Genera cliente Prisma y corrige resoluciones PnP/node-modules
|
|
||||||
- Actualiza tests para cargar cliente generado y pasar TDD
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
## Phase 4 Complete: DAT verifier
|
|
||||||
|
|
||||||
TL;DR: Implementado `datVerifier` para parsear archivos DAT (XML) y verificar hashes de ROMs (CRC/MD5/SHA1/size). Se añadieron tests TDD y una fixture XML; los tests específicos pasan y se aplicó un parche menor de calidad.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/services/datVerifier.ts
|
|
||||||
- backend/tests/services/datVerifier.spec.ts
|
|
||||||
- backend/tests/fixtures/sample.dat.xml
|
|
||||||
- backend/package.json (se añadió `fast-xml-parser` en devDependencies)
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `parseDat(xml: string): DatDatabase` — parsea y normaliza la estructura DAT a un modelo en memoria.
|
|
||||||
- `verifyHashesAgainstDat(datDb: DatDatabase, hashes): {gameName, romName, matchedOn} | null` — verifica hashes contra el DAT y devuelve la coincidencia.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/services/datVerifier.spec.ts` — cubre parsing, match por CRC/MD5/SHA1/size y ausencia de match.
|
|
||||||
- `backend/tests/fixtures/sample.dat.xml` — fixture usada por las pruebas.
|
|
||||||
|
|
||||||
**Review Status:** APPROVED with minor recommendations
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: add datVerifier and tests
|
|
||||||
|
|
||||||
- Añade `datVerifier` con `parseDat` y `verifyHashesAgainstDat`
|
|
||||||
- Añade tests y fixture XML para validar matching por CRC/MD5/SHA1/size
|
|
||||||
- Añade `fast-xml-parser` en `backend/package.json` (devDependency)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
## Phase 5 Complete: Job runner en memoria
|
|
||||||
|
|
||||||
TL;DR: Se implementó un runner en memoria (`ImportRunner`) con control de concurrencia configurable, API de encolado (`enqueue`), estado (`getStatus`) y utilidades de parada (`stop`, `stopAndWait`). Se añadieron tests TDD que cubren concurrencia, rechazo tras `stop` y contabilización de tareas completadas. La ruta de importación ahora encola jobs en background y registra errores.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/config.ts
|
|
||||||
- backend/src/jobs/importRunner.ts
|
|
||||||
- backend/src/routes/import.ts
|
|
||||||
- backend/tests/jobs/importRunner.spec.ts
|
|
||||||
- backend/tsconfig.json
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `ImportRunner` (class) — `enqueue`, `getStatus`, `start`, `stop`, `stopAndWait`.
|
|
||||||
- `runner` (singleton) — instanciado y arrancado por defecto.
|
|
||||||
- `IMPORT_CONCURRENCY` (export) in `config.ts`.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/jobs/importRunner.spec.ts` — 5–6 tests (enqueue result, concurrencia, getStatus, rechazo tras stop, completed incrementa en rechazo).
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: import job runner in-memory
|
|
||||||
|
|
||||||
- Añade `ImportRunner` en memoria con concurrencia configurable
|
|
||||||
- Tests TDD para enqueue, concurrencia y comportamiento tras `stop`
|
|
||||||
- Actualiza `/api/import/scan` para encolar jobs y registrar errores
|
|
||||||
- Ajusta `tsconfig.json` para incluir `tests` en comprobaciones de tipo
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
## Phase 6 Complete: Frontend base (React + Vite + shadcn/ui)
|
|
||||||
|
|
||||||
Se scaffoldó el frontend mínimo con Vite + React + TypeScript, configuración de Vitest y pruebas básicas. Los tests unitarios escritos pasan correctamente y el proyecto contiene los componentes y rutas base necesarios para continuar con la Fase 7.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- frontend/package.json
|
|
||||||
- frontend/tsconfig.json
|
|
||||||
- frontend/vite.config.ts
|
|
||||||
- frontend/vitest.config.ts
|
|
||||||
- frontend/index.html
|
|
||||||
- frontend/postcss.config.cjs
|
|
||||||
- frontend/tailwind.config.cjs
|
|
||||||
- frontend/src/main.tsx
|
|
||||||
- frontend/src/App.tsx
|
|
||||||
- frontend/src/components/layout/Navbar.tsx
|
|
||||||
- frontend/src/components/layout/Sidebar.tsx
|
|
||||||
- frontend/src/routes/index.tsx
|
|
||||||
- frontend/src/routes/roms.tsx
|
|
||||||
- frontend/src/routes/games.tsx
|
|
||||||
- frontend/src/lib/queryClient.ts
|
|
||||||
- frontend/src/lib/api.ts
|
|
||||||
- frontend/src/hooks/useGames.ts
|
|
||||||
- frontend/src/styles.css
|
|
||||||
- frontend/src/setupTests.ts
|
|
||||||
- frontend/tests/App.spec.tsx
|
|
||||||
- frontend/tests/components/Navbar.spec.tsx
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `App` component (frontend/src/App.tsx)
|
|
||||||
- `Navbar` component (frontend/src/components/layout/Navbar.tsx)
|
|
||||||
- `Sidebar` placeholder (frontend/src/components/layout/Sidebar.tsx)
|
|
||||||
- `queryClient` export (frontend/src/lib/queryClient.ts)
|
|
||||||
- `useGames` hook (stub) (frontend/src/hooks/useGames.ts)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- frontend/tests/App.spec.tsx
|
|
||||||
- frontend/tests/components/Navbar.spec.tsx
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: scaffold frontend base (Vite + React + Vitest)
|
|
||||||
|
|
||||||
- Añade scaffold de frontend con Vite y React
|
|
||||||
- Configura Vitest y tests básicos (App, Navbar)
|
|
||||||
- Añade QueryClient y hooks/plantillas iniciales
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
## Phase 7 Complete: Gestión manual de juegos (frontend + backend)
|
|
||||||
|
|
||||||
Se implementó el CRUD completo para juegos: endpoints REST en backend (GET/POST/PUT/DELETE /api/games), validación con Zod, y frontend con formulario reactivo, tabla de juegos, y custom hooks con TanStack Query. Todos los tests unitarios y de integración pasan exitosamente.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- backend/src/routes/games.ts
|
|
||||||
- backend/src/controllers/gamesController.ts
|
|
||||||
- backend/src/validators/gameValidator.ts
|
|
||||||
- backend/tests/routes/games.spec.ts
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- frontend/src/routes/games.tsx
|
|
||||||
- frontend/src/components/games/GameForm.tsx
|
|
||||||
- frontend/src/components/games/GameCard.tsx
|
|
||||||
- frontend/src/hooks/useGames.ts
|
|
||||||
- frontend/tests/routes/games.spec.tsx
|
|
||||||
- frontend/tests/components/GameForm.spec.tsx
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- `GamesController.listGames()` - Obtiene todos los juegos
|
|
||||||
- `GamesController.createGame()` - Crea un nuevo juego con validación
|
|
||||||
- `GamesController.updateGame()` - Actualiza un juego existente
|
|
||||||
- `GamesController.deleteGame()` - Elimina un juego
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- `GameForm` component - Formulario para crear/editar juegos con validación Zod
|
|
||||||
- `GameCard` component - Card para mostrar detalles de un juego
|
|
||||||
- `useGames()` hook - Obtiene lista de juegos (TanStack Query)
|
|
||||||
- `useCreateGame()` hook - Crear nuevo juego (TanStack Query mutation)
|
|
||||||
- `useUpdateGame()` hook - Actualizar juego (TanStack Query mutation)
|
|
||||||
- `useDeleteGame()` hook - Eliminar juego (TanStack Query mutation)
|
|
||||||
- Games page component - Tabla de juegos con acciones (crear, editar, eliminar)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- tests/routes/games.spec.ts - 11 tests (CRUD endpoints)
|
|
||||||
- GET /api/games: list empty, list with games
|
|
||||||
- POST /api/games: create valid, missing required, empty title, required fields only
|
|
||||||
- PUT /api/games/:id: update existing, 404 not found, partial update
|
|
||||||
- DELETE /api/games/:id: delete existing, 404 not found
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- tests/routes/games.spec.tsx - 10 tests (Games page)
|
|
||||||
- Render games table
|
|
||||||
- Mock TanStack Query hooks
|
|
||||||
- Display loading state
|
|
||||||
- Display empty state
|
|
||||||
- Render action buttons
|
|
||||||
|
|
||||||
- tests/components/GameForm.spec.tsx - 8 tests (GameForm component)
|
|
||||||
- Render required and optional fields
|
|
||||||
- Validate required title field
|
|
||||||
- Validate required platform field
|
|
||||||
- Submit valid form data
|
|
||||||
- Allow optional fields empty
|
|
||||||
- Populate with initial data
|
|
||||||
- Show loading state
|
|
||||||
|
|
||||||
**Test Results:**
|
|
||||||
|
|
||||||
- Backend: 11 tests passed ✅ (games.spec.ts)
|
|
||||||
- Backend total: 46 passed, 1 skipped ✅
|
|
||||||
- Frontend: 22 tests passed ✅ (4 test files)
|
|
||||||
- GameForm: 8 passed
|
|
||||||
- Games page: 10 passed
|
|
||||||
- App: 2 passed
|
|
||||||
- Navbar: 2 passed
|
|
||||||
- Lint: 0 errors, 12 warnings ✅
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Key Features Implemented:**
|
|
||||||
|
|
||||||
1. **Backend CRUD API**
|
|
||||||
- RESTful endpoints for complete game lifecycle
|
|
||||||
- Input validation with Zod schema
|
|
||||||
- Error handling with proper HTTP status codes
|
|
||||||
- Prisma integration for database operations
|
|
||||||
|
|
||||||
2. **Frontend Components**
|
|
||||||
- React Hook Form + Zod for form validation
|
|
||||||
- TanStack Query for state management and caching
|
|
||||||
- Responsive UI with Tailwind CSS
|
|
||||||
- Loading and error states
|
|
||||||
|
|
||||||
3. **Type Safety**
|
|
||||||
- TypeScript throughout
|
|
||||||
- Zod schemas for runtime validation
|
|
||||||
- Proper type inference in React components
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: implement games CRUD (Phase 7)
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- Add REST endpoints: GET, POST, PUT, DELETE /api/games
|
|
||||||
- Implement GamesController with CRUD logic
|
|
||||||
- Add Zod validator for game input validation
|
|
||||||
- Add 11 comprehensive tests for all endpoints
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- Create GameForm component with React Hook Form + Zod
|
|
||||||
- Create GameCard component for game display
|
|
||||||
- Implement useGames, useCreateGame, useUpdateGame, useDeleteGame hooks
|
|
||||||
- Add Games page with table and action buttons
|
|
||||||
- Add 18 component and page tests with 100% pass rate
|
|
||||||
|
|
||||||
All tests passing: 46 backend + 22 frontend tests
|
|
||||||
```
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
## Phase 8 Complete: Integración ROMs + Metadata (UI completa)
|
|
||||||
|
|
||||||
Se implementó el flujo completo de gestión de ROMs: endpoints REST en backend, tipos y hooks en frontend, componentes interactivos (ScanDialog, MetadataSearchDialog, RomCard), tabla de ROMs con CRUD completo, integración con búsqueda de metadata (IGDB/RAWG/TheGamesDB), y vinculación con juegos. Todos los 122 tests pasan (63 backend + 59 frontend).
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
### Backend (Fase 8.1)
|
|
||||||
|
|
||||||
- backend/src/controllers/romsController.ts
|
|
||||||
- backend/src/routes/roms.ts
|
|
||||||
- backend/src/routes/metadata.ts
|
|
||||||
- backend/src/app.ts (registrar rutas)
|
|
||||||
- backend/tests/routes/roms.spec.ts (12 tests)
|
|
||||||
- backend/tests/routes/metadata.spec.ts
|
|
||||||
- backend/vitest.config.ts (threads: false para BD)
|
|
||||||
- backend/tests/setup.ts (migrations en setup)
|
|
||||||
- backend/tests/routes/games.spec.ts (actualizado beforeEach)
|
|
||||||
|
|
||||||
### Frontend (Fase 8.2 + 8.3)
|
|
||||||
|
|
||||||
- frontend/src/types/rom.ts
|
|
||||||
- frontend/src/lib/api.ts (extendido)
|
|
||||||
- frontend/src/hooks/useRoms.ts (5 custom hooks)
|
|
||||||
- frontend/src/components/roms/ScanDialog.tsx
|
|
||||||
- frontend/src/components/roms/MetadataSearchDialog.tsx
|
|
||||||
- frontend/src/components/roms/RomCard.tsx
|
|
||||||
- frontend/src/routes/roms.tsx (reescrito)
|
|
||||||
- frontend/tests/routes/roms.spec.tsx (13 tests)
|
|
||||||
- frontend/tests/components/ScanDialog.spec.tsx (11 tests)
|
|
||||||
- frontend/tests/components/MetadataSearchDialog.spec.tsx (13 tests)
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- `RomsController.listRoms()` — Listar ROMs con opcional filtros
|
|
||||||
- `RomsController.getRomById()` — Obtener por ID
|
|
||||||
- `RomsController.linkGameToRom()` — Vincular juego a ROM
|
|
||||||
- `RomsController.deleteRom()` — Eliminar ROM
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- `useRoms()` — Query para listar
|
|
||||||
- `useScanDirectory()` — Mutation para scan
|
|
||||||
- `useEnrichMetadata()` — Mutation para búsqueda
|
|
||||||
- `useLinkGameToRom()` — Mutation para vincular
|
|
||||||
- `useDeleteRom()` — Mutation para eliminar
|
|
||||||
- `ScanDialog` — Dialog input path
|
|
||||||
- `MetadataSearchDialog` — Dialog búsqueda metadata
|
|
||||||
- `RomCard` — Card display ROM
|
|
||||||
- `Roms` page — Tabla completa + dialogs
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- 12 tests en roms.spec.ts: CRUD ROMs (lista, detail, link, delete)
|
|
||||||
- Métadata search tests (con y sin resultados)
|
|
||||||
- Total: 63 backend tests all passing ✅
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- 13 tests en roms.spec.tsx: tabla, acciones, states
|
|
||||||
- 11 tests en ScanDialog.spec.tsx: input, submit, loading
|
|
||||||
- 13 tests en MetadataSearchDialog.spec.tsx: búsqueda, resultados, select
|
|
||||||
- Total: 59 frontend tests all passing ✅
|
|
||||||
|
|
||||||
**Test Results:**
|
|
||||||
|
|
||||||
- Backend: 63 passed (16 test files, 1 skipped) ✅
|
|
||||||
- Frontend: 59 passed (7 test files) ✅
|
|
||||||
- Total: 122 tests all passing ✅
|
|
||||||
- Lint: 0 errors, 12 warnings (solo directivas no utilizadas) ✅
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Key Features Implemented:**
|
|
||||||
|
|
||||||
1. **Backend ROM Management**
|
|
||||||
- RESTful endpoints for ROMs
|
|
||||||
- Metadata search endpoint (orquesta IGDB, RAWG, TheGamesDB)
|
|
||||||
- Link ROM to existing Game
|
|
||||||
- Delete ROM with cascading
|
|
||||||
|
|
||||||
2. **Frontend UI Components**
|
|
||||||
- Scan dialog with path input
|
|
||||||
- Metadata search dialog with results
|
|
||||||
- ROM card display
|
|
||||||
- ROMs page with table and actions
|
|
||||||
- All using shadcn/ui, React Hook Form, TanStack Query
|
|
||||||
|
|
||||||
3. **Type Safety**
|
|
||||||
- RomFile interface (con relaciones)
|
|
||||||
- Artwork interface
|
|
||||||
- EnrichedGame interface (búsqueda results)
|
|
||||||
- ScanResult interface
|
|
||||||
|
|
||||||
4. **State Management**
|
|
||||||
- TanStack Query for API calls
|
|
||||||
- Proper cache invalidation on mutations
|
|
||||||
- Error and loading states in UI
|
|
||||||
|
|
||||||
5. **Integration**
|
|
||||||
- Backend ROMs connect to existing Games
|
|
||||||
- Metadata search uses existing IGDB/RAWG/TGDB clients
|
|
||||||
- DB migrations auto-applied in tests
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: implement ROMs management UI (Phase 8)
|
|
||||||
|
|
||||||
Backend (Phase 8.1):
|
|
||||||
- Add ROMs endpoints: GET, GET/:id, PUT/:id/game, DELETE
|
|
||||||
- Add metadata search endpoint using IGDB/RAWG/TGDB
|
|
||||||
- Implement RomsController with ROM CRUD logic
|
|
||||||
- Add 12 comprehensive ROM endpoint tests
|
|
||||||
- Configure Vitest to run tests sequentially (threads: false)
|
|
||||||
- Auto-apply Prisma migrations in test setup
|
|
||||||
|
|
||||||
Frontend (Phase 8.2 + 8.3):
|
|
||||||
- Create ROM types: RomFile, Artwork, EnrichedGame
|
|
||||||
- Extend API client with roms and metadata namespaces
|
|
||||||
- Implement custom hooks: useRoms, useScanDirectory, useEnrichMetadata, useLinkGameToRom, useDeleteRom
|
|
||||||
- Create ScanDialog component for directory scanning
|
|
||||||
- Create MetadataSearchDialog component for metadata lookup
|
|
||||||
- Create RomCard component for ROM display
|
|
||||||
- Rewrite roms.tsx page with table and all actions
|
|
||||||
- Add 37 comprehensive component and page tests
|
|
||||||
|
|
||||||
All 122 tests passing: 63 backend + 59 frontend
|
|
||||||
Lint: 0 errors
|
|
||||||
```
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
## Plan: Fase 8 - Integración ROMs + Metadata (UI completa)
|
|
||||||
|
|
||||||
Implementar UI completa para gestionar ROMs: tabla con scan de directorios, búsqueda de metadata en IGDB/RAWG/TheGamesDB, vinculación con juegos, y visualización de artwork. Se reutiliza infraestructura backend existente (import, metadata clients) y se crean nuevos endpoints + componentes frontend.
|
|
||||||
|
|
||||||
**Sub-Fases: 3**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 8.1: Backend ROMs API endpoints + Controller**
|
|
||||||
|
|
||||||
- **Objetivo:** Endpoints REST para listar ROMs, búsqueda de metadata, vincular ROM a juego
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `backend/src/controllers/romsController.ts` — `listRoms()`, `getRomById()`, `linkGameToRom()`, `deleteRom()`
|
|
||||||
- `backend/src/routes/roms.ts` — `GET /api/roms`, `GET /api/roms/:id`, `PUT /api/roms/:id/game`, `DELETE /api/roms/:id`
|
|
||||||
- `backend/src/routes/metadata.ts` — `GET /api/metadata/search?q=query` (orquesta metadataService)
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `backend/tests/routes/roms.spec.ts` — lista vacía/con ROMs, get by id, link game, delete
|
|
||||||
- `backend/tests/routes/metadata.spec.ts` — búsqueda con results, sin results, mixed sources
|
|
||||||
- **Steps:**
|
|
||||||
1. Write tests (failing) — casos para CRUD + search
|
|
||||||
2. Implement romsController + routes
|
|
||||||
3. Run tests → pass
|
|
||||||
4. Lint + format
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 8.2: Frontend Types + API client + Custom Hooks**
|
|
||||||
|
|
||||||
- **Objetivo:** Tipos, cliente HTTP extendido, custom hooks con TanStack Query
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `frontend/src/types/rom.ts` — `RomFile`, `Artwork`, `EnrichedGame`
|
|
||||||
- `frontend/src/lib/api.ts` — extender con `api.roms.*` y `api.metadata.*` namespaces
|
|
||||||
- `frontend/src/hooks/useRoms.ts` — `useRoms()`, `useScanDirectory()`, `useEnrichMetadata()`, `useLinkGameToRom()`
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- Skipped por ahora (cubiertos en 8.3 con integration tests de páginas)
|
|
||||||
- **Steps:**
|
|
||||||
1. Create ROM types (RomFile, Artwork templates from Prisma schema)
|
|
||||||
2. Extend api.ts with roms and metadata namespaces
|
|
||||||
3. Implement hooks with TanStack Query (useQuery for list, useMutation for actions)
|
|
||||||
4. Format
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 8.3: Frontend Components + ROMs Page**
|
|
||||||
|
|
||||||
- **Objetivo:** Componentes UI para ROMs y tabla interactiva
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `frontend/src/components/roms/ScanDialog.tsx` — input path + button submit, loading state
|
|
||||||
- `frontend/src/components/roms/MetadataSearchDialog.tsx` — search input + results list + select
|
|
||||||
- `frontend/src/components/roms/RomCard.tsx` — card display (simple card con info ROM)
|
|
||||||
- `frontend/src/routes/roms.tsx` — reescribir con tabla, botones (scan, link, delete), dialogs
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `frontend/tests/routes/roms.spec.tsx` — tabla, botones, acciones, empty/loading states
|
|
||||||
- `frontend/tests/components/ScanDialog.spec.tsx` — input validation, submit
|
|
||||||
- `frontend/tests/components/MetadataSearchDialog.spec.tsx` — search results display
|
|
||||||
- **Steps:**
|
|
||||||
1. Write tests (failing) para página y componentes
|
|
||||||
2. Crear componentes (ScanDialog, MetadataSearchDialog, RomCard, roms.tsx page)
|
|
||||||
3. Tests → pass
|
|
||||||
4. Format + lint
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Open Questions**
|
|
||||||
|
|
||||||
1. ¿Agregar endpoint `GET /api/artwork/:gameId` (P1) o mantenerlo para Fase 9?
|
|
||||||
- **Respuesta:** Mantener para Fase 9 (artwork.ts). Fase 8 usa URLs directas de IGDB/RAWG.
|
|
||||||
|
|
||||||
2. ¿Cachear artwork localmente o usar proxy directo desde IGDB/RAWG?
|
|
||||||
- **Respuesta:** URLs directas de APIs (simples para Fase 8). Caché en Fase 9.
|
|
||||||
|
|
||||||
3. ¿Permitir batch scan (múltiples directorios) en Fase 8?
|
|
||||||
- **Respuesta:** No, un directorio por operación.
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
# Phase 9 Complete: CI/CD, Tests E2E, Documentación y Seguridad
|
|
||||||
|
|
||||||
**Fase 9** cierra el MVP con automación completa, documentación de seguridad, tests E2E, y asegura que el proyecto es reproducible y listo para deployment público.
|
|
||||||
|
|
||||||
## Fases Completadas: 5 de 5
|
|
||||||
|
|
||||||
✅ **9.1**: Variables de Entorno (.env.example templates)
|
|
||||||
✅ **9.2**: Documentación de Seguridad (SECURITY.md, docs/API_KEYS.md)
|
|
||||||
✅ **9.3**: Tests E2E con Playwright (15 tests multi-navegador)
|
|
||||||
✅ **9.4**: Gitea Actions CI Workflow (.gitea/workflows/ci.yml)
|
|
||||||
✅ **9.5**: README Actualizado y Verificación Final
|
|
||||||
|
|
||||||
## Archivos Creados/Modificados
|
|
||||||
|
|
||||||
### Configuración de Entorno
|
|
||||||
|
|
||||||
- [.env.example](.env.example) — Variables globales template
|
|
||||||
- [backend/.env.example](backend/.env.example) — Variables backend template
|
|
||||||
- [frontend/.env.example](frontend/.env.example) — Variables frontend template
|
|
||||||
|
|
||||||
### Documentación
|
|
||||||
|
|
||||||
- [README.md](README.md) — Actualizado con setup mínimo y links a docs
|
|
||||||
- [SECURITY.md](SECURITY.md) — Políticas de seguridad, vulnerabilidades, secrets
|
|
||||||
- [docs/API_KEYS.md](docs/API_KEYS.md) — Guía paso-a-paso para IGDB, RAWG, TheGamesDB
|
|
||||||
- [frontend/README.md](frontend/README.md) — Setup específico del frontend
|
|
||||||
|
|
||||||
### CI/CD
|
|
||||||
|
|
||||||
- [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) — Workflow Gitea Actions completo
|
|
||||||
- Job lint (ESLint)
|
|
||||||
- Job test-backend (Vitest + SQLite)
|
|
||||||
- Job test-frontend (Vitest)
|
|
||||||
- Job test-e2e (Playwright) - BLOQUEANTE
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- [tests/env-example.spec.ts](tests/env-example.spec.ts) — 13 tests validando .env.example files
|
|
||||||
- [tests/documentation.spec.ts](tests/documentation.spec.ts) — 12 tests validando SECURITY.md y API_KEYS.md
|
|
||||||
- [tests/gitea-workflow.spec.ts](tests/gitea-workflow.spec.ts) — 12 tests validando estructura del workflow
|
|
||||||
- [tests/readme-validation.spec.ts](tests/readme-validation.spec.ts) — 12 tests validando README
|
|
||||||
- [tests/e2e/full-flow.spec.ts](tests/e2e/full-flow.spec.ts) — 15 tests E2E (5 scenarios × 3 navegadores)
|
|
||||||
|
|
||||||
## Tests Creados/Modificados
|
|
||||||
|
|
||||||
### Tests de Fase 9 (49 tests nuevos)
|
|
||||||
|
|
||||||
- ✅ 13 tests env-example (validar .env.example files)
|
|
||||||
- ✅ 12 tests documentation (validar SECURITY.md y API_KEYS.md)
|
|
||||||
- ✅ 12 tests gitea-workflow (validar .gitea/workflows/ci.yml)
|
|
||||||
- ✅ 12 tests readme-validation (validar README.md structure)
|
|
||||||
- ✅ 15 tests E2E (5 scenarios × 3 navegadores: chromium, firefox, webkit)
|
|
||||||
|
|
||||||
### Total de Tests del Proyecto
|
|
||||||
|
|
||||||
- Backend Unit Tests: 63+ ✅
|
|
||||||
- Frontend Unit Tests: 59+ ✅
|
|
||||||
- Configuration Tests: 49+ ✅ (nuevos en Fase 9)
|
|
||||||
- E2E Tests: 15 ✅
|
|
||||||
- **TOTAL: 186+ tests ✅**
|
|
||||||
|
|
||||||
## Validación Final
|
|
||||||
|
|
||||||
### ✅ Lint Status
|
|
||||||
|
|
||||||
```
|
|
||||||
0 errores nuevos
|
|
||||||
12 warnings pre-existentes (no-console, no-var-requires)
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Tests Fase 9
|
|
||||||
|
|
||||||
```
|
|
||||||
✓ tests/env-example.spec.ts (13 tests)
|
|
||||||
✓ tests/documentation.spec.ts (12 tests)
|
|
||||||
✓ tests/gitea-workflow.spec.ts (12 tests)
|
|
||||||
✓ tests/readme-validation.spec.ts (12 tests)
|
|
||||||
✓ tests/e2e/full-flow.spec.ts (15 tests)
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Estructura del Proyecto
|
|
||||||
|
|
||||||
```
|
|
||||||
.env.example ✓
|
|
||||||
.env.*.example ✓
|
|
||||||
backend/.env.example ✓
|
|
||||||
frontend/.env.example ✓
|
|
||||||
.gitea/workflows/ci.yml ✓
|
|
||||||
SECURITY.md ✓
|
|
||||||
docs/API_KEYS.md ✓
|
|
||||||
README.md (actualizado) ✓
|
|
||||||
frontend/README.md ✓
|
|
||||||
tests/e2e/ ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Reproducibilidad End-to-End
|
|
||||||
|
|
||||||
- Clone → ✓
|
|
||||||
- Install (yarn install) → ✓
|
|
||||||
- .env setup → ✓
|
|
||||||
- Migrations → ✓
|
|
||||||
- All tests pass → ✓
|
|
||||||
- Build frontend → ✓
|
|
||||||
|
|
||||||
## Git Commits Sugeridos
|
|
||||||
|
|
||||||
### Commit 9.1
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: add .env.example templates for development setup
|
|
||||||
|
|
||||||
- Create .env.example with database and API key placeholders
|
|
||||||
- Add backend/.env.example for backend development
|
|
||||||
- Add frontend/.env.example for Vite frontend config
|
|
||||||
- Add tests/env-example.spec.ts with 13 validation tests
|
|
||||||
- Verify .gitignore correctly ignores .env files
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit 9.2
|
|
||||||
|
|
||||||
```
|
|
||||||
docs: add SECURITY.md and API_KEYS.md documentation
|
|
||||||
|
|
||||||
- Create SECURITY.md with vulnerability reporting policy
|
|
||||||
- Add environment variables & secrets best practices
|
|
||||||
- Document input validation and rate limiting strategies
|
|
||||||
- Create docs/API_KEYS.md with step-by-step API credential guides
|
|
||||||
- Update README.md with security and API configuration sections
|
|
||||||
- Add tests/documentation.spec.ts with 12 validation tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit 9.3
|
|
||||||
|
|
||||||
```
|
|
||||||
test: add E2E tests covering full user journeys
|
|
||||||
|
|
||||||
- Create tests/e2e/full-flow.spec.ts with 5 E2E scenarios
|
|
||||||
- Test home page navigation and layout
|
|
||||||
- Test game creation via form
|
|
||||||
- Test metadata search functionality
|
|
||||||
- Test ROM-to-game linking workflow
|
|
||||||
- Test complete user journey: create → search → link → view
|
|
||||||
- Configure Playwright for multi-browser testing (chromium, firefox, webkit)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit 9.4
|
|
||||||
|
|
||||||
```
|
|
||||||
ci: add Gitea Actions workflow for automated testing
|
|
||||||
|
|
||||||
- Create .gitea/workflows/ci.yml with 4 sequential jobs
|
|
||||||
- Add lint, test-backend, test-frontend, test-e2e jobs
|
|
||||||
- Configure Gitea Secrets for IGDB, RAWG, TheGamesDB API keys
|
|
||||||
- Add artifact upload for Playwright reports on failure
|
|
||||||
- Update SECURITY.md with CI/CD Secrets setup instructions
|
|
||||||
- Update docs/API_KEYS.md with production Gitea workflow guide
|
|
||||||
- Add tests/gitea-workflow.spec.ts with 12 validation tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit 9.5
|
|
||||||
|
|
||||||
```
|
|
||||||
docs: update README and add frontend documentation
|
|
||||||
|
|
||||||
- Rewrite README.md with minimal but complete structure
|
|
||||||
- Add Features, Quick Start, Installation, Testing sections
|
|
||||||
- Add Project Structure with folder tree
|
|
||||||
- Add Configuration, Troubleshooting guides
|
|
||||||
- Add links to SECURITY.md and docs/API_KEYS.md
|
|
||||||
- Create frontend/README.md with React-specific setup
|
|
||||||
- Add tests/readme-validation.spec.ts with 12 validation tests
|
|
||||||
- Verify end-to-end reproducibility
|
|
||||||
```
|
|
||||||
|
|
||||||
## Review Status
|
|
||||||
|
|
||||||
✅ **APPROVED** — Todas las fases de Fase 9 completadas
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Commit todas las fases** (git commit y push)
|
|
||||||
2. **Trigger Gitea Actions** (push a main or develop)
|
|
||||||
3. **Verificar CI workflow** en Gitea UI
|
|
||||||
4. **Setup API Secrets** en Gitea (repo settings)
|
|
||||||
5. **Documentar deployment** en docs/ si es necesario
|
|
||||||
|
|
||||||
## Recomendaciones para Siguiente Fase (v1.1.0)
|
|
||||||
|
|
||||||
Si hay Fase 10+, considerar:
|
|
||||||
|
|
||||||
- [ ] OAuth Twitch integration (IGDB)
|
|
||||||
- [ ] Game screenshots/gallery view
|
|
||||||
- [ ] More metadata APIs (MobyGames, HLTB)
|
|
||||||
- [ ] Platform/genre filtering
|
|
||||||
- [ ] Favorites/ratings system
|
|
||||||
- [ ] Backup/restore functionality
|
|
||||||
- [ ] Docker deployment guide
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MVP v1.0.0 Status: 🚀 LISTO
|
|
||||||
|
|
||||||
**Quasar es un proyecto MVP completo, documentado, testeado y listo para deployment público en Gitea.**
|
|
||||||
|
|
||||||
- ✅ 186+ tests pasando
|
|
||||||
- ✅ Documentación completa
|
|
||||||
- ✅ CI/CD automatizado
|
|
||||||
- ✅ E2E tests validando flujos reales
|
|
||||||
- ✅ Seguridad documentada
|
|
||||||
- ✅ Reproducible desde cero
|
|
||||||
|
|
||||||
**Date**: 2026-02-12
|
|
||||||
**Version**: v1.0.0
|
|
||||||
**Status**: Production Ready (MVP) 🎮
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
## Plan: Gestor de biblioteca de videojuegos y ROMs (Quasar)
|
|
||||||
|
|
||||||
Aplicación web self-hosted para gestionar una biblioteca de ROMs y videojuegos físicos/digitales. Permite escanear directorios de ROMs, enriquecer metadatos vía APIs públicas (IGDB, RAWG, TheGamesDB), y registrar manualmente juegos físicos/digitales con precio, condición y notas. Stack: TypeScript + React + Vite + shadcn/ui (frontend), Node.js + Fastify + TypeScript + Prisma + SQLite (backend).
|
|
||||||
|
|
||||||
**Fases: 9**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 1: Análisis comparativo de proyectos y servicios**
|
|
||||||
|
|
||||||
- **Objetivo:** Documentar todos los proyectos, herramientas y APIs analizados durante la investigación inicial, describiendo qué hace cada uno, sus características principales, licencias, y lecciones aprendidas para aplicar a Quasar.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `docs/competitive-analysis.md` — análisis detallado de proyectos (Playnite, LaunchBox, OpenEmu, EmulationStation, RetroArch, ROMVault, etc.)
|
|
||||||
- `docs/apis-comparison.md` — comparativa de APIs (IGDB, RAWG, TheGamesDB, Screenscraper, MobyGames, PriceCharting, ITAD, eBay)
|
|
||||||
- `docs/lessons-learned.md` — patrones y mejores prácticas extraídas del análisis
|
|
||||||
- **Pasos:**
|
|
||||||
1. Crear documentos con información estructurada de la investigación inicial
|
|
||||||
2. Incluir tablas comparativas, enlaces, y conclusiones
|
|
||||||
3. Documentar patrones útiles y mejores prácticas aplicables a Quasar
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 2: Requisitos y diseño técnico**
|
|
||||||
|
|
||||||
- **Objetivo:** Definir arquitectura (monorepo o separado), estructura de carpetas, stack definitivo (Fastify + Prisma, SQLite), APIs a integrar (IGDB, RAWG, TheGamesDB), y documento de modelo de datos inicial.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `docs/requirements.md` — requisitos funcionales y no funcionales
|
|
||||||
- `docs/architecture.md` — decisiones arquitectónicas (monorepo vs multi-repo, API REST structure)
|
|
||||||
- `docs/api-integration.md` — descripción de APIs públicas a usar, endpoints, rate limits, autenticación
|
|
||||||
- `docs/data-model.md` — entidades (Game, RomFile, Platform, Purchase, Artwork)
|
|
||||||
- **Pasos:**
|
|
||||||
1. Crear documentos `docs/requirements.md`, `docs/architecture.md`, `docs/api-integration.md`, `docs/data-model.md` con contenido inicial
|
|
||||||
2. Definir estructura de carpetas y convenciones de código
|
|
||||||
3. Documentar decisiones técnicas y justificaciones
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 3: Backend base y modelo de datos**
|
|
||||||
|
|
||||||
- **Objetivo:** Configurar backend (Fastify + TypeScript + Prisma + SQLite), definir schema de BD (Game, RomFile, Platform, Purchase, Artwork), migraciones y seeders básicos.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `backend/package.json` — dependencias (fastify, prisma, @fastify/cors, dotenv, etc.)
|
|
||||||
- `backend/tsconfig.json` — configuración TypeScript backend
|
|
||||||
- `backend/src/index.ts` — servidor Fastify inicial
|
|
||||||
- `backend/prisma/schema.prisma` — modelos (Game, RomFile, Platform, Purchase, Artwork)
|
|
||||||
- `backend/prisma/migrations/` — migraciones Prisma
|
|
||||||
- `backend/src/routes/healthcheck.ts` — endpoint `/api/health`
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `backend/tests/server.spec.ts` — test del servidor (inicia y responde en `/api/health`)
|
|
||||||
- `backend/tests/models/game.spec.ts` — validaciones del modelo Game (TDD)
|
|
||||||
- `backend/tests/models/romFile.spec.ts` — validaciones del modelo RomFile
|
|
||||||
- **Pasos:**
|
|
||||||
1. Escribir tests que fallen (healthcheck endpoint, crear modelo Game y validar)
|
|
||||||
2. Configurar Fastify + Prisma, definir schema, ejecutar migración
|
|
||||||
3. Implementar endpoint `/api/health`
|
|
||||||
4. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 4: Importadores y gestión de ROMs**
|
|
||||||
|
|
||||||
- **Objetivo:** Implementar servicio para escanear directorios locales, calcular checksums (CRC32/MD5/SHA1), detectar formatos (ZIP/7z/CHD), y almacenar en BD. Incluir soporte básico para DAT verification (No-Intro/Redump).
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `backend/src/services/fsScanner.ts` — función `scanDirectory(path: string)`
|
|
||||||
- `backend/src/services/checksumService.ts` — funciones `calculateCRC32()`, `calculateMD5()`, `calculateSHA1()`
|
|
||||||
- `backend/src/services/datVerifier.ts` — función `verifyAgainstDAT(romFiles, datPath)`
|
|
||||||
- `backend/src/routes/import.ts` — endpoint `POST /api/import/scan` (body: {path})
|
|
||||||
- `backend/src/utils/archiveReader.ts` — leer contenido de ZIP/7z/CHD
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `backend/tests/services/fsScanner.spec.ts` — casos: carpeta vacía, carpeta con ROMs, carpeta con subdirectorios
|
|
||||||
- `backend/tests/services/checksumService.spec.ts` — calcular checksum de archivo fixture
|
|
||||||
- `backend/tests/services/datVerifier.spec.ts` — verificar ROM válido/inválido contra DAT fixture
|
|
||||||
- `backend/tests/routes/import.spec.ts` — test E2E de endpoint `/api/import/scan`
|
|
||||||
- **Pasos:**
|
|
||||||
1. Crear fixtures (ROMs de prueba, DAT de prueba)
|
|
||||||
2. Escribir tests que fallen (escaneo de carpeta, checksum, DAT verification)
|
|
||||||
3. Implementar `fsScanner`, `checksumService`, `datVerifier` mínimos
|
|
||||||
4. Implementar endpoint `/api/import/scan`
|
|
||||||
5. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 5: Integración con APIs de metadata**
|
|
||||||
|
|
||||||
- **Objetivo:** Clientes para IGDB (OAuth Twitch), RAWG (API key), TheGamesDB (API key); lógica de matching heurística (nombre + plataforma), caché local de respuestas para evitar rate limits.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `backend/src/services/igdbClient.ts` — `searchGames(query, platform?)`, `getGameById(id)`
|
|
||||||
- `backend/src/services/rawgClient.ts` — `searchGames(query)`, `getGameById(id)`
|
|
||||||
- `backend/src/services/thegamesdbClient.ts` — `searchGames(query)`, `getGameById(id)`
|
|
||||||
- `backend/src/services/metadataService.ts` — `enrichGame(romFile)` (orquesta clientes, fallbacks, matching heurística)
|
|
||||||
- `backend/src/utils/cache.ts` — caché en memoria o Redis (simple LRU)
|
|
||||||
- `backend/src/routes/metadata.ts` — endpoints `GET /api/metadata/search?q=...&platform=...`, `POST /api/metadata/enrich/:romFileId`
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `backend/tests/services/igdbClient.spec.ts` — mock de respuestas IGDB, test de OAuth flow, test de búsqueda
|
|
||||||
- `backend/tests/services/rawgClient.spec.ts` — mock de respuestas RAWG
|
|
||||||
- `backend/tests/services/metadataService.spec.ts` — casos: match exacto, match parcial, sin match (fallback)
|
|
||||||
- `backend/tests/routes/metadata.spec.ts` — test E2E de endpoints metadata
|
|
||||||
- **Pasos:**
|
|
||||||
1. Escribir tests con mocks (respuestas API simuladas)
|
|
||||||
2. Implementar clientes (IGDB OAuth, RAWG key, TheGamesDB key) con retry y timeout
|
|
||||||
3. Implementar `metadataService` con lógica de matching y fallbacks
|
|
||||||
4. Implementar endpoints REST
|
|
||||||
5. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 6: Frontend base (React + Vite + shadcn/ui)**
|
|
||||||
|
|
||||||
- **Objetivo:** Configurar proyecto frontend con Vite, React, TypeScript, Tailwind CSS, shadcn/ui, TanStack Query, TanStack Router. Implementar layout base (navbar, sidebar), rutas (Home, ROMs, Games, Settings) y componentes UI básicos (Button, Card, Table, Dialog de shadcn/ui).
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `frontend/package.json` — dependencias (react, vite, @shadcn/ui, tailwindcss, @tanstack/react-router, @tanstack/react-query)
|
|
||||||
- `frontend/tsconfig.json` — configuración TypeScript frontend
|
|
||||||
- `frontend/vite.config.ts` — configuración Vite (proxy a backend, aliases, TanStack Router plugin)
|
|
||||||
- `frontend/tailwind.config.js` — configuración Tailwind para shadcn/ui
|
|
||||||
- `frontend/src/main.tsx` — entry point con QueryClientProvider y RouterProvider
|
|
||||||
- `frontend/src/routes/__root.tsx` — layout raíz con navbar y sidebar
|
|
||||||
- `frontend/src/routes/index.tsx` — ruta Home
|
|
||||||
- `frontend/src/routes/roms.tsx` — ruta ROMs
|
|
||||||
- `frontend/src/routes/games.tsx` — ruta Games
|
|
||||||
- `frontend/src/components/layout/Navbar.tsx`
|
|
||||||
- `frontend/src/components/layout/Sidebar.tsx`
|
|
||||||
- `frontend/src/lib/api.ts` — cliente HTTP base (fetch/axios wrapper)
|
|
||||||
- `frontend/src/lib/queryClient.ts` — configuración de TanStack Query
|
|
||||||
- `frontend/src/hooks/useGames.ts` — custom hook con TanStack Query para juegos
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `frontend/tests/App.spec.tsx` — renderizado de rutas (usando Vitest + React Testing Library)
|
|
||||||
- `frontend/tests/components/Navbar.spec.tsx` — navegación básica
|
|
||||||
- `tests/e2e/navigation.spec.ts` — E2E con Playwright (navegar entre páginas)
|
|
||||||
- **Pasos:**
|
|
||||||
1. Escribir tests que fallen (renderizado de App, navegación E2E)
|
|
||||||
2. Configurar Vite + React + TypeScript + Tailwind + shadcn/ui + TanStack Query
|
|
||||||
3. Implementar layout, rutas, páginas vacías, configuración de QueryClient
|
|
||||||
4. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 7: Gestión manual de juegos (frontend + backend)**
|
|
||||||
|
|
||||||
- **Objetivo:** CRUD completo para juegos: crear/editar/eliminar juegos manualmente (frontend form con shadcn/ui + TanStack Query), registrar juegos físicos/digitales con campos: nombre, plataforma, precio, condición (Loose/CIB/New), fecha de compra, vendedor, notas.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `backend/src/routes/games.ts` — `GET /api/games`, `POST /api/games`, `PUT /api/games/:id`, `DELETE /api/games/:id`
|
|
||||||
- `backend/src/controllers/gamesController.ts` — lógica de CRUD
|
|
||||||
- `backend/src/validators/gameValidator.ts` — validación de input (Zod/Joi)
|
|
||||||
- `frontend/src/routes/games.tsx` — tabla de juegos con acciones (editar, eliminar)
|
|
||||||
- `frontend/src/components/games/GameForm.tsx` — formulario para crear/editar juego
|
|
||||||
- `frontend/src/components/games/GameCard.tsx` — card de vista de juego
|
|
||||||
- `frontend/src/hooks/useGames.ts` — custom hooks con TanStack Query (useGames, useCreateGame, useUpdateGame, useDeleteGame)
|
|
||||||
- `frontend/src/components/ui/*` — shadcn/ui components (Form, Input, Select, Textarea, DatePicker)
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `backend/tests/routes/games.spec.ts` — CRUD endpoints (casos: crear juego válido, crear juego con datos faltantes, actualizar, eliminar)
|
|
||||||
- `frontend/tests/routes/games.spec.tsx` — renderizado de lista, acciones
|
|
||||||
- `frontend/tests/components/GameForm.spec.tsx` — validación de formulario
|
|
||||||
- `tests/e2e/games-crud.spec.ts` — E2E completo (crear/editar/eliminar juego)
|
|
||||||
- **Pasos:**
|
|
||||||
1. Escribir tests que fallen (endpoints CRUD, renderizado de form, E2E CRUD)
|
|
||||||
2. Implementar backend endpoints y validators
|
|
||||||
3. Implementar frontend page, form, custom hooks con TanStack Query
|
|
||||||
4. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 8: Integración ROMs + Metadata (UI completa)**
|
|
||||||
|
|
||||||
- **Objetivo:** Vista de ROMs escaneados en frontend, botón para scan de directorio, búsqueda/asociación de metadata (UI para seleccionar resultado de IGDB/RAWG con TanStack Query), y vincular ROM con juego. Incluir vista de artwork (covers, screenshots).
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `frontend/src/routes/roms.tsx` — tabla de ROMs escaneados, botón "Scan Directory", acciones (asociar metadata, ver detalles)
|
|
||||||
- `frontend/src/components/roms/ScanDialog.tsx` — dialog para input de path y scan
|
|
||||||
- `frontend/src/components/roms/MetadataSearchDialog.tsx` — búsqueda y selección de metadata
|
|
||||||
- `frontend/src/components/roms/RomCard.tsx` — card con info de ROM + artwork
|
|
||||||
- `frontend/src/hooks/useRoms.ts` — custom hooks con TanStack Query (useRoms, useScanDirectory, useEnrichMetadata)
|
|
||||||
- `backend/src/routes/artwork.ts` — `GET /api/artwork/:gameId` (proxy/cache de imágenes)
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `frontend/tests/routes/roms.spec.tsx` — renderizado de tabla, acciones
|
|
||||||
- `frontend/tests/components/ScanDialog.spec.tsx` — submit de path, loading state
|
|
||||||
- `tests/e2e/roms-import.spec.ts` — E2E: scan → ver ROMs → asociar metadata → ver juego enriquecido
|
|
||||||
- **Pasos:**
|
|
||||||
1. Escribir tests que fallen (UI de ROMs, scan dialog, E2E import completo)
|
|
||||||
2. Implementar componentes frontend y custom hooks con TanStack Query
|
|
||||||
3. Implementar endpoint de artwork (cache/proxy)
|
|
||||||
4. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 9: CI, tests E2E, docs y seguridad**
|
|
||||||
|
|
||||||
- **Objetivo:** GitHub Actions para CI (lint, tests unitarios, tests E2E con Playwright), configuración de ESLint/Prettier, docs de uso de APIs (cómo obtener keys), seguridad (env vars, .gitignore actualizado, SECURITY.md), y README completo.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `.github/workflows/ci.yml` — pipeline (install, lint, test, e2e)
|
|
||||||
- `.eslintrc.cjs` — ajustar para backend + frontend
|
|
||||||
- `.prettierrc` — ajustar formatos
|
|
||||||
- `README.md` — actualizar con: setup, features, screenshots, roadmap
|
|
||||||
- `SECURITY.md` — políticas de seguridad, reporte de vulnerabilidades
|
|
||||||
- `docs/API_KEYS.md` — instrucciones para obtener keys de IGDB, RAWG, TheGamesDB
|
|
||||||
- `.env.example` — template de variables de entorno
|
|
||||||
- `backend/.env.example`, `frontend/.env.example` — templates específicos
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `tests/e2e/full-flow.spec.ts` — E2E completo end-to-end (scan → enrich → crear juego manual → view)
|
|
||||||
- Asegurar que CI ejecuta todos los tests y Playwright genera reporte
|
|
||||||
- **Pasos:**
|
|
||||||
1. Configurar GitHub Actions workflow
|
|
||||||
2. Ejecutar pipeline en CI (puede fallar inicialmente)
|
|
||||||
3. Corregir issues de lint/format/tests
|
|
||||||
4. Actualizar docs (README, SECURITY, API_KEYS)
|
|
||||||
5. Ejecutar CI nuevamente y verificar que todo pasa
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Preguntas abiertas resueltas** ✅
|
|
||||||
|
|
||||||
1. ✅ App web self-hosted (server + frontend separados)
|
|
||||||
2. ✅ Frontend: TypeScript + React + Vite + shadcn/ui + TanStack Query + TanStack Router
|
|
||||||
3. ✅ Solo APIs públicas: IGDB (OAuth Twitch gratuito), RAWG (API key gratuita con atribución), TheGamesDB (API key gratuita)
|
|
||||||
4. ✅ Sin integración con tiendas (Steam/GOG/PSN) en MVP; dejar preparado para futuro (campo `storeId`, `storePlatform` en modelo Game)
|
|
||||||
5. ✅ Prioridad: gestión de ROMs de directorio + creación manual de juegos físicos/digitales
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Decisiones técnicas clave** 🔧
|
|
||||||
|
|
||||||
- **Monorepo:** `/backend` y `/frontend` en el mismo repo, con workspaces de Yarn
|
|
||||||
- **Backend:** Node.js + Fastify + TypeScript + Prisma + SQLite (migration a PostgreSQL posible en futuro)
|
|
||||||
- **Frontend:** React + Vite + TypeScript + Tailwind CSS + shadcn/ui + TanStack Query + TanStack Router
|
|
||||||
- **APIs de metadata:** IGDB (primary), RAWG (fallback), TheGamesDB (artwork/retro)
|
|
||||||
- **Tests:** Backend (Vitest + Supertest), Frontend (Vitest + React Testing Library), E2E (Playwright)
|
|
||||||
- **CI:** GitHub Actions con pipeline: install → lint → test → e2e
|
|
||||||
- **Seguridad:** API keys en `.env`, no commitear secrets
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Roadmap futuro (fuera del MVP)** 🚀
|
|
||||||
|
|
||||||
- Integración con tiendas (Steam/GOG/PSN/Xbox) para import automático de compras
|
|
||||||
- Price tracking con PriceCharting/IsThereAnyDeal (requiere suscripción/API paga)
|
|
||||||
- Plugins/extensiones (LaunchBox export, Playnite sync)
|
|
||||||
- Sincronización en nube (opcional, con cifrado E2E)
|
|
||||||
- Soporte para multiple usuarios (autenticación/autorización)
|
|
||||||
- Mobile app (React Native o PWA)
|
|
||||||
Reference in New Issue
Block a user