Refactor code structure for improved readability and maintainability
Some checks failed
CI / lint (push) Failing after 1m4s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped

This commit is contained in:
2026-02-22 18:18:46 +01:00
parent c27e9bec7a
commit 0c9c408564
98 changed files with 8207 additions and 5250 deletions

147
README.md
View File

@@ -32,7 +32,7 @@ yarn install
cp .env.example .env.local
# 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
cd backend
@@ -70,21 +70,33 @@ quasar/
│ │ └── controllers/ # Request handlers
│ └── tests/ # Vitest unit tests (63+ tests)
├── frontend/ # React + Vite + React Query
├── frontend/ # React 18 + Vite + TypeScript + TanStack
│ ├── src/
│ │ ├── routes/ # Pages (/games, /roms, etc.)
│ │ ├── components/ # React components (Forms, Dialogs, Cards)
│ │ ── hooks/ # TanStack Query hooks
└── tests/ # Vitest + React Testing Library (59+ tests)
│ │ ├── components/ # shadcn/ui components + custom components
│ │ ├── pages/ # Application pages (Dashboard, Games, etc.)
│ │ ── api/ # API services and types
│ ├── 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/
│ ├── e2e/ # Playwright E2E tests (15 tests)
│ └── *.spec.ts # Config validation tests
├── docs/ # Documentation
│ ├── API_KEYS.md # How to get API credentials
│ ├── SECURITY.md # Security guidelines
── ...
│ ├── README.md # Documentation index
│ ├── 01-conceptos/ # Fundamental concepts and requirements
── 02-tecnico/ # Technical documentation
│ ├── 03-analisis/ # Comparative analysis
│ └── 04-operaciones/ # Operations and deployment
├── .gitea/
│ └── workflows/
@@ -103,7 +115,7 @@ Copy `.env.example` to `.env.local` (or `.env.development`) and fill in:
# Database (local SQLite)
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_SECRET=your_client_secret
RAWG_API_KEY=your_api_key
@@ -115,10 +127,12 @@ PORT=3000
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
### General Tests
```bash
# Run all tests (unit + config)
yarn test
@@ -139,6 +153,97 @@ yarn lint
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
### Backend won't start
@@ -158,7 +263,7 @@ Error: EADDRINUSE: address already in use :::3000
### 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
→ Test with: curl http://localhost:3000/api/metadata/search\?q\=Mario
```
@@ -172,12 +277,12 @@ Error: EADDRINUSE: address already in use :::3000
## 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
- **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)
- **APIs:** IGDB (OAuth), RAWG, TheGamesDB
@@ -193,10 +298,13 @@ For security guidelines, see [SECURITY.md](SECURITY.md).
## Documentation
- **[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/architecture.md](docs/architecture.md)** — System architecture and design decisions
- **[docs/data-model.md](docs/data-model.md)** — Database schema and entities
- **[docs/requirements.md](docs/requirements.md)** — Project requirements and use cases
- **[docs/README.md](docs/README.md)** — Documentation index and navigation guide
- **[docs/01-conceptos/requirements.md](docs/01-conceptos/requirements.md)** — Project requirements and use cases
- **[docs/01-conceptos/architecture.md](docs/01-conceptos/architecture.md)** — System architecture and design decisions
- **[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
@@ -252,5 +360,6 @@ MIT (or choose your license)
---
**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 ✅
**Documentation:** Reorganized and consolidated ✅

View File

@@ -1,15 +1,222 @@
# Comparativa de APIs — cobertura, límites, coste y calidad
# APIs del Sistema — Guía completa
**Introducció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.
Este documento integra toda la información sobre APIs del sistema: obtención de claves, prioridades, estrategias, comparación y configuració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.
- **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).
- **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.
- **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.
- **Enlace:** https://rawg.io/apidocs
---
### TheGamesDB
#### TheGamesDB
- **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).
@@ -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.
- **Enlace:** https://api.thegamesdb.net/
---
### ScreenScraper
#### ScreenScraper
- **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.
@@ -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.
- **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.
- **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.
- **Enlace:** https://www.mobygames.com/api/subscribe/
---
### PriceCharting
#### PriceCharting
- **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.
@@ -90,9 +287,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
- **Costes / modelo:** Servicio comercial (licencias / API keys pagadas).
- **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.
- **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.
- **Enlace:** https://docs.isthereanydeal.com/
---
### eBay
#### eBay
- **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.
@@ -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.
- **Enlace:** https://developer.ebay.com/
---
## Tabla resumida
### Tabla resumida
| 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 |
| 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.**
@@ -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).
- **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.
- **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.
### Variables de entorno (ejemplos)
```
IGDB_CLIENT_ID=...
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

File diff suppressed because it is too large Load Diff

View 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_

View File

@@ -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
View 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_

View File

@@ -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
View 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?

View File

@@ -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
cd frontend
yarn install
yarn dev # Start Vite dev server on http://localhost:5173
- [@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
- [@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
## 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
yarn test # Run Vitest in watch mode
yarn test:run # Run once
```js
// eslint.config.js
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
View 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,
},
},
])

View File

@@ -1,33 +1,10 @@
<!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">
<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" />
<title>Quasar</title>
<title>frontend</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,38 +1,54 @@
{
"name": "quasar-frontend",
"version": "0.0.0",
"name": "frontend",
"private": true,
"packageManager": "yarn@4.12.0",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"lint": "echo \"No lint configured\"",
"format": "prettier --write ."
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.3.0",
"@tanstack/react-query": "^4.34.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.0",
"zod": "^3.22.0"
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@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": {
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"jsdom": "^22.1.0",
"postcss": "^8.4.24",
"tailwindcss": "^3.4.7",
"typescript": "^5.2.2",
"vite": "^5.1.0",
"vitest": "^0.34.1"
"@eslint/js": "^9.39.1",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/postcss": "^4.2.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.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"
}
}

View File

@@ -1,18 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

1
frontend/public/vite.svg Normal file
View 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
View 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;
}

View File

@@ -1,13 +1,38 @@
import React from 'react';
import Navbar from './components/layout/Navbar';
import { Button } from '@/components/ui/button';
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 (
<div>
<Navbar />
<main>
<h1>Quasar</h1>
</main>
<div className="min-h-screen bg-background">
<div className="container mx-auto p-6">
<Card>
<CardHeader>
<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>
);
}
export default App;

165
frontend/src/api/client.ts Normal file
View 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
View 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');
};

View 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');
};

View 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');
};

View 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
View 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');
};

View 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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 (
<aside style={{ padding: 12 }}>
<div>Sidebar (placeholder)</div>
</aside>
<>
{/* Overlay para móviles */}
{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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
};

View 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 };

View 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 };

View 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),
}),
};

View File

@@ -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 });
},
});
}

View File

@@ -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
View 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;
}
}

View File

@@ -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 }),
}),
},
};

View File

@@ -1,3 +0,0 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient();

View 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));
}

View File

@@ -1,32 +1,10 @@
import React from 'react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient';
import App from './App';
import './styles.css';
import './styles/globals.css';
import App from './App.tsx';
const rootEl = document.getElementById('root');
if (rootEl) {
createRoot(rootEl).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
createRoot(document.getElementById('root')!).render(
<StrictMode>
<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>
</StrictMode>
);

View 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>
);
}

View 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>
);
}

View 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>&nbsp;</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>
);
}

View 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
View 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,
]),
});

View File

@@ -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>
);
}

View File

@@ -1,9 +0,0 @@
import React from 'react';
export default function Home(): JSX.Element {
return (
<div>
<h2>Home</h2>
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -1,2 +0,0 @@
import '@testing-library/jest-dom';
import '@testing-library/jest-dom';

View File

@@ -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;
}

View 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;
}
}

View File

@@ -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
View 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;
}

View File

@@ -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;
}

View File

@@ -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: [],
};

View 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;

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View 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"]
}

View File

@@ -1,19 +1,7 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"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"]
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View File

@@ -1,22 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path,
},
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.ts'],
include: ['tests/**/*.spec.tsx'],
},
});

View File

@@ -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'],
},
});

View File

@@ -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)

View File

@@ -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)
```

View File

@@ -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

View File

@@ -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)

View File

@@ -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` — 56 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

View File

@@ -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

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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) 🎮

View File

@@ -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)

3835
yarn.lock

File diff suppressed because it is too large Load Diff