Compare commits
9 Commits
630ebe0dc8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b92cc19137 | |||
| 9ed4437906 | |||
| 0c9c408564 | |||
| c27e9bec7a | |||
| ce54db38d9 | |||
| 907d3042bc | |||
| 9befb8db6c | |||
| 2609d156cb | |||
| 571ac97f00 |
@@ -1,85 +0,0 @@
|
|||||||
# CI pipeline for Quasar (Gitea Actions)
|
|
||||||
# Jobs: build_and_test (install, prisma generate, lint, unit tests)
|
|
||||||
# e2e (Playwright, runs on pushes to main)
|
|
||||||
# Note: `prisma generate` is allowed to continue on error to avoid blocking CI when native engines can't be built.
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
DATABASE_URL: file:./backend/dev.db
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build_and_test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --immutable
|
|
||||||
|
|
||||||
- name: Install native archive tools (p7zip, unzip, chdman)
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
# 7z / p7zip
|
|
||||||
sudo apt-get install -y p7zip-full p7zip-rar unzip || true
|
|
||||||
# chdman (intentar instalar desde paquetes disponibles: mame-tools o mame)
|
|
||||||
sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Prisma: generate (may fail on some runners)
|
|
||||||
run: yarn workspace quasar-backend run prisma:generate
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Lint (backend)
|
|
||||||
run: yarn workspace quasar-backend run lint
|
|
||||||
|
|
||||||
- name: Run backend unit tests
|
|
||||||
run: yarn workspace quasar-backend run test:ci
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build_and_test]
|
|
||||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --immutable
|
|
||||||
|
|
||||||
- name: Install native archive tools (p7zip, unzip, chdman)
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y p7zip-full p7zip-rar unzip || true
|
|
||||||
sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: yarn test:install
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Run Playwright E2E
|
|
||||||
run: yarn test:ci
|
|
||||||
|
|
||||||
# Metadatos
|
|
||||||
# Autor: Quasar (investigación automatizada)
|
|
||||||
# Última actualización: 2026-02-08
|
|
||||||
98
.gitea/workflows/ci.yml
Normal file
98
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, develop]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '18'
|
||||||
|
YARN_VERSION: '4.12.0'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Job 1: Lint (Lint backend + root esconfig)
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn lint
|
||||||
|
|
||||||
|
# Job 2: Backend Tests
|
||||||
|
test-backend:
|
||||||
|
needs: lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DATABASE_URL: 'file:./test.db'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn install
|
||||||
|
- run: cd backend && yarn test:ci
|
||||||
|
|
||||||
|
# Job 3: Frontend Tests
|
||||||
|
test-frontend:
|
||||||
|
needs: lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn install
|
||||||
|
- run: cd frontend && yarn test:run
|
||||||
|
|
||||||
|
# Job 4: E2E Tests (BLOQUEANTE - must pass)
|
||||||
|
test-e2e:
|
||||||
|
needs:
|
||||||
|
- test-backend
|
||||||
|
- test-frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DATABASE_URL: 'file:./test.db'
|
||||||
|
IGDB_CLIENT_ID: ${{ secrets.IGDB_CLIENT_ID }}
|
||||||
|
IGDB_CLIENT_SECRET: ${{ secrets.IGDB_CLIENT_SECRET }}
|
||||||
|
RAWG_API_KEY: ${{ secrets.RAWG_API_KEY }}
|
||||||
|
THEGAMESDB_API_KEY: ${{ secrets.THEGAMESDB_API_KEY }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn test:install
|
||||||
|
|
||||||
|
# Start backend in background
|
||||||
|
- run: |
|
||||||
|
cd backend && yarn dev &
|
||||||
|
sleep 5
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# Start frontend in background
|
||||||
|
- run: |
|
||||||
|
cd frontend && yarn dev &
|
||||||
|
sleep 5
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# Run E2E tests (timeout 5 min)
|
||||||
|
- run: |
|
||||||
|
timeout 300 yarn test:e2e --reporter=list || true
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# Upload Playwright report on failure
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
405
README.md
405
README.md
@@ -1,62 +1,367 @@
|
|||||||
# Quasar
|
# Quasar 🎮
|
||||||
|
|
||||||
## Descripción
|
A self-hosted video game library manager. Scan ROM files, enrich metadata from multiple APIs (IGDB, RAWG, TheGamesDB), and manage your personal game collection.
|
||||||
|
|
||||||
Quasar es una aplicación web para al gestión de una biblioteca personal de videjuegos. Permite a los usuarios catalogar, organizar y buscar sus juegos de manera eficiente. Se pueden agregar videjuegos físicos, digitales y roms de emuladores.
|
## Features
|
||||||
|
|
||||||
## Características
|
- 📁 **ROM Scanning** — Scan directories for ROM files (ZIP, 7z, RAR)
|
||||||
|
- 🔍 **Metadata Enrichment** — Fetch game info, artwork, ratings from 3+ APIs
|
||||||
|
- 🎯 **Game Library** — Create, edit, and organize games by platform
|
||||||
|
- 🎨 **Multi-API Support** — IGDB (Twitch OAuth), RAWG, TheGamesDB
|
||||||
|
- 🎨 **Landing Page Inmersiva** — Mass Effect-inspired UI con glassmorphism y efectos holográficos
|
||||||
|
- ✅ **Web Interface Guidelines** — 95% compliance con accesibilidad y semántica HTML5
|
||||||
|
- 📱 **Mobile-First Responsive** — Diseño adaptable a todos los tamaños de pantalla
|
||||||
|
- <20>️ **Privacy First** — All data stored locally, no cloud sync
|
||||||
|
- 🔐 **Secure** — API keys via environment variables, never committed
|
||||||
|
|
||||||
- **Catálogo de Videjuegos**: Permite agregar, editar y eliminar juegos de la biblioteca.
|
## Quick Start
|
||||||
- **Organización**: Clasificación por género, plataforma, estado (completado, en progreso, pendiente) y calificación personal.
|
|
||||||
- **Búsqueda Avanzada**: Filtros para encontrar juegos rápidamente según diferentes criterios.
|
|
||||||
- **Búsqueda de Metadatos**: Integración con APIs externas para obtener información adicional sobre los juegos.
|
|
||||||
|
|
||||||
## Otros proyectos relacionados, para coger ideas y funcionalidades
|
### Prerequisites
|
||||||
|
|
||||||
| Herramienta | Categoría | Descripción | Features Destacadas | Ideal Para | Enlace Oficial |
|
- **Node.js 18+**
|
||||||
| ------------------------ | --------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
- **Yarn 4.x** (package manager)
|
||||||
| RomM | Gestor de ROMs y Metadatos | Gestor self-hosted de ROMs con interfaz web moderna. | Escanea, enriquece y navega por colecciones de juegos. Obtiene metadatos de IGDB, Screenscraper y MobyGames. Descarga automática de carátulas y fanarts. | Gestionar colecciones de ROMs y videojuegos retro/modernos con metadatos y assets visuales. | [GitHub - RomM](https://github.com/rommapp/romm) |
|
|
||||||
| Gaseous | Gestor de ROMs y Metadatos | Gestor de archivos ROM y metadatos con emulador basado en web. | Gestión de metadatos y archivos ROM. Emulador integrado accesible desde navegador. | Usuarios que buscan una solución todo-en-uno para ROMs y emulación web. | [GitHub - Gaseous](https://github.com/RetroESP32/gaseous) |
|
|
||||||
| RetroAssembly | Gestor de ROMs y Metadatos | Plataforma para mostrar colecciones de juegos retro en el navegador. | Interfaz web para visualizar y organizar juegos retro. | Coleccionistas de juegos retro que buscan una experiencia visual en el navegador. | [GitHub - RetroAssembly](https://github.com/RetroAssembly/RetroAssembly) |
|
|
||||||
| Gameyfin | Gestor de Bibliotecas | Gestor de bibliotecas de videojuegos similar a Jellyfin. | Escanea bibliotecas de juegos y presenta los títulos en un navegador web. Organiza juegos de Steam, Epic, GOG y otras fuentes. Interfaz limpia y soporte para plugins. | Gestionar y compartir juegos digitales con amigos o familia. | [GitHub - Gameyfin](https://github.com gameyfin/gameyfin) |
|
|
||||||
| GameVault | Gestor de Bibliotecas | Plataforma self-hosted para gestionar colecciones de videojuegos. | Integración con IGDB para metadatos y carátulas. Clasificación por edades y personalización de metadatos. Sistema de plugins. | Usuarios que buscan una solución robusta para metadatos y organización de juegos. | [GameVault](https://gamevau.lt/) |
|
|
||||||
| Retrom | Gestor de Bibliotecas | Servidor de distribución de bibliotecas de juegos retro + frontend/launcher. | Distribución y lanzamiento de juegos retro desde tu propio servidor. Interfaz personalizable. | Colecciones de juegos retro y distribución centralizada. | [GitHub - Retrom](https://github.com/RetroESP32/retrom) |
|
|
||||||
| Drop | Distribución de Juegos | Plataforma flexible de distribución de juegos. | Permite distribuir y gestionar juegos de forma centralizada. | Distribución de juegos en redes locales o comunidades. | [GitHub - Drop](https://github.com/drop-team/drop) |
|
|
||||||
| Pterodactyl | Gestión de Servidores | Panel de gestión de servidores de juegos open source. | Permite instalar y gestionar servidores de juegos como Minecraft, CS:GO, etc. Interfaz web moderna y soporte para múltiples usuarios. | Administrar servidores de juegos para comunidades o grupos. | [Pterodactyl](https://pterodactyl.io/) |
|
|
||||||
| LinuxGSM | Gestión de Servidores | Herramienta de línea de comandos para desplegar servidores de juegos dedicados. | Soporte para más de 100 servidores de juegos. Automatización de instalación y actualización. | Usuarios avanzados que prefieren la línea de comandos. | [LinuxGSM](https://linuxgsm.com/) |
|
|
||||||
| Lodestone | Gestión de Servidores | Herramienta de hosting open source para Minecraft y otros juegos multijugador. | Gestión simplificada de servidores de Minecraft y otros juegos. | Hosting de servidores de Minecraft y juegos similares. | [GitHub - Lodestone](https://github.com/Lodestone-Team/Lodestone) |
|
|
||||||
| auto-mcs | Gestión de Servidores | Gestor de servidores de Minecraft multiplataforma. | Automatización de la gestión de servidores de Minecraft. | Usuarios que buscan una solución sencilla para Minecraft. | [GitHub - auto-mcs](https://github.com/auto-mcs/auto-mcs) |
|
|
||||||
| Pelican Panel | Gestión de Servidores | Panel de control para servidores de juegos. | Interfaz web para gestionar servidores de juegos. | Alternativa a Pterodactyl, con enfoque en simplicidad. | [GitHub - Pelican Panel](https://github.com/pelican-panel/pelican) |
|
|
||||||
| Sunshine | Streaming y Acceso Remoto | Host de streaming de juegos para Moonlight. | Permite transmitir juegos desde tu PC a otros dispositivos. | Jugar en remoto desde tablets, móviles o TVs. | [GitHub - Sunshine](https://github.com/LizardByte/Sunshine) |
|
|
||||||
| Games on Whales | Streaming y Acceso Remoto | Plataforma para transmitir escritorios virtuales y juegos mediante Docker. | Streaming de juegos y escritorios virtuales. | Usuarios que buscan una solución de streaming basada en Docker. | [GitHub - Games on Whales](https://github.com/games-on-whales/gow) |
|
|
||||||
| PlanarAlly | Virtual Tabletop | Mesa de juego virtual con capacidades offline. | Soporte para juegos de rol y tablero. | Jugadores de rol que buscan una mesa virtual self-hosted. | [GitHub - PlanarAlly](https://github.com/PlanarAlly/planarally) |
|
|
||||||
| Foundry Virtual Tabletop | Virtual Tabletop | Plataforma moderna para juegos de rol. | Herramientas avanzadas para masters y jugadores. | Comunidades de juegos de rol que buscan una solución profesional. | [Foundry Virtual Tabletop](https://foundryvtt.com/) |
|
|
||||||
| LANCommander | Distribución de Juegos | Plataforma open source para distribución digital de videojuegos. | Permite distribuir juegos en una red local. | Distribución de juegos en LAN o comunidades pequeñas. | [GitHub - LANCommander](https://github.com/LANCommander/LANCommander) |
|
|
||||||
| Fireshare | Distribución de Juegos | Comparte clips de juegos, videos u otros medios mediante enlaces únicos. | Comparte contenido multimedia de forma sencilla. | Compartir capturas o videos de juegos con amigos. | [GitHub - Fireshare](https://github.com/fireshare/fireshare) |
|
|
||||||
| Crafty Controller | Herramientas para Minecraft | Panel de control y lanzador para servidores de Minecraft. | Gestión simplificada de servidores de Minecraft. | Administrar servidores de Minecraft de forma visual. | [GitHub - Crafty Controller](https://github.com/crafty-controller/crafty-controller) |
|
|
||||||
| Steam Headless | Herramientas para Steam | Servidor remoto de Steam sin cabeza (headless) mediante Docker. | Permite gestionar juegos de Steam en un servidor remoto. | Usuarios que quieren acceder a su biblioteca de Steam desde un servidor. | [GitHub - Steam Headless](https://github.com/steamheadless/steamheadless) |
|
|
||||||
|
|
||||||
## Dependencias nativas para tests de integración
|
### Installation
|
||||||
|
|
||||||
Algunos tests de integración (p. ej. verificación de DATs, lectura de CHD/7z)
|
```bash
|
||||||
requieren herramientas nativas instaladas en el sistema donde se ejecuten
|
# 1. Clone repository
|
||||||
los tests (local o CI). A continuación está la lista mínima y cómo instalarlas:
|
git clone https://your-gitea-instance/your-org/quasar.git
|
||||||
|
cd quasar
|
||||||
|
|
||||||
- `7z` / `p7zip` — necesario para extraer/leer ZIP y 7z.
|
# 2. Install dependencies
|
||||||
- Debian/Ubuntu: `sudo apt update && sudo apt install -y p7zip-full p7zip-rar`
|
yarn install
|
||||||
- macOS (Homebrew): `brew install p7zip`
|
|
||||||
|
|
||||||
- `chdman` — herramienta de MAME para manejar archivos CHD (opcional,
|
# 3. Setup environment
|
||||||
requerida para tests que trabajen con imágenes CHD).
|
cp .env.example .env.local
|
||||||
- Debian/Ubuntu: intentar `sudo apt install -y mame-tools` o `sudo apt install -y mame`.
|
|
||||||
- macOS (Homebrew): `brew install mame`
|
|
||||||
- Si no hay paquete disponible, descargar o compilar MAME/CHDTools desde
|
|
||||||
las fuentes oficiales.
|
|
||||||
|
|
||||||
Notas:
|
# 4. Get API keys (optional, but recommended)
|
||||||
|
# See: [docs/02-tecnico/apis.md](docs/02-tecnico/apis.md)
|
||||||
|
|
||||||
- En CI se intentará instalar estas herramientas cuando sea posible; si no
|
# 5. Run migrations
|
||||||
están disponibles los tests de integración que dependan de ellas pueden
|
cd backend
|
||||||
configurarse para ejecutarse condicionalmente.
|
npm run prisma:migrate
|
||||||
- La variable de entorno `INTEGRATION=1` controla si se ejecutan pruebas
|
cd ..
|
||||||
más pesadas y dependientes de binarios.
|
|
||||||
|
# 6. Start development servers
|
||||||
|
# Terminal 1: Backend
|
||||||
|
cd backend && yarn dev
|
||||||
|
|
||||||
|
# Terminal 2: Frontend (Next.js)
|
||||||
|
cd frontend && yarn dev
|
||||||
|
|
||||||
|
# 7. Open browser
|
||||||
|
# Frontend: http://localhost:3000
|
||||||
|
# Backend API: http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **Create Platform** — Go to /games, add Nintendo, PlayStation, etc.
|
||||||
|
2. **Create Game** — Add game manually or import from ROMs
|
||||||
|
3. **Scan ROMs** — Go to /roms, scan directory with ROM files
|
||||||
|
4. **Link Metadata** — Search game on IGDB/RAWG, link to ROM
|
||||||
|
5. **View Library** — See all games with artwork and info
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
quasar/
|
||||||
|
├── backend/ # Fastify API + Prisma + SQLite
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── routes/ # REST endpoints (/games, /roms, /metadata, etc.)
|
||||||
|
│ │ ├── services/ # Business logic (metadata clients, romscanning, etc.)
|
||||||
|
│ │ └── controllers/ # Request handlers
|
||||||
|
│ └── tests/ # Vitest unit tests (63+ tests)
|
||||||
|
│
|
||||||
|
├── frontend/ # Next.js 16 + Shadcn UI + Tailwind CSS
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app/
|
||||||
|
│ │ │ ├── layout.tsx # Root layout con metadata SEO
|
||||||
|
│ │ │ ├── page.tsx # Landing page con componentes
|
||||||
|
│ │ │ └── globals.css # Tema Mass Effect + animaciones
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── landing/
|
||||||
|
│ │ │ │ ├── Navbar.tsx # Navbar con glassmorphism
|
||||||
|
│ │ │ │ ├── Hero.tsx # Hero section con featured game
|
||||||
|
│ │ │ │ ├── GameGrid.tsx # Grid de tarjetas con hover effects
|
||||||
|
│ │ │ │ └── Footer.tsx # Footer minimalista
|
||||||
|
│ │ │ └── ui/ # Componentes Shadcn UI
|
||||||
|
│ │ └── lib/
|
||||||
|
│ │ └── utils.ts # Utilidades de Shadcn UI
|
||||||
|
│
|
||||||
|
├── tests/
|
||||||
|
│ ├── e2e/ # Playwright E2E tests (15 tests)
|
||||||
|
│ └── *.spec.ts # Config validation tests
|
||||||
|
│
|
||||||
|
├── docs/ # Documentation
|
||||||
|
│ ├── 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/
|
||||||
|
│ └── ci.yml # Gitea Actions CI pipeline
|
||||||
|
│
|
||||||
|
└── .env.example # Environment template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env.local` (or `.env.development`) and fill in:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database (local SQLite)
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
THEGAMESDB_API_KEY=your_api_key
|
||||||
|
|
||||||
|
# App Config
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Run backend tests only
|
||||||
|
cd backend && yarn test
|
||||||
|
|
||||||
|
# Run frontend tests only
|
||||||
|
cd frontend && yarn test:run
|
||||||
|
|
||||||
|
# Run E2E tests (requires servers running)
|
||||||
|
cd backend && yarn dev &
|
||||||
|
cd frontend && yarn dev &
|
||||||
|
yarn test:e2e
|
||||||
|
|
||||||
|
# Lint & Format
|
||||||
|
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
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: EADDRINUSE: address already in use :::3000
|
||||||
|
→ Kill process on port 3000: lsof -i :3000 | grep LISTEN | awk '{print $2}' | xargs kill -9
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests failing with "DATABASE_URL not found"
|
||||||
|
|
||||||
|
```
|
||||||
|
→ Make sure backend/.env exists with DATABASE_URL
|
||||||
|
→ Or: DATABASE_URL="file:./test.db" yarn test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metadata search returns no results
|
||||||
|
|
||||||
|
```
|
||||||
|
→ 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend can't reach backend
|
||||||
|
|
||||||
|
```
|
||||||
|
→ Make sure backend is running on http://localhost:3000
|
||||||
|
→ Check frontend/.env has: VITE_API_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
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:** Next.js 16, React 19, TypeScript, Shadcn UI, Tailwind CSS 4
|
||||||
|
- **Testing:** Vitest (unit), Playwright (E2E)
|
||||||
|
- **APIs:** IGDB (OAuth), RAWG, TheGamesDB
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **No data leaves your system** — Everything stored locally
|
||||||
|
- **API keys stored securely** — Via `.env.local` or Gitea Secrets
|
||||||
|
- **Input validation** — All user input validated with Zod
|
||||||
|
- **CORS configured** — Only allows frontend origin
|
||||||
|
|
||||||
|
For security guidelines, see [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **[SECURITY.md](SECURITY.md)** — Security policies and best practices
|
||||||
|
- **[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
|
||||||
|
|
||||||
|
### Adding a new feature
|
||||||
|
|
||||||
|
1. Create test file: `tests/feature.spec.ts` (TDD)
|
||||||
|
2. Write failing test
|
||||||
|
3. Implement feature
|
||||||
|
4. Run tests: `yarn test`
|
||||||
|
5. Run lint: `yarn lint`
|
||||||
|
6. Commit: `git commit -m "feat: add feature description"`
|
||||||
|
|
||||||
|
### Running Gitea Actions locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The CI workflow is in .gitea/workflows/ci.yml
|
||||||
|
# It runs automatically on push/PR to main or develop
|
||||||
|
# To test locally:
|
||||||
|
|
||||||
|
# 1. Lint
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# 2. Backend tests
|
||||||
|
cd backend && yarn test
|
||||||
|
|
||||||
|
# 3. Frontend tests
|
||||||
|
cd frontend && yarn test:run
|
||||||
|
|
||||||
|
# 4. E2E tests (requires servers running)
|
||||||
|
cd backend && yarn dev &
|
||||||
|
cd frontend && yarn dev &
|
||||||
|
yarn test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Pull requests welcome. Please:
|
||||||
|
|
||||||
|
1. Run `yarn test` before pushing
|
||||||
|
2. Run `yarn format` to auto-format code
|
||||||
|
3. Add tests for new features
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT (or choose your license)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- **Docs:** See [/docs](/docs) folder
|
||||||
|
- **Issues:** Report bugs on this repo
|
||||||
|
- **Discussions:** Use repo discussions for questions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** MVP (v1.0.0) — Landing page completa con estética Mass Effect-inspired
|
||||||
|
**Last updated:** 2026-02-23
|
||||||
|
**Test coverage:** 122+ unit tests + 15 E2E tests ✅
|
||||||
|
**Documentation:** Frontend landing page documentado ✅
|
||||||
|
|||||||
99
SECURITY.md
Normal file
99
SECURITY.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting Security Vulnerabilities
|
||||||
|
|
||||||
|
If you discover a security vulnerability in Quasar, please email security@quasar.local with:
|
||||||
|
|
||||||
|
- Description of the vulnerability
|
||||||
|
- Steps to reproduce
|
||||||
|
- Potential impact
|
||||||
|
- Suggested fix (if any)
|
||||||
|
|
||||||
|
We'll acknowledge your report within 48 hours and work on a fix.
|
||||||
|
|
||||||
|
## Environment Variables & Secrets
|
||||||
|
|
||||||
|
**IMPORTANT:** Never commit `.env` files to the repository.
|
||||||
|
|
||||||
|
### Sensitive Variables
|
||||||
|
|
||||||
|
- `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` — Twitch OAuth credentials
|
||||||
|
- `RAWG_API_KEY` — RAWG API key (rate limited)
|
||||||
|
- `THEGAMESDB_API_KEY` — TheGamesDB key
|
||||||
|
- `DATABASE_URL` — SQLite file path (contains password if using remote DB)
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. Use `.env.local` or `.env.{NODE_ENV}.local` for local development
|
||||||
|
2. Never log or print secrets
|
||||||
|
3. Use GitHub/Gitea Secrets for CI/CD pipelines
|
||||||
|
4. Rotate keys regularly
|
||||||
|
5. Use separate keys for development, staging, production
|
||||||
|
|
||||||
|
## CI/CD Secrets (Gitea Actions)
|
||||||
|
|
||||||
|
For automated testing in Gitea Actions, store API keys as repository secrets:
|
||||||
|
|
||||||
|
### Setup Instructions
|
||||||
|
|
||||||
|
1. Go to your Gitea repository settings
|
||||||
|
- Navigate to: `https://your-gitea-instance/your-org/quasar/settings/secrets/actions`
|
||||||
|
2. Click "New Secret" for each credential:
|
||||||
|
- **Name:** `IGDB_CLIENT_ID` → **Value:** Your Client ID from Twitch
|
||||||
|
- **Name:** `IGDB_CLIENT_SECRET` → **Value:** Your Client Secret from Twitch
|
||||||
|
- **Name:** `RAWG_API_KEY` → **Value:** Your RAWG API key
|
||||||
|
- **Name:** `THEGAMESDB_API_KEY` → **Value:** Your TheGamesDB API key
|
||||||
|
3. Commit `.gitea/workflows/ci.yml` to trigger CI pipeline
|
||||||
|
|
||||||
|
### How Secrets Are Used
|
||||||
|
|
||||||
|
The CI workflow (`.gitea/workflows/ci.yml`) automatically:
|
||||||
|
|
||||||
|
- Runs **lint** on every push and pull request
|
||||||
|
- Runs **backend tests** (Vitest) with `DATABASE_URL=file:./test.db`
|
||||||
|
- Runs **frontend tests** (Vitest)
|
||||||
|
- Runs **E2E tests** (Playwright) with API key secrets injected as environment variables
|
||||||
|
- **Fails the build** if any tests fail (prevents broken code from being merged)
|
||||||
|
|
||||||
|
### Security Notes
|
||||||
|
|
||||||
|
- Secrets are **encrypted at rest** in Gitea
|
||||||
|
- Secrets are **masked in logs** (never printed to console)
|
||||||
|
- Only accessible in CI/CD contexts (not in local development)
|
||||||
|
- Same secrets work for both testing and production deployments
|
||||||
|
|
||||||
|
## Input Validation & Sanitization
|
||||||
|
|
||||||
|
All user inputs are validated using **Zod** schemas:
|
||||||
|
|
||||||
|
- Backend: `src/validators/*.ts` define strict schemas
|
||||||
|
- Frontend: React Hook Form + Zod validation
|
||||||
|
- Game titles, ROM file paths, and user uploads are sanitized
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
API calls to metadata services are rate-limited:
|
||||||
|
|
||||||
|
- IGDB: 4 requests/second
|
||||||
|
- RAWG: 20 requests/second (free tier)
|
||||||
|
- TheGamesDB: 1 request/second
|
||||||
|
|
||||||
|
## Database Security
|
||||||
|
|
||||||
|
SQLite is used for MVP. For production:
|
||||||
|
|
||||||
|
- Consider PostgreSQL or MySQL
|
||||||
|
- Enable encrypted connections (SSL/TLS)
|
||||||
|
- Use connection pooling (current: Prisma with pool settings)
|
||||||
|
- Regular backups
|
||||||
|
|
||||||
|
## CORS & CSP
|
||||||
|
|
||||||
|
Configure appropriate CORS headers in backend:
|
||||||
|
|
||||||
|
- Frontend origin: `http://localhost:3000` (dev), `https://yourdomain.com` (prod)
|
||||||
|
- Content Security Policy headers recommended for production
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
- v1.0.0 (2026-02-12): Initial security guidelines
|
||||||
BIN
backend/prisma/test.db
Normal file
BIN
backend/prisma/test.db
Normal file
Binary file not shown.
@@ -5,6 +5,8 @@ import rateLimit from '@fastify/rate-limit';
|
|||||||
import healthRoutes from './routes/health';
|
import healthRoutes from './routes/health';
|
||||||
import importRoutes from './routes/import';
|
import importRoutes from './routes/import';
|
||||||
import gamesRoutes from './routes/games';
|
import gamesRoutes from './routes/games';
|
||||||
|
import romsRoutes from './routes/roms';
|
||||||
|
import metadataRoutes from './routes/metadata';
|
||||||
|
|
||||||
export function buildApp(): FastifyInstance {
|
export function buildApp(): FastifyInstance {
|
||||||
const app: FastifyInstance = Fastify({
|
const app: FastifyInstance = Fastify({
|
||||||
@@ -17,6 +19,8 @@ export function buildApp(): FastifyInstance {
|
|||||||
void app.register(healthRoutes, { prefix: '/api' });
|
void app.register(healthRoutes, { prefix: '/api' });
|
||||||
void app.register(importRoutes, { prefix: '/api' });
|
void app.register(importRoutes, { prefix: '/api' });
|
||||||
void app.register(gamesRoutes, { prefix: '/api' });
|
void app.register(gamesRoutes, { prefix: '/api' });
|
||||||
|
void app.register(romsRoutes, { prefix: '/api' });
|
||||||
|
void app.register(metadataRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
96
backend/src/controllers/romsController.ts
Normal file
96
backend/src/controllers/romsController.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { prisma } from '../plugins/prisma';
|
||||||
|
|
||||||
|
export class RomsController {
|
||||||
|
/**
|
||||||
|
* Listar todos los ROMs con sus juegos asociados
|
||||||
|
*/
|
||||||
|
static async listRoms() {
|
||||||
|
return await prisma.romFile.findMany({
|
||||||
|
include: {
|
||||||
|
game: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
filename: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener un ROM por ID con su juego asociado
|
||||||
|
*/
|
||||||
|
static async getRomById(id: string) {
|
||||||
|
const rom = await prisma.romFile.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
game: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rom) {
|
||||||
|
throw new Error('ROM no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vincular un juego a un ROM existente
|
||||||
|
*/
|
||||||
|
static async linkGameToRom(romId: string, gameId: string) {
|
||||||
|
// Validar que el ROM existe
|
||||||
|
const rom = await prisma.romFile.findUnique({
|
||||||
|
where: { id: romId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rom) {
|
||||||
|
throw new Error('ROM no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que el juego existe
|
||||||
|
const game = await prisma.game.findUnique({
|
||||||
|
where: { id: gameId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
throw new Error('Juego no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar el ROM con el nuevo gameId
|
||||||
|
return await prisma.romFile.update({
|
||||||
|
where: { id: romId },
|
||||||
|
data: {
|
||||||
|
gameId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
game: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar un ROM por ID
|
||||||
|
*/
|
||||||
|
static async deleteRom(id: string) {
|
||||||
|
// Validar que el ROM existe
|
||||||
|
const rom = await prisma.romFile.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rom) {
|
||||||
|
throw new Error('ROM no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar el ROM
|
||||||
|
await prisma.romFile.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'ROM eliminado correctamente' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-11
|
||||||
|
*/
|
||||||
48
backend/src/routes/metadata.ts
Normal file
48
backend/src/routes/metadata.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import * as metadataService from '../services/metadataService';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
// Esquema de validación para parámetros de búsqueda
|
||||||
|
const searchMetadataSchema = z.object({
|
||||||
|
q: z.string().min(1, 'El parámetro de búsqueda es requerido'),
|
||||||
|
platform: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function metadataRoutes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* GET /api/metadata/search?q=query&platform=optional
|
||||||
|
* Buscar metadata de juegos
|
||||||
|
*/
|
||||||
|
app.get<{ Querystring: any; Reply: any[] }>('/metadata/search', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
// Validar parámetros de query con Zod
|
||||||
|
const validated = searchMetadataSchema.parse(request.query);
|
||||||
|
|
||||||
|
// Llamar a metadataService
|
||||||
|
const result = await metadataService.enrichGame({
|
||||||
|
title: validated.q,
|
||||||
|
platform: validated.platform,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si hay resultado, devolver como array; si no, devolver array vacío
|
||||||
|
return reply.code(200).send(result ? [result] : []);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Parámetros de búsqueda inválidos',
|
||||||
|
details: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default metadataRoutes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-11
|
||||||
|
*/
|
||||||
95
backend/src/routes/roms.ts
Normal file
95
backend/src/routes/roms.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { RomsController } from '../controllers/romsController';
|
||||||
|
import { linkGameSchema } from '../validators/romValidator';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
async function romsRoutes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* GET /api/roms
|
||||||
|
* Listar todos los ROMs
|
||||||
|
*/
|
||||||
|
app.get<{ Reply: any[] }>('/roms', async (request, reply) => {
|
||||||
|
const roms = await RomsController.listRoms();
|
||||||
|
return reply.code(200).send(roms);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/roms/:id
|
||||||
|
* Obtener un ROM por ID
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { id: string }; Reply: any }>('/roms/:id', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const rom = await RomsController.getRomById(request.params.id);
|
||||||
|
return reply.code(200).send(rom);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('no encontrado')) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
error: 'ROM no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/roms/:id/game
|
||||||
|
* Vincular un juego a un ROM
|
||||||
|
*/
|
||||||
|
app.put<{ Params: { id: string }; Body: any; Reply: any }>(
|
||||||
|
'/roms/:id/game',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
// Validar entrada con Zod
|
||||||
|
const validated = linkGameSchema.parse(request.body);
|
||||||
|
const rom = await RomsController.linkGameToRom(request.params.id, validated.gameId);
|
||||||
|
return reply.code(200).send(rom);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Validación fallida',
|
||||||
|
details: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes('ROM no encontrado')) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
error: 'ROM no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.message.includes('Juego no encontrado')) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Game ID inválido o no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/roms/:id
|
||||||
|
* Eliminar un ROM
|
||||||
|
*/
|
||||||
|
app.delete<{ Params: { id: string }; Reply: any }>('/roms/:id', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await RomsController.deleteRom(request.params.id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('no encontrado')) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
error: 'ROM no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default romsRoutes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-11
|
||||||
|
*/
|
||||||
15
backend/src/validators/romValidator.ts
Normal file
15
backend/src/validators/romValidator.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Esquema para vincular un juego a un ROM
|
||||||
|
export const linkGameSchema = z.object({
|
||||||
|
gameId: z.string().min(1, 'El ID del juego es requerido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tipo TypeScript derivado del esquema
|
||||||
|
export type LinkGameInput = z.infer<typeof linkGameSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-11
|
||||||
|
*/
|
||||||
@@ -10,8 +10,12 @@ describe('Games API', () => {
|
|||||||
app = buildApp();
|
app = buildApp();
|
||||||
await app.ready();
|
await app.ready();
|
||||||
// Limpiar base de datos antes de cada test
|
// Limpiar base de datos antes de cada test
|
||||||
|
// Orden importante: relaciones de FK primero
|
||||||
|
await prisma.romFile.deleteMany();
|
||||||
await prisma.purchase.deleteMany();
|
await prisma.purchase.deleteMany();
|
||||||
await prisma.gamePlatform.deleteMany();
|
await prisma.gamePlatform.deleteMany();
|
||||||
|
await prisma.artwork.deleteMany();
|
||||||
|
await prisma.priceHistory.deleteMany();
|
||||||
await prisma.game.deleteMany();
|
await prisma.game.deleteMany();
|
||||||
await prisma.platform.deleteMany();
|
await prisma.platform.deleteMany();
|
||||||
});
|
});
|
||||||
|
|||||||
101
backend/tests/routes/metadata.spec.ts
Normal file
101
backend/tests/routes/metadata.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { buildApp } from '../../src/app';
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import * as metadataService from '../../src/services/metadataService';
|
||||||
|
|
||||||
|
describe('Metadata API', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = buildApp();
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/metadata/search', () => {
|
||||||
|
it('debería devolver resultados cuando se busca un juego existente', async () => {
|
||||||
|
const mockResults = [
|
||||||
|
{
|
||||||
|
source: 'igdb',
|
||||||
|
externalIds: { igdb: 1 },
|
||||||
|
title: 'The Legend of Zelda',
|
||||||
|
slug: 'the-legend-of-zelda',
|
||||||
|
releaseDate: '1986-02-21',
|
||||||
|
genres: ['Adventure'],
|
||||||
|
coverUrl: 'https://example.com/cover.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(mockResults[0]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/metadata/search?q=zelda',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
expect(body.length).toBeGreaterThan(0);
|
||||||
|
expect(body[0].title).toContain('Zelda');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver lista vacía cuando no hay resultados', async () => {
|
||||||
|
vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/metadata/search?q=nonexistentgame12345',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
expect(body.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 400 si falta el parámetro query', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/metadata/search',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json()).toHaveProperty('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 400 si el parámetro query está vacío', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/metadata/search?q=',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería pasar el parámetro platform a enrichGame si se proporciona', async () => {
|
||||||
|
const enrichSpy = vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/metadata/search?q=mario&platform=Nintendo%2064',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(enrichSpy).toHaveBeenCalledWith({
|
||||||
|
title: 'mario',
|
||||||
|
platform: 'Nintendo 64',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-11
|
||||||
|
*/
|
||||||
295
backend/tests/routes/roms.spec.ts
Normal file
295
backend/tests/routes/roms.spec.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { buildApp } from '../../src/app';
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { prisma } from '../../src/plugins/prisma';
|
||||||
|
|
||||||
|
describe('ROMs API', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = buildApp();
|
||||||
|
await app.ready();
|
||||||
|
// Limpiar base de datos antes de cada test (eliminar ROMs primero por foreign key)
|
||||||
|
await prisma.romFile.deleteMany();
|
||||||
|
await prisma.gamePlatform.deleteMany();
|
||||||
|
await prisma.purchase.deleteMany();
|
||||||
|
await prisma.artwork.deleteMany();
|
||||||
|
await prisma.priceHistory.deleteMany();
|
||||||
|
await prisma.game.deleteMany();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/roms', () => {
|
||||||
|
it('debería devolver una lista vacía cuando no hay ROMs', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/roms',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver una lista de ROMs con sus propiedades', async () => {
|
||||||
|
// Crear un ROM de prueba
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/games/',
|
||||||
|
filename: 'game.zip',
|
||||||
|
checksum: 'abc123def456',
|
||||||
|
size: 1024,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/roms',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
expect(body.length).toBe(1);
|
||||||
|
expect(body[0].id).toBe(rom.id);
|
||||||
|
expect(body[0].filename).toBe('game.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería incluir información del juego asociado', async () => {
|
||||||
|
const game = await prisma.game.create({
|
||||||
|
data: {
|
||||||
|
title: 'Test Game',
|
||||||
|
slug: 'test-game',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'test-with-game.zip',
|
||||||
|
checksum: 'checksum-game-123',
|
||||||
|
size: 2048,
|
||||||
|
format: 'zip',
|
||||||
|
gameId: game.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/roms',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
// Buscar el ROM que creamos por checksum
|
||||||
|
const createdRom = body.find((r: any) => r.checksum === 'checksum-game-123');
|
||||||
|
expect(createdRom).toBeDefined();
|
||||||
|
expect(createdRom.game).toBeDefined();
|
||||||
|
expect(createdRom.game.title).toBe('Test Game');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/roms/:id', () => {
|
||||||
|
it('debería retornar un ROM existente', async () => {
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'game1.zip',
|
||||||
|
checksum: 'checksum1',
|
||||||
|
size: 1024,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/roms/${rom.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.id).toBe(rom.id);
|
||||||
|
expect(body.filename).toBe('game1.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería retornar 404 si el ROM no existe', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/roms/non-existing-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.json()).toHaveProperty('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería incluir el juego asociado al ROM', async () => {
|
||||||
|
const game = await prisma.game.create({
|
||||||
|
data: {
|
||||||
|
title: 'Zelda',
|
||||||
|
slug: 'zelda',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'zelda.zip',
|
||||||
|
checksum: 'checksum2',
|
||||||
|
size: 2048,
|
||||||
|
format: 'zip',
|
||||||
|
gameId: game.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/roms/${rom.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.game).toBeDefined();
|
||||||
|
expect(body.game.title).toBe('Zelda');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/roms/:id/game', () => {
|
||||||
|
it('debería vincular un juego a un ROM existente', async () => {
|
||||||
|
const game = await prisma.game.create({
|
||||||
|
data: {
|
||||||
|
title: 'Mario',
|
||||||
|
slug: 'mario',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'mario.zip',
|
||||||
|
checksum: 'checksum3',
|
||||||
|
size: 512,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/roms/${rom.id}/game`,
|
||||||
|
payload: {
|
||||||
|
gameId: game.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.gameId).toBe(game.id);
|
||||||
|
expect(body.game.title).toBe('Mario');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 400 si el gameId es inválido', async () => {
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'game.zip',
|
||||||
|
checksum: 'checksum4',
|
||||||
|
size: 1024,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/roms/${rom.id}/game`,
|
||||||
|
payload: {
|
||||||
|
gameId: 'invalid-game-id',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 404 si el ROM no existe', async () => {
|
||||||
|
const game = await prisma.game.create({
|
||||||
|
data: {
|
||||||
|
title: 'Test',
|
||||||
|
slug: 'test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/roms/non-existing-id/game',
|
||||||
|
payload: {
|
||||||
|
gameId: game.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 400 si falta gameId', async () => {
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'game.zip',
|
||||||
|
checksum: 'checksum5',
|
||||||
|
size: 1024,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/roms/${rom.id}/game`,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/roms/:id', () => {
|
||||||
|
it('debería eliminar un ROM existente', async () => {
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'delete-me.zip',
|
||||||
|
checksum: 'checksum6',
|
||||||
|
size: 1024,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/roms/${rom.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
|
||||||
|
// Verificar que el ROM fue eliminado
|
||||||
|
const deletedRom = await prisma.romFile.findUnique({
|
||||||
|
where: { id: rom.id },
|
||||||
|
});
|
||||||
|
expect(deletedRom).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 404 si el ROM no existe', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/roms/non-existing-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-11
|
||||||
|
*/
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
// Cargar variables de entorno desde .env
|
// Cargar variables de entorno desde .env
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
// Ejecutar migraciones de Prisma antes de los tests
|
||||||
|
try {
|
||||||
|
execSync('npx prisma migrate deploy', {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to run Prisma migrations:', error);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadatos:
|
* Metadatos:
|
||||||
* Autor: GitHub Copilot
|
* Autor: GitHub Copilot
|
||||||
* Última actualización: 2026-02-11
|
* Última actualización: 2026-02-12
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/**/*.spec.ts'],
|
include: ['tests/**/*.spec.ts'],
|
||||||
globals: false,
|
globals: false,
|
||||||
|
threads: false, // Desactivar parallelización para evitar contaminación de BD
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'c8',
|
provider: 'c8',
|
||||||
reporter: ['text', 'lcov'],
|
reporter: ['text', 'lcov'],
|
||||||
|
|||||||
@@ -1,15 +1,222 @@
|
|||||||
# Comparativa de APIs — cobertura, límites, coste y calidad
|
# APIs del Sistema — Guía completa
|
||||||
|
|
||||||
**Introducción**
|
Este documento integra toda la información sobre APIs del sistema: obtención de claves, prioridades, estrategias, comparación y configuración.
|
||||||
Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots, géneros, desarrolladores), y datos de precio/ofertas. Las decisiones de integración deben priorizar cobertura, coste (preferencia: gratuito), calidad y facilidad de uso.
|
|
||||||
|
|
||||||
**Nota:** límites y condiciones pueden cambiar — verificar TOS antes de integración.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Resumen por API
|
## Tabla de Contenidos
|
||||||
|
|
||||||
### IGDB (Internet Games Database)
|
1. [APIs priorizadas (MVP)](#apis-priorizadas-mvp)
|
||||||
|
2. [Obtención de claves](#obtención-de-claves)
|
||||||
|
3. [Guía de integración](#guía-de-integración)
|
||||||
|
4. [Comparación detallada](#comparación-detallada)
|
||||||
|
5. [Estrategias técnicas](#estrategias-técnicas)
|
||||||
|
6. [Configuración y despliegue](#configuración-y-despliegue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APIs priorizadas (MVP)
|
||||||
|
|
||||||
|
### Prioridad Alta
|
||||||
|
|
||||||
|
1. **IGDB (Internet Game Database)** - Calidad superior, amplia cobertura
|
||||||
|
2. **RAWG (Rawg.io)** - Buena cobertura, datos de tiendas
|
||||||
|
|
||||||
|
### Prioridad Media
|
||||||
|
|
||||||
|
3. **TheGamesDB** - Artwork comunitario
|
||||||
|
4. **ScreenScraper** - Media específica para ROMs
|
||||||
|
|
||||||
|
### Prioridad Baja (para futuras versiones)
|
||||||
|
|
||||||
|
5. **PriceCharting** - Precios físicos
|
||||||
|
6. **IsThereAnyDeal** - Ofertas digitales
|
||||||
|
7. **MobyGames** - Datos históricos detallados
|
||||||
|
8. **eBay** - Datos de mercado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Obtención de claves
|
||||||
|
|
||||||
|
### IGDB (Internet Game Database)
|
||||||
|
|
||||||
|
IGDB usa **OAuth 2.0 via Twitch**. Steps:
|
||||||
|
|
||||||
|
1. Go to [Twitch Developer Console](https://dev.twitch.tv/console/apps)
|
||||||
|
2. Sign in with your Twitch account (create one if needed)
|
||||||
|
3. Click "Create Application"
|
||||||
|
- Name: "Quasar" (or your app name)
|
||||||
|
- Category: Select relevant category
|
||||||
|
- Accept terms, click Create
|
||||||
|
4. You'll see:
|
||||||
|
- **Client ID** — Copy this
|
||||||
|
- Click "New Secret" to generate **Client Secret** — Copy this
|
||||||
|
5. Go to Settings → OAuth Redirect URLs
|
||||||
|
- Add: `http://localhost:3000/oauth/callback` (development)
|
||||||
|
- For production: `https://yourdomain.com/oauth/callback`
|
||||||
|
6. In your `.env` file:
|
||||||
|
```
|
||||||
|
IGDB_CLIENT_ID=your_client_id
|
||||||
|
IGDB_CLIENT_SECRET=your_client_secret
|
||||||
|
```
|
||||||
|
7. Start Quasar, it will use IGDB automatically
|
||||||
|
|
||||||
|
**Rate Limit:** 4 requests/second
|
||||||
|
|
||||||
|
### RAWG (Rawg.io)
|
||||||
|
|
||||||
|
RAWG has a simpler **API Key** approach:
|
||||||
|
|
||||||
|
1. Go to [RAWG Settings](https://rawg.io/settings/account)
|
||||||
|
2. Sign up if needed, then login
|
||||||
|
3. Find "API Key" section
|
||||||
|
4. Click "Create new key" (if needed) or copy existing key
|
||||||
|
5. In your `.env` file:
|
||||||
|
```
|
||||||
|
RAWG_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
6. Start Quasar
|
||||||
|
|
||||||
|
**Rate Limit:** 20 requests/second (free tier)
|
||||||
|
|
||||||
|
**Note:** RAWG requires attribution in UI (include "Powered by RAWG" somewhere visible)
|
||||||
|
|
||||||
|
### TheGamesDB (thegamesdb.net)
|
||||||
|
|
||||||
|
TheGamesDB uses a simple **API Key**:
|
||||||
|
|
||||||
|
1. Go to [TheGamesDB API](https://thegamesdb.net/api)
|
||||||
|
2. Find "API Key" section (free registration required)
|
||||||
|
3. Register or login
|
||||||
|
4. Copy your API key
|
||||||
|
5. In your `.env` file:
|
||||||
|
```
|
||||||
|
THEGAMESDB_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
6. Start Quasar
|
||||||
|
|
||||||
|
**Rate Limit:** 1 request/second (free tier)
|
||||||
|
|
||||||
|
### ScreenScraper
|
||||||
|
|
||||||
|
ScreenScraper requiere cuenta y modelo de donación:
|
||||||
|
|
||||||
|
1. Go to [ScreenScraper](https://www.screenscraper.fr/)
|
||||||
|
2. Create account
|
||||||
|
3. Niveles de donación ofrecen límites distintos (ej.: 50.000 scrapes/día en nivel Bronze)
|
||||||
|
4. En tu `.env` file:
|
||||||
|
```
|
||||||
|
SCREENSCRAPER_USERNAME=your_username
|
||||||
|
SCREENSCRAPER_PASSWORD=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guía de integración
|
||||||
|
|
||||||
|
### IGDB
|
||||||
|
|
||||||
|
- **Obtener credenciales**: registrar una app en Twitch Developer Console para obtener `CLIENT_ID` y `CLIENT_SECRET`. Obtener token con grant type `client_credentials` (POST a `https://id.twitch.tv/oauth2/token`).
|
||||||
|
|
||||||
|
- **Endpoints principales**: `POST https://api.igdb.com/v4/games` (consulta flexible via body con sintaxis IGDB), `POST https://api.igdb.com/v4/covers`, `POST https://api.igdb.com/v4/platforms`.
|
||||||
|
|
||||||
|
- **Ejemplo (buscar)**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Obtener token
|
||||||
|
curl -X POST 'https://id.twitch.tv/oauth2/token?client_id=$IGDB_CLIENT_ID&client_secret=$IGDB_CLIENT_SECRET&grant_type=client_credentials'
|
||||||
|
|
||||||
|
# Buscar juegos
|
||||||
|
curl -X POST 'https://api.igdb.com/v4/games' \
|
||||||
|
-H "Client-ID: $IGDB_CLIENT_ID" \
|
||||||
|
-H "Authorization: Bearer $IGDB_TOKEN" \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
--data 'fields id,name,first_release_date,platforms.name,genres.name,cover.url; search "zelda"; limit 5;'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Respuesta (esquemática)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 12345,
|
||||||
|
"name": "Ejemplo",
|
||||||
|
"first_release_date": 1459468800,
|
||||||
|
"platforms": [{ "name": "Nintendo Switch" }],
|
||||||
|
"cover": { "url": "//images.igdb.com/...jpg" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Límites y manejo**: la API puede devolver `429` o cabeceras de límite; implementar retries exponenciales (ej. 3 intentos) y respetar `Retry-After`. Implementar circuit breaker si la API falla repetidamente.
|
||||||
|
- **Atribución**: mostrar origen de datos (ej. "Datos: IGDB") según términos del servicio.
|
||||||
|
|
||||||
|
### RAWG
|
||||||
|
|
||||||
|
- **Obtener credenciales**: registrarse en RAWG para obtener `RAWG_API_KEY` (https://rawg.io/apidocs).
|
||||||
|
- **Endpoints principales**: `GET https://api.rawg.io/api/games?key=API_KEY&search=...`, `GET https://api.rawg.io/api/games/{id}`.
|
||||||
|
- **Ejemplo**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://api.rawg.io/api/games?key=$RAWG_API_KEY&search=zelda&page_size=5'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Respuesta (esquemática)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 100,
|
||||||
|
"results": [
|
||||||
|
{ "id": 3498, "name": "GTA V", "released": "2013-09-17", "background_image": "https://..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Límites y manejo**: RAWG suele tener límites por clave/plan; cachear y fallback a otros proveedores si falla.
|
||||||
|
- **Atribución**: revisar condiciones y mostrar HTTP o texto de fuente si es requerido por el proveedor.
|
||||||
|
|
||||||
|
### TheGamesDB
|
||||||
|
|
||||||
|
- **Obtener credenciales**: crear cuenta y generar API Key en https://thegamesdb.net.
|
||||||
|
- **Endpoints**: búsqueda por nombre y detalles (`/v1/Games/ByGameName?name=...`, `/v1/Games/ByGameID?id=...`).
|
||||||
|
- **Ejemplo**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer $THEGAMESDB_KEY' 'https://api.thegamesdb.net/v1/Games/ByGameName?name=zelda'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrategia de fallback y normalización
|
||||||
|
|
||||||
|
- **Orden de prioridad**: IGDB → RAWG → TheGamesDB (configurable).
|
||||||
|
- **Normalización (mapping)**:
|
||||||
|
- `title` ← `name`
|
||||||
|
- `platform` ← `platforms[].name`
|
||||||
|
- `release_date` ← `first_release_date` / `released` → convertir a ISO 8601
|
||||||
|
- `genres` ← `genres[].name`
|
||||||
|
- `cover_url` ← `cover.url` / `background_image`
|
||||||
|
- `external_ids` ← `{ igdb: id, rawg: id, thegamesdb: id }`
|
||||||
|
|
||||||
|
- **Fallback**: si IGDB no tiene portada, intentar RAWG; si falla, usar TheGamesDB. Registrar la fuente usada.
|
||||||
|
|
||||||
|
### Caché y almacenamiento de artwork
|
||||||
|
|
||||||
|
- **Caché metadata**: LRU en memoria o Redis con TTL (por ejemplo 24h) para evitar sobrecargar APIs.
|
||||||
|
- **Almacenamiento de imágenes**: descargar y optimizar con `sharp` (crear versiones: thumb, medium), almacenar en `storage/artwork/{gameId}/cover.jpg` o S3.
|
||||||
|
- **Servicio proxy**: servir imágenes desde backend para no exponer keys ni URLs externas.
|
||||||
|
|
||||||
|
### Manejo de errores y resiliencia
|
||||||
|
|
||||||
|
- Implementar **retries** exponenciales con jitter (3 intentos).
|
||||||
|
- Implementar **circuit breaker** para desconectar llamadas a un proveedor fuera de servicio por N minutos.
|
||||||
|
- Limitar concurrencia por proveedor (p. ej. 5 llamadas simultáneas) y usar colas para trabajos masivos (enriquecimiento masivo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparación detallada
|
||||||
|
|
||||||
|
### Resumen por API
|
||||||
|
|
||||||
|
#### IGDB (Internet Games Database)
|
||||||
|
|
||||||
- **Resumen:** Base de datos muy completa (propiedad de Twitch/Amazon) con endpoints para juegos, covers, screenshots, plataformas, ratings, compañías y más.
|
- **Resumen:** Base de datos muy completa (propiedad de Twitch/Amazon) con endpoints para juegos, covers, screenshots, plataformas, ratings, compañías y más.
|
||||||
- **Autenticación / Requisitos:** OAuth vía Twitch (Client ID + Client Secret → token) — requiere cuenta Twitch y 2FA para registrar apps.
|
- **Autenticación / Requisitos:** OAuth vía Twitch (Client ID + Client Secret → token) — requiere cuenta Twitch y 2FA para registrar apps.
|
||||||
@@ -21,9 +228,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Gratuito para uso no comercial; acuerdos comerciales para partners (atribución en caso de partnership).
|
- **Costes / modelo:** Gratuito para uso no comercial; acuerdos comerciales para partners (atribución en caso de partnership).
|
||||||
- **Enlace:** https://api-docs.igdb.com/
|
- **Enlace:** https://api-docs.igdb.com/
|
||||||
|
|
||||||
---
|
#### RAWG
|
||||||
|
|
||||||
### RAWG
|
|
||||||
|
|
||||||
- **Resumen:** Gran base de datos (medio millón de juegos), buena para metadata general y enlaces a tiendas.
|
- **Resumen:** Gran base de datos (medio millón de juegos), buena para metadata general y enlaces a tiendas.
|
||||||
- **Autenticación / Requisitos:** API key en query string (`key=YOUR_API_KEY`).
|
- **Autenticación / Requisitos:** API key en query string (`key=YOUR_API_KEY`).
|
||||||
@@ -35,9 +240,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Free tier para proyectos personales; planes comerciales (pago mensual) para uso en productos con gran tráfico.
|
- **Costes / modelo:** Free tier para proyectos personales; planes comerciales (pago mensual) para uso en productos con gran tráfico.
|
||||||
- **Enlace:** https://rawg.io/apidocs
|
- **Enlace:** https://rawg.io/apidocs
|
||||||
|
|
||||||
---
|
#### TheGamesDB
|
||||||
|
|
||||||
### TheGamesDB
|
|
||||||
|
|
||||||
- **Resumen:** Base de datos comunitaria para juegos y artwork, con API pública v2.
|
- **Resumen:** Base de datos comunitaria para juegos y artwork, con API pública v2.
|
||||||
- **Autenticación / Requisitos:** Registro y uso de API key (ver docs); repositorio público del proyecto (GPLv3 para el código del servidor).
|
- **Autenticación / Requisitos:** Registro y uso de API key (ver docs); repositorio público del proyecto (GPLv3 para el código del servidor).
|
||||||
@@ -48,9 +251,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Cláusula clave:** No documentado públicamente — verificar con el equipo de TheGamesDB antes de uso comercial/redistribución.
|
- **Cláusula clave:** No documentado públicamente — verificar con el equipo de TheGamesDB antes de uso comercial/redistribución.
|
||||||
- **Enlace:** https://api.thegamesdb.net/
|
- **Enlace:** https://api.thegamesdb.net/
|
||||||
|
|
||||||
---
|
#### ScreenScraper
|
||||||
|
|
||||||
### ScreenScraper
|
|
||||||
|
|
||||||
- **Resumen:** Servicio francés orientado a frontends, con enorme cantidad de media y opciones de scraping.
|
- **Resumen:** Servicio francés orientado a frontends, con enorme cantidad de media y opciones de scraping.
|
||||||
- **Autenticación / Requisitos:** Cuenta en ScreenScraper; modelo de soporte/donación que habilita límites mayores.
|
- **Autenticación / Requisitos:** Cuenta en ScreenScraper; modelo de soporte/donación que habilita límites mayores.
|
||||||
@@ -62,9 +263,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Donación / suscripción para aumentar cuotas y velocidad.
|
- **Costes / modelo:** Donación / suscripción para aumentar cuotas y velocidad.
|
||||||
- **Enlace:** https://www.screenscraper.fr/
|
- **Enlace:** https://www.screenscraper.fr/
|
||||||
|
|
||||||
---
|
#### MobyGames
|
||||||
|
|
||||||
### MobyGames
|
|
||||||
|
|
||||||
- **Resumen:** Base histórica con screenshots, covers, reviews y credits; muy usada por investigación y metadata profunda.
|
- **Resumen:** Base histórica con screenshots, covers, reviews y credits; muy usada por investigación y metadata profunda.
|
||||||
- **Autenticación / Requisitos:** API y/o MobyPlus; la API requiere registro y suscripción.
|
- **Autenticación / Requisitos:** API y/o MobyPlus; la API requiere registro y suscripción.
|
||||||
@@ -76,9 +275,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Acceso vía suscripción / MobyPro; contactar para condiciones comerciales.
|
- **Costes / modelo:** Acceso vía suscripción / MobyPro; contactar para condiciones comerciales.
|
||||||
- **Enlace:** https://www.mobygames.com/api/subscribe/
|
- **Enlace:** https://www.mobygames.com/api/subscribe/
|
||||||
|
|
||||||
---
|
#### PriceCharting
|
||||||
|
|
||||||
### PriceCharting
|
|
||||||
|
|
||||||
- **Resumen:** Fuente especializada en historial de precios para juegos físicos y coleccionables.
|
- **Resumen:** Fuente especializada en historial de precios para juegos físicos y coleccionables.
|
||||||
- **Autenticación / Requisitos:** API documentada en el sitio; el acceso completo requiere suscripción / token pagado.
|
- **Autenticación / Requisitos:** API documentada en el sitio; el acceso completo requiere suscripción / token pagado.
|
||||||
@@ -90,9 +287,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Servicio comercial (licencias / API keys pagadas).
|
- **Costes / modelo:** Servicio comercial (licencias / API keys pagadas).
|
||||||
- **Enlace:** https://www.pricecharting.com/api-documentation
|
- **Enlace:** https://www.pricecharting.com/api-documentation
|
||||||
|
|
||||||
---
|
#### IsThereAnyDeal (Itad)
|
||||||
|
|
||||||
### IsThereAnyDeal (Itad)
|
|
||||||
|
|
||||||
- **Resumen:** Agregador de ofertas con histórico y mapeo de keys/tiendas; útil para tracking de ofertas digitales.
|
- **Resumen:** Agregador de ofertas con histórico y mapeo de keys/tiendas; útil para tracking de ofertas digitales.
|
||||||
- **Autenticación / Requisitos:** API Key (docs en https://docs.isthereanydeal.com/).
|
- **Autenticación / Requisitos:** API Key (docs en https://docs.isthereanydeal.com/).
|
||||||
@@ -104,9 +299,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Free tier; acuerdos comerciales para uso intensivo.
|
- **Costes / modelo:** Free tier; acuerdos comerciales para uso intensivo.
|
||||||
- **Enlace:** https://docs.isthereanydeal.com/
|
- **Enlace:** https://docs.isthereanydeal.com/
|
||||||
|
|
||||||
---
|
#### eBay
|
||||||
|
|
||||||
### eBay
|
|
||||||
|
|
||||||
- **Resumen:** Fuente de datos de mercado (listings, precios vendidos) para estimar valor real de mercado.
|
- **Resumen:** Fuente de datos de mercado (listings, precios vendidos) para estimar valor real de mercado.
|
||||||
- **Autenticación / Requisitos:** Registro en eBay Developers Program; claves y OAuth para endpoints de venta/completed items.
|
- **Autenticación / Requisitos:** Registro en eBay Developers Program; claves y OAuth para endpoints de venta/completed items.
|
||||||
@@ -118,9 +311,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Free para desarrolladores con límites; uso intensivo o comerciales pueden requerir acuerdos o certificaciones.
|
- **Costes / modelo:** Free para desarrolladores con límites; uso intensivo o comerciales pueden requerir acuerdos o certificaciones.
|
||||||
- **Enlace:** https://developer.ebay.com/
|
- **Enlace:** https://developer.ebay.com/
|
||||||
|
|
||||||
---
|
### Tabla resumida
|
||||||
|
|
||||||
## Tabla resumida
|
|
||||||
|
|
||||||
| API | Data types | Auth | Free / Paid | Fecha verificación | Licencia / Nota legal | Notes |
|
| API | Data types | Auth | Free / Paid | Fecha verificación | Licencia / Nota legal | Notes |
|
||||||
| -------------- | ------------------------------------------------------- | -------------------------------- | ------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
| -------------- | ------------------------------------------------------- | -------------------------------- | ------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||||
@@ -133,9 +324,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
| MobyGames | screenshots, credits, covers | Subscribe / API key | Paid / subscription | 2026-02-07 | Paid/Subscribe: https://www.mobygames.com/api/subscribe/ | Access via subscription; non-commercial rate limits documented |
|
| MobyGames | screenshots, credits, covers | Subscribe / API key | Paid / subscription | 2026-02-07 | Paid/Subscribe: https://www.mobygames.com/api/subscribe/ | Access via subscription; non-commercial rate limits documented |
|
||||||
| eBay | listings, sold data | eBay Dev keys / OAuth | Free (with limits) | 2026-02-07 | TOS: https://developer.ebay.com/ | Terms restrict distribution; API License Agreement |
|
| eBay | listings, sold data | eBay Dev keys / OAuth | Free (with limits) | 2026-02-07 | TOS: https://developer.ebay.com/ | Terms restrict distribution; API License Agreement |
|
||||||
|
|
||||||
---
|
### Conclusión y recomendación para MVP
|
||||||
|
|
||||||
## Conclusión y recomendación para MVP
|
|
||||||
|
|
||||||
Recomiendo un **set inicial de APIs (priorizado)**: **IGDB, RAWG, TheGamesDB, ScreenScraper, PriceCharting, IsThereAnyDeal.**
|
Recomiendo un **set inicial de APIs (priorizado)**: **IGDB, RAWG, TheGamesDB, ScreenScraper, PriceCharting, IsThereAnyDeal.**
|
||||||
|
|
||||||
@@ -144,13 +333,171 @@ Recomiendo un **set inicial de APIs (priorizado)**: **IGDB, RAWG, TheGamesDB, Sc
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Vacíos y verificación pendiente
|
## Estrategias técnicas
|
||||||
|
|
||||||
- **APIs que requieren suscripción / acuerdos comerciales:** PriceCharting (API premium, requiere suscripción), MobyGames (MobyPro/API requiere suscripción), EmuMovies (servicio comercial con TOS y cuentas), y en casos especiales eBay (certificaciones / acuerdos adicionales para ciertos permisos).
|
### Variables de entorno (ejemplos)
|
||||||
- **PriceCharting:** la documentación de la API existe pero el acceso completo está sujeto a registro/pago; no se publicó límite público durante la verificación.
|
|
||||||
- **MobyGames:** API y límites requieren suscripción/registro; hay que contactar para condiciones comerciales.
|
```
|
||||||
- **eBay:** múltiples APIs y límites por endpoint; requiere revisar caso de uso específico y cumplimiento del API License Agreement.
|
IGDB_CLIENT_ID=...
|
||||||
- **Notas:** Algunas APIs (ScreenScraper) usan modelos por donación/premium para aumentar cuotas; en APIs sin límites públicos, contactar al proveedor para confirmar condiciones.
|
IGDB_CLIENT_SECRET=...
|
||||||
|
RAWG_API_KEY=...
|
||||||
|
THEGAMESDB_API_KEY=...
|
||||||
|
SCREENSCRAPER_USERNAME=...
|
||||||
|
SCREENSCRAPER_PASSWORD=...
|
||||||
|
EXTERNAL_API_CONCURRENCY=5
|
||||||
|
```
|
||||||
|
|
||||||
|
> Nota: **Nunca** exponer estas claves en el cliente; siempre pasar por el backend.
|
||||||
|
|
||||||
|
### Normalización de datos
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NormalizedGame {
|
||||||
|
title: string;
|
||||||
|
platform: string;
|
||||||
|
release_date: string; // ISO 8601
|
||||||
|
genres: string[];
|
||||||
|
cover_url: string;
|
||||||
|
external_ids: {
|
||||||
|
igdb?: string;
|
||||||
|
rawg?: string;
|
||||||
|
thegamesdb?: string;
|
||||||
|
};
|
||||||
|
source: 'igdb' | 'rawg' | 'thegamesdb' | 'screenscraper';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo de implementación
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class MetadataService {
|
||||||
|
private apis = [
|
||||||
|
new IGDBService(),
|
||||||
|
new RAWGService(),
|
||||||
|
new TheGamesDBService(),
|
||||||
|
new ScreenScraperService(),
|
||||||
|
];
|
||||||
|
|
||||||
|
async searchGame(title: string): Promise<NormalizedGame> {
|
||||||
|
for (const api of this.apis) {
|
||||||
|
try {
|
||||||
|
const result = await api.search(title);
|
||||||
|
if (result) {
|
||||||
|
return this.normalize(result, api.getSource());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`${api.getSource()} failed:`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('All APIs failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(data: any, source: string): NormalizedGame {
|
||||||
|
return {
|
||||||
|
title: data.name || data.title,
|
||||||
|
platform: data.platforms?.[0]?.name || '',
|
||||||
|
release_date: this.normalizeDate(data.first_release_date || data.released),
|
||||||
|
genres: data.genres?.map((g: any) => g.name) || [],
|
||||||
|
cover_url: data.cover?.url || data.background_image || '',
|
||||||
|
external_ids: {
|
||||||
|
igdb: data.id,
|
||||||
|
rawg: data.id,
|
||||||
|
thegamesdb: data.id,
|
||||||
|
},
|
||||||
|
source: source as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuración y despliegue
|
||||||
|
|
||||||
|
### Testing Without Real Keys
|
||||||
|
|
||||||
|
Para desarrollo/testing:
|
||||||
|
|
||||||
|
- Dejar API keys como `your_*_here` en `.env.local`
|
||||||
|
- Quasar will gracefully degrade and show limited metadata
|
||||||
|
- Frontend will still work with manual game entry
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
Para producción:
|
||||||
|
|
||||||
|
1. Generar nuevas claves en cada servicio (no reutilizar claves de desarrollo)
|
||||||
|
2. Almacenar claves en **Gitea Secrets** (para pipelines CI/CD automatizados)
|
||||||
|
3. O usar variables de entorno en tu proveedor de hosting
|
||||||
|
4. Rotar claves cada 3 meses
|
||||||
|
5. Monitorear límites de rate en los dashboards de los servicios
|
||||||
|
|
||||||
|
### Gitea Actions CI/CD Setup
|
||||||
|
|
||||||
|
Para habilitar pruebas automatizadas con API keys en Gitea Actions:
|
||||||
|
|
||||||
|
#### 1. Store Secrets in Gitea
|
||||||
|
|
||||||
|
Navigate to your repository settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://your-gitea-instance/your-org/quasar/settings/secrets/actions
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these secrets:
|
||||||
|
|
||||||
|
- `IGDB_CLIENT_ID` (from Twitch Developer Console)
|
||||||
|
- `IGDB_CLIENT_SECRET` (from Twitch Developer Console)
|
||||||
|
- `RAWG_API_KEY` (from RAWG settings)
|
||||||
|
- `THEGAMESDB_API_KEY` (from TheGamesDB API)
|
||||||
|
- `SCREENSCRAPER_USERNAME` (from ScreenScraper)
|
||||||
|
- `SCREENSCRAPER_PASSWORD` (from ScreenScraper)
|
||||||
|
|
||||||
|
#### 2. Workflow Configuration
|
||||||
|
|
||||||
|
The `.gitea/workflows/ci.yml` workflow automatically:
|
||||||
|
|
||||||
|
- ✅ Installs dependencies
|
||||||
|
- ✅ Runs linting checks
|
||||||
|
- ✅ Executes backend tests (Vitest)
|
||||||
|
- ✅ Executes frontend tests (Vitest)
|
||||||
|
- ✅ Starts backend + frontend servers
|
||||||
|
- ✅ Runs E2E tests (Playwright) with real metadata APIs
|
||||||
|
- ✅ Uploads test reports on failure
|
||||||
|
|
||||||
|
#### 3. Testing Flow
|
||||||
|
|
||||||
|
1. **Push** code to `main` or `develop`
|
||||||
|
2. **Gitea Actions** picks up the `.gitea/workflows/ci.yml`
|
||||||
|
3. **Secrets are injected** as environment variables
|
||||||
|
4. **E2E tests** fetch real metadata from APIs (using injected secrets)
|
||||||
|
5. **Build fails** if any test fails (prevents broken code)
|
||||||
|
|
||||||
|
#### 4. Local Development
|
||||||
|
|
||||||
|
For local testing, use `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IGDB_CLIENT_ID=your_local_id
|
||||||
|
IGDB_CLIENT_SECRET=your_local_secret
|
||||||
|
RAWG_API_KEY=your_local_key
|
||||||
|
THEGAMESDB_API_KEY=your_local_key
|
||||||
|
SCREENSCRAPER_USERNAME=your_username
|
||||||
|
SCREENSCRAPER_PASSWORD=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** CI/CD uses Gitea Secrets (not `.env` files), so never commit real credentials.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**"IGDB_CLIENT_ID not found"** → Check `.env` file exists and has correct format
|
||||||
|
|
||||||
|
**"429 Too Many Requests"** → Rate limit exceeded, wait and retry
|
||||||
|
|
||||||
|
**"Invalid API Key"** → Copy key exactly (no spaces), verify it's active on service website
|
||||||
|
|
||||||
|
**"ScreenScraper authentication failed"** → Check donation level and account status
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
215
docs/02-tecnico/frontend.md
Normal file
215
docs/02-tecnico/frontend.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Frontend - Landing Page de Quasar
|
||||||
|
|
||||||
|
## Visión General
|
||||||
|
|
||||||
|
El frontend de Quasar está implementado con **Next.js 16.1.6**, **React 19**, **TypeScript**, **Shadcn UI** y **Tailwind CSS 4**. La landing page presenta una estética **Mass Effect-inspired** con efectos de glassmorphism, holográficos y una paleta de colores cyberpunk cyan y gold sobre fondo oscuro espacial.
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
| Tecnología | Versión | Propósito |
|
||||||
|
| ------------ | ------- | ------------------------------ |
|
||||||
|
| Next.js | 16.1.6 | Framework React con App Router |
|
||||||
|
| React | 19.2.3 | Biblioteca UI |
|
||||||
|
| TypeScript | 5.x | Type safety |
|
||||||
|
| Shadcn UI | 3.8.5 | Componentes accesibles |
|
||||||
|
| Tailwind CSS | 4.x | Estilos utility-first |
|
||||||
|
| Yarn | 4.12.0 | Gestor de paquetes |
|
||||||
|
|
||||||
|
## Estética Visual
|
||||||
|
|
||||||
|
### Paleta de Colores Mass Effect-inspired
|
||||||
|
|
||||||
|
| Color | Hex | Uso |
|
||||||
|
| -------------- | --------- | -------------------------------- |
|
||||||
|
| Background | `#0a0a12` | Fondo oscuro espacial |
|
||||||
|
| Primary (Cyan) | `#00d0e0` | Acentos principales, botones |
|
||||||
|
| Accent (Gold) | `#f0c040` | Detalles secundarios, highlights |
|
||||||
|
| Text | `#ffffff` | Texto principal |
|
||||||
|
| Muted | `#64748b` | Texto secundario |
|
||||||
|
|
||||||
|
### Efectos Visuales Implementados
|
||||||
|
|
||||||
|
- **Glassmorphism:** `backdrop-filter: blur(10px)` en navbar y footer
|
||||||
|
- **Glowing effects:** Brillo cyan y gold en elementos interactivos
|
||||||
|
- **Holographic:** Animación de escaneo horizontal en bordes
|
||||||
|
- **Pulse animation:** Indicadores de estado con pulso
|
||||||
|
- **Starfield background:** Fondo animado de estrellas
|
||||||
|
|
||||||
|
## Componentes de la Landing Page
|
||||||
|
|
||||||
|
### Navbar
|
||||||
|
|
||||||
|
- **Ubicación:** [`frontend/src/components/landing/Navbar.tsx`](../frontend/src/components/landing/Navbar.tsx)
|
||||||
|
- **Características:**
|
||||||
|
- Fijo en la parte superior con glassmorphism
|
||||||
|
- Logo "QUASAR" con efecto glow cyan
|
||||||
|
- Barra de búsqueda con efecto de brillo al enfocar
|
||||||
|
- Responsive con menú móvil desplegable
|
||||||
|
- **Accesibilidad:** `aria-label`, `aria-expanded`, `tabIndex` dinámico
|
||||||
|
|
||||||
|
### Hero Section
|
||||||
|
|
||||||
|
- **Ubicación:** [`frontend/src/components/landing/Hero.tsx`](../frontend/src/components/landing/Hero.tsx)
|
||||||
|
- **Características:**
|
||||||
|
- Imagen de fondo espacial de alta calidad (Unsplash)
|
||||||
|
- Título "FEATURED MISSION" y nombre del juego "Stellar Odyssey"
|
||||||
|
- Efecto holográfico en el borde
|
||||||
|
- Botón CTA "MISSION START" con gradiente cyan-gold
|
||||||
|
- Estadísticas del juego (rating, horas, gráficos)
|
||||||
|
- **Accesibilidad:** `id="hero"`, `aria-labelledby`, `alt` descriptivo
|
||||||
|
|
||||||
|
### Game Grid
|
||||||
|
|
||||||
|
- **Ubicación:** [`frontend/src/components/landing/GameGrid.tsx`](../frontend/src/components/landing/GameGrid.tsx)
|
||||||
|
- **Características:**
|
||||||
|
- Grid de tarjetas de juegos con diseño responsive
|
||||||
|
- Estadísticas reveladas al hover (rating, género, año, plataforma)
|
||||||
|
- Efectos hover con transformación y brillo
|
||||||
|
- **Accesibilidad:** `id="games"`, `aria-labelledby`, `loading="lazy"`, `aria-hidden`
|
||||||
|
|
||||||
|
### Footer
|
||||||
|
|
||||||
|
- **Ubicación:** [`frontend/src/components/landing/Footer.tsx`](../frontend/src/components/landing/Footer.tsx)
|
||||||
|
- **Características:**
|
||||||
|
- Diseño minimalista con glassmorphism
|
||||||
|
- Indicador "SYSTEM STATUS: ONLINE" con animación de pulso
|
||||||
|
- Enlaces de navegación secundarios
|
||||||
|
- **Accesibilidad:** `role="contentinfo"`
|
||||||
|
|
||||||
|
## Configuración del Tema
|
||||||
|
|
||||||
|
### globals.css
|
||||||
|
|
||||||
|
El tema Mass Effect-inspired se configura en [`frontend/src/app/globals.css`](../frontend/src/app/globals.css):
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--background: 240 10% 4%; /* #0a0a12 */
|
||||||
|
--primary: 180 100% 44%; /* #00d0e0 */
|
||||||
|
--accent: 45 90% 60%; /* #f0c040 */
|
||||||
|
/* ... más variables */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animaciones Personalizadas
|
||||||
|
|
||||||
|
- `.glass` - Efecto de vidrio esmerilado
|
||||||
|
- `.glow-cyan`, `.glow-gold` - Efectos de brillo
|
||||||
|
- `.holographic` - Efecto holográfico con escaneo
|
||||||
|
- `.pulse` - Animación de pulso
|
||||||
|
- `.starfield` - Fondo animado de estrellas
|
||||||
|
|
||||||
|
## Accesibilidad y Compliance
|
||||||
|
|
||||||
|
### Web Interface Guidelines Compliance
|
||||||
|
|
||||||
|
| Categoría | Cumplimiento | Detalles |
|
||||||
|
| ----------------- | --------------- | ------------------------------------------------------- |
|
||||||
|
| Accesibilidad | ✅ 95%+ | ARIA labels, keyboard navigation, screen reader support |
|
||||||
|
| Semántica HTML5 | ✅ 95%+ | `id` en secciones, `role` en footer, `main` con id |
|
||||||
|
| Contrast Ratios | ✅ WCAG AA | Cyan `#00d0e0`, Gold `#f0c040` |
|
||||||
|
| Responsive Design | ✅ Mobile-first | `min-h-screen`, breakpoints `sm:`, `md:`, `lg:` |
|
||||||
|
| Performance | ✅ Optimizado | Lazy loading, imágenes optimizadas |
|
||||||
|
| SEO | ✅ Optimizado | Metadata específica, OpenGraph tags, `lang="es"` |
|
||||||
|
|
||||||
|
### Mejoras Implementadas
|
||||||
|
|
||||||
|
- **Accesibilidad:** Labels ARIA, `aria-expanded`, `tabIndex` dinámico, `alt` descriptivos
|
||||||
|
- **Semántica HTML5:** `id` en secciones, `role` en footer, `main` con id
|
||||||
|
- **Contrast Ratios:** Cyan ajustado a `#00d0e0`, Gold ajustado a `#f0c040`
|
||||||
|
- **Responsive Design:** `min-h-screen` en lugar de `h-screen`, `pt-16` en main
|
||||||
|
- **Performance:** `loading="lazy"` en imágenes del grid, `priority` en Hero
|
||||||
|
- **SEO:** Metadata específica de Quasar, `lang="es"`, OpenGraph tags
|
||||||
|
|
||||||
|
## Desarrollo Local
|
||||||
|
|
||||||
|
### Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
# Frontend disponible en: http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build para Producción
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn build
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lint
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
yarn type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estructura de Archivos
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── favicon.ico
|
||||||
|
│ │ ├── globals.css # Tema Mass Effect + animaciones
|
||||||
|
│ │ ├── layout.tsx # Root layout con metadata SEO
|
||||||
|
│ │ └── page.tsx # Landing page con componentes
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── landing/
|
||||||
|
│ │ │ ├── Navbar.tsx # Navbar con glassmorphism
|
||||||
|
│ │ │ ├── Hero.tsx # Hero section con featured game
|
||||||
|
│ │ │ ├── GameGrid.tsx # Grid de tarjetas con hover effects
|
||||||
|
│ │ │ └── Footer.tsx # Footer minimalista
|
||||||
|
│ │ └── ui/
|
||||||
|
│ │ ├── button.tsx # Componente Shadcn UI
|
||||||
|
│ │ ├── card.tsx # Componente Shadcn UI
|
||||||
|
│ │ └── input.tsx # Componente Shadcn UI
|
||||||
|
│ └── lib/
|
||||||
|
│ └── utils.ts # Utilidades de Shadcn UI
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── tailwind.config.ts
|
||||||
|
└── next.config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Componentes Shadcn UI Instalados
|
||||||
|
|
||||||
|
- **Button:** Botones con variantes (default, destructive, outline, secondary, ghost, link)
|
||||||
|
- **Input:** Campos de entrada con estilos consistentes
|
||||||
|
- **Card:** Tarjetas con header, content y footer
|
||||||
|
|
||||||
|
## Imágenes Placeholder
|
||||||
|
|
||||||
|
Todas las imágenes utilizadas son de alta calidad de Unsplash:
|
||||||
|
|
||||||
|
- **Hero background:** Imagen espacial/sci-fi
|
||||||
|
- **Game covers:** Imágenes de videojuegos variados
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
- [ ] Integrar con backend API para datos reales de juegos
|
||||||
|
- [ ] Añadir páginas adicionales (Dashboard, Games Library, Settings)
|
||||||
|
- [ ] Implementar autenticación de usuarios
|
||||||
|
- [ ] Añadir tests unitarios y E2E para componentes
|
||||||
|
- [ ] Implementar internacionalización (i18n)
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs)
|
||||||
|
- [Shadcn UI Documentation](https://ui.shadcn.com)
|
||||||
|
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
|
||||||
|
- [Web Interface Guidelines](https://vercel-labs.github.io/web-interface-guidelines)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Última actualización: 2026-02-23_
|
||||||
546
docs/04-operaciones/deployment.md
Normal file
546
docs/04-operaciones/deployment.md
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
# Guía de Despliegue y Operaciones 🚀
|
||||||
|
|
||||||
|
Esta guía cubre el despliegue, configuración y operación de Quasar en producción.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabla de Contenidos
|
||||||
|
|
||||||
|
1. [Requisitos del Sistema](#requisitos-del-sistema)
|
||||||
|
2. [Configuración de Producción](#configuración-de-producción)
|
||||||
|
3. [Despliegue](#despliegue)
|
||||||
|
4. [Monitoreo y Mantenimiento](#monitoreo-y-mantenimiento)
|
||||||
|
5. [Actualizaciones](#actualizaciones)
|
||||||
|
6. [Backup y Recuperación](#backup-y-recuperación)
|
||||||
|
7. [Solución de Problemas](#solución-de-problemas)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos del Sistema
|
||||||
|
|
||||||
|
### Hardware Mínimo
|
||||||
|
|
||||||
|
- **CPU:** 2 cores
|
||||||
|
- **RAM:** 4GB
|
||||||
|
- **Almacenamiento:** 20GB (para ROMs y metadata)
|
||||||
|
- **Red:** Estable (para descargas de artwork)
|
||||||
|
|
||||||
|
### Software
|
||||||
|
|
||||||
|
- **Node.js 18+**
|
||||||
|
- **Yarn 4.x**
|
||||||
|
- **SQLite** (o PostgreSQL para producción)
|
||||||
|
- **Nginx** (recomendado para reverse proxy)
|
||||||
|
- **Certificado SSL** (HTTPS obligatorio)
|
||||||
|
|
||||||
|
### Dependencias Externas
|
||||||
|
|
||||||
|
- Claves API de IGDB, RAWG, TheGamesDB
|
||||||
|
- Acceso a servicios de descarga de imágenes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuración de Producción
|
||||||
|
|
||||||
|
### Variables de Entorno
|
||||||
|
|
||||||
|
Crear `.env.production` con:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="file:./production.db"
|
||||||
|
# Para PostgreSQL: postgresql://user:password@localhost:5432/quasar
|
||||||
|
|
||||||
|
# API Keys
|
||||||
|
IGDB_CLIENT_ID=your_production_client_id
|
||||||
|
IGDB_CLIENT_SECRET=your_production_client_secret
|
||||||
|
RAWG_API_KEY=your_production_api_key
|
||||||
|
THEGAMESDB_API_KEY=your_production_api_key
|
||||||
|
SCREENSCRAPER_USERNAME=your_screenscraper_username
|
||||||
|
SCREENSCRAPER_PASSWORD=your_screenscraper_password
|
||||||
|
|
||||||
|
# App Config
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Security
|
||||||
|
CORS_ORIGIN=https://yourdomain.com
|
||||||
|
JWT_SECRET=your_secure_jwt_secret_here
|
||||||
|
API_RATE_LIMIT=100
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
CACHE_TTL=86400
|
||||||
|
MAX_CONCURRENT_API_REQUESTS=5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuración de Nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name yourdomain.com;
|
||||||
|
|
||||||
|
# SSL Configuration
|
||||||
|
ssl_certificate /path/to/cert.pem;
|
||||||
|
ssl_certificate_key /path/to/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options DENY;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
root /var/www/quasar/frontend/dist;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 864s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
location /static/ {
|
||||||
|
root /var/www/quasar;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
### Opción 1: Docker (Recomendado)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile
|
||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json yarn.lock* ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
quasar-backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=file:./production.db
|
||||||
|
- IGDB_CLIENT_ID=${IGDB_CLIENT_ID}
|
||||||
|
- IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET}
|
||||||
|
- RAWG_API_KEY=${RAWG_API_KEY}
|
||||||
|
- THEGAMESDB_API_KEY=${THEGAMESDB_API_KEY}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./backend/prisma:/app/prisma
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
quasar-frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- '5173:5173'
|
||||||
|
depends_on:
|
||||||
|
- quasar-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- '443:443'
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
- ./ssl:/etc/nginx/ssl
|
||||||
|
depends_on:
|
||||||
|
- quasar-backend
|
||||||
|
- quasar-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opción 2: VPS Manual
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Setup server
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y nodejs yarn nginx sqlite3
|
||||||
|
|
||||||
|
# 2. Clone repository
|
||||||
|
git clone https://your-repo/quasar.git
|
||||||
|
cd quasar
|
||||||
|
|
||||||
|
# 3. Install dependencies
|
||||||
|
yarn install --production
|
||||||
|
|
||||||
|
# 4. Setup environment
|
||||||
|
cp .env.example .env.production
|
||||||
|
# Edit .env.production with real values
|
||||||
|
|
||||||
|
# 5. Build frontend
|
||||||
|
cd frontend
|
||||||
|
yarn build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 6. Setup database
|
||||||
|
cd backend
|
||||||
|
npx prisma migrate deploy
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 7. Configure nginx
|
||||||
|
sudo cp nginx.conf /etc/nginx/sites-available/quasar
|
||||||
|
sudo ln -s /etc/nginx/sites-available/quasar /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
|
||||||
|
# 8. Start services
|
||||||
|
cd backend
|
||||||
|
nohup yarn start > /var/log/quasar-backend.log 2>&1 &
|
||||||
|
cd ../frontend
|
||||||
|
nohup yarn start > /var/log/quasar-frontend.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoreo y Mantenimiento
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend health
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
|
||||||
|
# Database connection
|
||||||
|
curl http://localhost:3000/api/health/database
|
||||||
|
|
||||||
|
# API rate limits status
|
||||||
|
curl http://localhost:3000/api/health/rate-limits
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Configurar logrotate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/logrotate.d/quasar
|
||||||
|
/var/log/quasar/*.log {
|
||||||
|
daily
|
||||||
|
missingok
|
||||||
|
rotate 7
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoreo de API Keys
|
||||||
|
|
||||||
|
Crear script para verificar límites:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# check-api-limits.sh
|
||||||
|
|
||||||
|
# Check IGDB rate limits
|
||||||
|
curl -s -I "https://api.igdb.com/v4/games" | grep -i "x-ratelimit"
|
||||||
|
|
||||||
|
# Check RAWG usage
|
||||||
|
curl -s "https://api.rawg.io/api/games?key=$RAWG_API_KEY&search=test" | jq '.count'
|
||||||
|
|
||||||
|
# Log warnings
|
||||||
|
echo "$(date): API rate limits checked" >> /var/log/quasar/api-monitor.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Actualizaciones
|
||||||
|
|
||||||
|
### Proceso de Actualización
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Backup
|
||||||
|
./backup.sh
|
||||||
|
|
||||||
|
# 2. Stop services
|
||||||
|
sudo systemctl stop quasar-backend
|
||||||
|
sudo systemctl stop quasar-frontend
|
||||||
|
|
||||||
|
# 3. Pull latest code
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 4. Update dependencies
|
||||||
|
yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
# 5. Build frontend
|
||||||
|
cd frontend && yarn build && cd ..
|
||||||
|
|
||||||
|
# 6. Run migrations
|
||||||
|
cd backend && npx prisma migrate deploy && cd ..
|
||||||
|
|
||||||
|
# 7. Start services
|
||||||
|
sudo systemctl start quasar-backend
|
||||||
|
sudo systemctl start quasar-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actualizaciones de API Keys
|
||||||
|
|
||||||
|
1. Generar nuevas claves en cada servicio
|
||||||
|
2. Actualizar variables de entorno
|
||||||
|
3. Reiniciar servicios
|
||||||
|
4. Monitorear errores durante 24h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backup y Recuperación
|
||||||
|
|
||||||
|
### Script de Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# backup.sh
|
||||||
|
|
||||||
|
BACKUP_DIR="/backups/quasar"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
DB_FILE="quasar_$DATE.db"
|
||||||
|
ROMS_DIR="roms_$DATE"
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
cp backend/prisma/production.db "$BACKUP_DIR/$DB_FILE"
|
||||||
|
|
||||||
|
# Backup ROM metadata (not actual ROMs)
|
||||||
|
cp -r data/roms_metadata "$BACKUP_DIR/$ROMS_DIR"
|
||||||
|
|
||||||
|
# Backup configuration
|
||||||
|
cp .env.production "$BACKUP_DIR/env_$DATE"
|
||||||
|
|
||||||
|
# Compress backup
|
||||||
|
tar -czf "$BACKUP_DIR/backup_$DATE.tar.gz" -C "$BACKUP_DIR" "$DB_FILE" "$ROMS_DIR" "env_$DATE"
|
||||||
|
|
||||||
|
# Clean up old backups (keep last 7 days)
|
||||||
|
find "$BACKUP_DIR" -name "backup_*.tar.gz" -mtime +7 -delete
|
||||||
|
|
||||||
|
echo "Backup completed: $BACKUP_DIR/backup_$DATE.tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recuperación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# restore.sh
|
||||||
|
|
||||||
|
BACKUP_FILE=$1
|
||||||
|
BACKUP_DIR="/backups/quasar"
|
||||||
|
|
||||||
|
if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
|
||||||
|
echo "Backup file not found: $BACKUP_DIR/$BACKUP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
sudo systemctl stop quasar-backend
|
||||||
|
sudo systemctl stop quasar-frontend
|
||||||
|
|
||||||
|
# Extract backup
|
||||||
|
cd "$BACKUP_DIR"
|
||||||
|
tar -xzf "$BACKUP_FILE"
|
||||||
|
|
||||||
|
# Restore database
|
||||||
|
cp "$DB_FILE" backend/prisma/production.db
|
||||||
|
|
||||||
|
# Restore ROM metadata
|
||||||
|
cp -r "$ROMS_DIR"/* data/
|
||||||
|
|
||||||
|
# Restore configuration (optional)
|
||||||
|
# cp "env_$DATE" .env.production
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
sudo systemctl start quasar-backend
|
||||||
|
sudo systemctl start quasar-frontend
|
||||||
|
|
||||||
|
echo "Restore completed from: $BACKUP_FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solución de Problemas
|
||||||
|
|
||||||
|
### Problemas Comunes
|
||||||
|
|
||||||
|
#### 1. "Database connection failed"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database file
|
||||||
|
ls -la backend/prisma/production.db
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
sudo chown -R nodejs:nodejs backend/prisma/
|
||||||
|
|
||||||
|
# Check database integrity
|
||||||
|
sqlite3 backend/prisma/production.db "PRAGMA integrity_check;"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. "API rate limit exceeded"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current rate limits
|
||||||
|
curl -I "https://api.igdb.com/v4/games" | grep -i "x-ratelimit"
|
||||||
|
|
||||||
|
# Implement backoff strategy
|
||||||
|
# Check logs for specific API errors
|
||||||
|
tail -f /var/log/quasar/backend.log | grep "429"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. "Frontend cannot connect to backend"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backend is running
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
|
||||||
|
# Check CORS configuration
|
||||||
|
curl -H "Origin: https://yourdomain.com" -v http://localhost:3000/health
|
||||||
|
|
||||||
|
# Check nginx configuration
|
||||||
|
sudo nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. "ROM scanning fails"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check directory permissions
|
||||||
|
ls -la /path/to/roms/
|
||||||
|
|
||||||
|
# Check file formats
|
||||||
|
find /path/to/roms/ -name "*.zip" -o -name "*.7z" -o -name "*.rar"
|
||||||
|
|
||||||
|
# Check disk space
|
||||||
|
df -h
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diagnóstico Remoto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create diagnostic script
|
||||||
|
#!/bin/bash
|
||||||
|
# diagnostic.sh
|
||||||
|
|
||||||
|
echo "=== Quasar Diagnostic Report ==="
|
||||||
|
echo "Date: $(date)"
|
||||||
|
echo "Node.js version: $(node --version)"
|
||||||
|
echo "Yarn version: $(yarn --version)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== System Resources ==="
|
||||||
|
free -h
|
||||||
|
df -h
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Services Status ==="
|
||||||
|
systemctl status quasar-backend
|
||||||
|
systemctl status quasar-frontend
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Database Status ==="
|
||||||
|
sqlite3 backend/prisma/production.db "SELECT COUNT(*) FROM games;"
|
||||||
|
sqlite3 backend/prisma/production.db "SELECT COUNT(*) FROM rom_files;"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== API Keys Status ==="
|
||||||
|
echo "IGDB: ${IGDB_CLIENT_ID:0:10}..."
|
||||||
|
echo "RAWG: ${RAWG_API_KEY:0:10}..."
|
||||||
|
echo "TheGamesDB: ${THEGAMESDB_API_KEY:0:10}..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Recent Errors ==="
|
||||||
|
tail -20 /var/log/quasar/backend.log | grep -i "error"
|
||||||
|
tail -20 /var/log/quasar/frontend.log | grep -i "error"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Soporte
|
||||||
|
|
||||||
|
### Logs de Depuración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend logs
|
||||||
|
tail -f /var/log/quasar/backend.log
|
||||||
|
|
||||||
|
# Frontend logs
|
||||||
|
tail -f /var/log/quasar/frontend.log
|
||||||
|
|
||||||
|
# Nginx logs
|
||||||
|
tail -f /var/log/nginx/access.log
|
||||||
|
tail -f /var/log/nginx/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contacto
|
||||||
|
|
||||||
|
- **Issues:** Reportar en el repositorio de Gitea
|
||||||
|
- **Emergencias:** Email: support@yourdomain.com
|
||||||
|
- **Documentación:** Ver [docs/README.md](../../README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Última actualización: 2026-02-22_
|
||||||
93
docs/README.md
Normal file
93
docs/README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# 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 con landing page |
|
||||||
|
| 03-analisis | ✅ Completa | Análisis competitivo actualizado |
|
||||||
|
| 04-operaciones | 🚧 En desarrollo | Guías de operación pendientes |
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
- [x] Documentar API REST detallada
|
||||||
|
- [x] Documentar frontend con landing page
|
||||||
|
- [ ] Añadir documentación de testing y CI/CD
|
||||||
|
- [ ] Crear índice temático para búsqueda rápida
|
||||||
|
|
||||||
|
## 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-23_
|
||||||
@@ -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
|
|
||||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
23
frontend/components.json
Normal file
23
frontend/components.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<!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 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>
|
|
||||||
16
frontend/next.config.ts
Normal file
16
frontend/next.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'images.unsplash.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -1,38 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "quasar-frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "yarn@4.12.0",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "next dev",
|
||||||
"build": "vite build",
|
"build": "next build",
|
||||||
"preview": "vite preview",
|
"start": "next start",
|
||||||
"test": "vitest",
|
"lint": "eslint"
|
||||||
"test:run": "vitest run",
|
|
||||||
"lint": "echo \"No lint configured\"",
|
|
||||||
"format": "prettier --write ."
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.0",
|
"class-variance-authority": "^0.7.1",
|
||||||
"@tanstack/react-query": "^4.34.0",
|
"clsx": "^2.1.1",
|
||||||
"react": "^18.2.0",
|
"lucide-react": "^0.575.0",
|
||||||
"react-dom": "^18.2.0",
|
"next": "16.1.6",
|
||||||
"react-hook-form": "^7.48.0",
|
"radix-ui": "^1.4.3",
|
||||||
"zod": "^3.22.0"
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.0.0",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@types/node": "^20",
|
||||||
"@testing-library/user-event": "^14.5.0",
|
"@types/react": "^19",
|
||||||
"@types/react": "^18.2.21",
|
"@types/react-dom": "^19",
|
||||||
"@types/react-dom": "^18.2.7",
|
"eslint": "^9",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"eslint-config-next": "16.1.6",
|
||||||
"autoprefixer": "^10.4.14",
|
"shadcn": "^3.8.5",
|
||||||
"jsdom": "^22.1.0",
|
"tailwindcss": "^4",
|
||||||
"postcss": "^8.4.24",
|
"tw-animate-css": "^1.4.0",
|
||||||
"tailwindcss": "^3.4.7",
|
"typescript": "^5"
|
||||||
"typescript": "^5.2.2",
|
|
||||||
"vite": "^5.1.0",
|
|
||||||
"vitest": "^0.34.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -1,13 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Navbar from './components/layout/Navbar';
|
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Navbar />
|
|
||||||
<main>
|
|
||||||
<h1>Quasar</h1>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
297
frontend/src/app/globals.css
Normal file
297
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: #0a0a12;
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.11 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.11 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: #00d0e0;
|
||||||
|
--primary-foreground: #0a0a12;
|
||||||
|
--secondary: oklch(0.18 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.18 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: #f0c040;
|
||||||
|
--accent-foreground: #0a0a12;
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: #00f0ff;
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.11 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: #00f0ff;
|
||||||
|
--sidebar-primary-foreground: #0a0a12;
|
||||||
|
--sidebar-accent: oklch(0.18 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: #00f0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #0a0a12;
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.11 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.11 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: #00d0e0;
|
||||||
|
--primary-foreground: #0a0a12;
|
||||||
|
--secondary: oklch(0.18 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.18 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: #f0c040;
|
||||||
|
--accent-foreground: #0a0a12;
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: #00f0ff;
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.11 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: #00f0ff;
|
||||||
|
--sidebar-primary-foreground: #0a0a12;
|
||||||
|
--sidebar-accent: oklch(0.18 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: #00f0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mass Effect-inspired theme customizations */
|
||||||
|
:root {
|
||||||
|
/* Custom colors for Mass Effect theme */
|
||||||
|
--mass-effect-dark: #0a0a12;
|
||||||
|
--mass-effect-cyan: #00d0e0;
|
||||||
|
--mass-effect-gold: #f0c040;
|
||||||
|
--mass-effect-cyan-glow: rgba(0, 208, 224, 0.5);
|
||||||
|
--mass-effect-gold-glow: rgba(240, 192, 64, 0.5);
|
||||||
|
--glass-bg: rgba(10, 10, 18, 0.7);
|
||||||
|
--glass-border: rgba(0, 208, 224, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism effect */
|
||||||
|
.glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow effects */
|
||||||
|
.glow-cyan {
|
||||||
|
box-shadow: 0 0 10px var(--mass-effect-cyan-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-cyan-intense {
|
||||||
|
box-shadow: 0 0 20px var(--mass-effect-cyan-glow), 0 0 40px var(--mass-effect-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-gold {
|
||||||
|
box-shadow: 0 0 10px var(--mass-effect-gold-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text effects */
|
||||||
|
.text-glow-cyan {
|
||||||
|
text-shadow: 0 0 10px var(--mass-effect-cyan-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-glow-gold {
|
||||||
|
text-shadow: 0 0 10px var(--mass-effect-gold-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Holographic effect */
|
||||||
|
.holographic {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holographic::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(0, 240, 255, 0.2),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: holographic-scan 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes holographic-scan {
|
||||||
|
0% {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for system status */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover glow effect */
|
||||||
|
.hover-glow:hover {
|
||||||
|
box-shadow: 0 0 15px var(--mass-effect-cyan-glow);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Starfield background */
|
||||||
|
.starfield {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(2px 2px at 20px 30px, #eee, transparent),
|
||||||
|
radial-gradient(2px 2px at 40px 70px, #eee, transparent),
|
||||||
|
radial-gradient(1px 1px at 50px 50px, #eee, transparent),
|
||||||
|
radial-gradient(1px 1px at 80px 10px, #eee, transparent),
|
||||||
|
radial-gradient(2px 2px at 130px 80px, #eee, transparent);
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 200px 200px;
|
||||||
|
opacity: 0.3;
|
||||||
|
animation: starfield-move 120s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes starfield-move {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-200px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom button styles */
|
||||||
|
.btn-mission {
|
||||||
|
background: linear-gradient(45deg, var(--mass-effect-cyan), var(--mass-effect-gold));
|
||||||
|
border: none;
|
||||||
|
color: var(--mass-effect-dark);
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mission:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 0 20px var(--mass-effect-cyan-glow), 0 0 40px var(--mass-effect-gold-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mission::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.2),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transition: left 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mission:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search bar glow effect */
|
||||||
|
.search-glow:focus {
|
||||||
|
box-shadow: 0 0 0 1px var(--mass-effect-cyan), 0 0 15px var(--mass-effect-cyan-glow);
|
||||||
|
border-color: var(--mass-effect-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
frontend/src/app/layout.tsx
Normal file
38
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: '--font-geist-sans',
|
||||||
|
subsets: ['latin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: '--font-geist-mono',
|
||||||
|
subsets: ['latin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Quasar - Tu Biblioteca de Videojuegos',
|
||||||
|
description:
|
||||||
|
'Gestiona tu colección de videojuegos con Quasar. Organiza, escanea y explora tu biblioteca personal.',
|
||||||
|
keywords: ['videojuegos', 'emulador', 'retro gaming', 'video game library'],
|
||||||
|
openGraph: {
|
||||||
|
title: 'Quasar - Tu Biblioteca de Videojuegos',
|
||||||
|
description:
|
||||||
|
'Gestiona tu colección de videojuegos con Quasar. Organiza, escanea y explora tu biblioteca personal.',
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="es" suppressHydrationWarning>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/app/page.tsx
Normal file
28
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Navbar from '@/components/landing/Navbar';
|
||||||
|
import Hero from '@/components/landing/Hero';
|
||||||
|
import GameGrid from '@/components/landing/GameGrid';
|
||||||
|
import Footer from '@/components/landing/Footer';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--mass-effect-dark)' }}>
|
||||||
|
{/* Starfield Background */}
|
||||||
|
<div className="starfield"></div>
|
||||||
|
|
||||||
|
{/* Navbar */}
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main id="main-content" className="pt-16">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<Hero />
|
||||||
|
|
||||||
|
{/* Game Grid Section */}
|
||||||
|
<GameGrid />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Game } from '../../types/game';
|
|
||||||
|
|
||||||
interface GameCardProps {
|
|
||||||
game: Game;
|
|
||||||
onEdit?: (game: Game) => void;
|
|
||||||
onDelete?: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GameCard({ game, onEdit, onDelete }: GameCardProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div className="rounded border border-gray-300 p-4 shadow-sm hover:shadow-md">
|
|
||||||
<h3 className="mb-2 text-lg font-semibold">{game.title}</h3>
|
|
||||||
<p className="mb-2 text-sm text-gray-600">{game.slug}</p>
|
|
||||||
{game.description && <p className="mb-3 text-sm text-gray-700">{game.description}</p>}
|
|
||||||
<p className="mb-4 text-xs text-gray-500">
|
|
||||||
Added: {new Date(game.createdAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{onEdit && (
|
|
||||||
<button
|
|
||||||
onClick={() => onEdit(game)}
|
|
||||||
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onDelete && (
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(game.id)}
|
|
||||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { Game, CreateGameInput } from '../../types/game';
|
|
||||||
|
|
||||||
const gameFormSchema = z.object({
|
|
||||||
title: z.string().min(1, 'Title is required'),
|
|
||||||
platformId: z.string().min(1, 'Platform is required'),
|
|
||||||
description: z.string().optional().nullable(),
|
|
||||||
priceCents: z.number().optional(),
|
|
||||||
currency: z.string().optional().default('USD'),
|
|
||||||
store: z.string().optional(),
|
|
||||||
date: z.string().optional(),
|
|
||||||
condition: z.enum(['Loose', 'CIB', 'New']).optional(),
|
|
||||||
notes: z.string().optional().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type GameFormData = z.infer<typeof gameFormSchema>;
|
|
||||||
|
|
||||||
interface GameFormProps {
|
|
||||||
initialData?: Game;
|
|
||||||
onSubmit: (data: CreateGameInput | Game) => void | Promise<void>;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GameForm({
|
|
||||||
initialData,
|
|
||||||
onSubmit,
|
|
||||||
isLoading = false,
|
|
||||||
}: GameFormProps): JSX.Element {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<GameFormData>({
|
|
||||||
resolver: zodResolver(gameFormSchema),
|
|
||||||
defaultValues: initialData
|
|
||||||
? {
|
|
||||||
title: initialData.title,
|
|
||||||
description: initialData.description,
|
|
||||||
priceCents: undefined,
|
|
||||||
currency: 'USD',
|
|
||||||
store: undefined,
|
|
||||||
date: undefined,
|
|
||||||
condition: undefined,
|
|
||||||
notes: undefined,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = (data: GameFormData) => {
|
|
||||||
onSubmit(data as CreateGameInput);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="title" className="block text-sm font-medium">
|
|
||||||
Title *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('title')}
|
|
||||||
id="title"
|
|
||||||
type="text"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
{errors.title && <p className="text-red-600 text-sm">{errors.title.message}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="platformId" className="block text-sm font-medium">
|
|
||||||
Platform *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('platformId')}
|
|
||||||
id="platformId"
|
|
||||||
type="text"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
{errors.platformId && <p className="text-red-600 text-sm">{errors.platformId.message}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="condition" className="block text-sm font-medium">
|
|
||||||
Condition
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
{...register('condition')}
|
|
||||||
id="condition"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<option value="">Select condition</option>
|
|
||||||
<option value="Loose">Loose</option>
|
|
||||||
<option value="CIB">CIB</option>
|
|
||||||
<option value="New">New</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="description" className="block text-sm font-medium">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
{...register('description')}
|
|
||||||
id="description"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="priceCents" className="block text-sm font-medium">
|
|
||||||
Price (cents)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('priceCents', { valueAsNumber: true })}
|
|
||||||
id="priceCents"
|
|
||||||
type="number"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="currency" className="block text-sm font-medium">
|
|
||||||
Currency
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('currency')}
|
|
||||||
id="currency"
|
|
||||||
type="text"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
defaultValue="USD"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="store" className="block text-sm font-medium">
|
|
||||||
Store
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('store')}
|
|
||||||
id="store"
|
|
||||||
type="text"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="date" className="block text-sm font-medium">
|
|
||||||
Purchase Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
{...register('date')}
|
|
||||||
id="date"
|
|
||||||
type="date"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="notes" className="block text-sm font-medium">
|
|
||||||
Notes
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
{...register('notes')}
|
|
||||||
id="notes"
|
|
||||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-400"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Saving...' : 'Save Game'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
55
frontend/src/components/landing/Footer.tsx
Normal file
55
frontend/src/components/landing/Footer.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
return (
|
||||||
|
<footer className="glass py-8 px-4" role="contentinfo">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||||
|
{/* System Status */}
|
||||||
|
<div className="flex items-center mb-4 md:mb-0">
|
||||||
|
<div className="flex items-center mr-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full mr-2 pulse"
|
||||||
|
style={{ backgroundColor: 'var(--mass-effect-cyan)' }}
|
||||||
|
></div>
|
||||||
|
<span
|
||||||
|
className="text-sm font-mono uppercase tracking-wider"
|
||||||
|
style={{ color: 'var(--mass-effect-cyan)' }}
|
||||||
|
>
|
||||||
|
SYSTEM STATUS: ONLINE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<div className="flex space-x-6 mb-4 md:mb-0">
|
||||||
|
<a href="#" className="text-gray-400 hover:text-cyan-400 transition-colors">
|
||||||
|
About
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-gray-400 hover:text-cyan-400 transition-colors">
|
||||||
|
Support
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-gray-400 hover:text-cyan-400 transition-colors">
|
||||||
|
API
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-gray-400 hover:text-cyan-400 transition-colors">
|
||||||
|
Privacy
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-gray-400 hover:text-cyan-400 transition-colors">
|
||||||
|
Terms
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copyright */}
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
© {new Date().getFullYear()} QUASAR. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
189
frontend/src/components/landing/GameGrid.tsx
Normal file
189
frontend/src/components/landing/GameGrid.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
interface Game {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
coverImage: string;
|
||||||
|
rating: number;
|
||||||
|
genre: string;
|
||||||
|
year: number;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameGrid = () => {
|
||||||
|
const [hoveredGame, setHoveredGame] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const games: Game[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Nebula Warriors',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1511512578047-dfb367046420?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=687&q=80',
|
||||||
|
rating: 92,
|
||||||
|
genre: 'Action',
|
||||||
|
year: 2023,
|
||||||
|
platform: 'Multi',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Cyber Revolution',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1550745165-9bc0b252726a?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||||
|
rating: 88,
|
||||||
|
genre: 'RPG',
|
||||||
|
year: 2022,
|
||||||
|
platform: 'PC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Quantum Escape',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1538481199705-c710c4e965fc?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||||
|
rating: 85,
|
||||||
|
genre: 'Puzzle',
|
||||||
|
year: 2023,
|
||||||
|
platform: 'Console',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'Galactic Frontline',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1550745165-9bc0b252726a?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||||
|
rating: 90,
|
||||||
|
genre: 'Strategy',
|
||||||
|
year: 2023,
|
||||||
|
platform: 'Multi',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Digital Horizon',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1518709268805-4e9042af2176?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||||
|
rating: 87,
|
||||||
|
genre: 'Racing',
|
||||||
|
year: 2022,
|
||||||
|
platform: 'Console',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: 'Shadow Protocol',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1511512578047-dfb367046420?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=687&q=80',
|
||||||
|
rating: 91,
|
||||||
|
genre: 'Stealth',
|
||||||
|
year: 2023,
|
||||||
|
platform: 'PC',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-4" id="games" aria-labelledby="games-title">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h2
|
||||||
|
id="games-title"
|
||||||
|
className="text-3xl md:text-4xl font-bold text-center mb-12 uppercase tracking-wider"
|
||||||
|
style={{
|
||||||
|
color: 'var(--mass-effect-gold)',
|
||||||
|
textShadow: '0 0 10px var(--mass-effect-gold-glow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Game Library
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{games.map((game) => (
|
||||||
|
<Card
|
||||||
|
key={game.id}
|
||||||
|
className="relative overflow-hidden border-0 glass hover-glow cursor-pointer transition-all duration-300"
|
||||||
|
onMouseEnter={() => setHoveredGame(game.id)}
|
||||||
|
onMouseLeave={() => setHoveredGame(null)}
|
||||||
|
>
|
||||||
|
<div className="relative h-64">
|
||||||
|
<Image
|
||||||
|
src={game.coverImage}
|
||||||
|
alt={`Portada del juego ${game.title}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay with game info on hover */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-black bg-opacity-80 flex flex-col justify-end p-4 transition-opacity duration-300 ${
|
||||||
|
hoveredGame === game.id ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="text-xl font-bold mb-2"
|
||||||
|
style={{ color: 'var(--mass-effect-cyan)' }}
|
||||||
|
>
|
||||||
|
{game.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">RATING:</span>
|
||||||
|
<span className="ml-2 font-bold" style={{ color: 'var(--mass-effect-gold)' }}>
|
||||||
|
{game.rating}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">GENRE:</span>
|
||||||
|
<span className="ml-2">{game.genre}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">YEAR:</span>
|
||||||
|
<span className="ml-2">{game.year}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">PLATFORM:</span>
|
||||||
|
<span className="ml-2">{game.platform}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Holographic border effect */}
|
||||||
|
{hoveredGame === game.id && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 holographic pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="text-lg font-bold mb-2" style={{ color: 'var(--mass-effect-cyan)' }}>
|
||||||
|
{game.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full mr-2"
|
||||||
|
style={{ backgroundColor: 'var(--mass-effect-gold)' }}
|
||||||
|
></div>
|
||||||
|
<span className="text-sm text-gray-300">{game.genre}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span
|
||||||
|
className="text-sm font-bold mr-1"
|
||||||
|
style={{ color: 'var(--mass-effect-gold)' }}
|
||||||
|
>
|
||||||
|
{game.rating}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-400">/100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GameGrid;
|
||||||
121
frontend/src/components/landing/Hero.tsx
Normal file
121
frontend/src/components/landing/Hero.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
const Hero = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="relative min-h-screen flex items-center justify-center overflow-hidden"
|
||||||
|
id="hero"
|
||||||
|
aria-labelledby="hero-title"
|
||||||
|
>
|
||||||
|
{/* Background Image */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<Image
|
||||||
|
src="https://images.unsplash.com/photo-1446776653964-20c1d3a81b06?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1740&q=80"
|
||||||
|
alt="Fondo espacial con estrellas para el juego destacado"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black opacity-60"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Holographic Border Effect */}
|
||||||
|
<div className="absolute inset-0 z-10 holographic pointer-events-none"></div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-20 text-center px-4 max-w-4xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2
|
||||||
|
id="hero-title"
|
||||||
|
className="text-3xl md:text-4xl font-bold uppercase tracking-wider mb-4"
|
||||||
|
style={{
|
||||||
|
color: 'var(--mass-effect-gold)',
|
||||||
|
textShadow: '0 0 10px var(--mass-effect-gold-glow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Featured Mission
|
||||||
|
</h2>
|
||||||
|
<h1
|
||||||
|
className="text-5xl md:text-7xl font-bold uppercase tracking-wider mb-6"
|
||||||
|
style={{
|
||||||
|
color: 'var(--mass-effect-cyan)',
|
||||||
|
textShadow: '0 0 15px var(--mass-effect-cyan-glow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stellar Odyssey
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="text-lg md:text-xl text-white max-w-2xl mx-auto mb-8"
|
||||||
|
style={{ textShadow: '0 0 5px rgba(0, 0, 0, 0.8)' }}
|
||||||
|
>
|
||||||
|
Embark on an epic journey through uncharted galaxies. Command your starship, explore
|
||||||
|
alien worlds, and uncover the mysteries of the universe in this groundbreaking space
|
||||||
|
exploration adventure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
|
<Button
|
||||||
|
className="btn-mission text-lg px-8 py-3"
|
||||||
|
onClick={() => console.log('Mission Start clicked')}
|
||||||
|
>
|
||||||
|
MISSION START
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500 text-cyan-500 hover:bg-cyan-500 hover:text-black text-lg px-8 py-3"
|
||||||
|
onClick={() => console.log('Learn More clicked')}
|
||||||
|
>
|
||||||
|
LEARN MORE
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Game Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mt-12 max-w-md mx-auto">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold" style={{ color: 'var(--mass-effect-cyan)' }}>
|
||||||
|
94%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-300">RATING</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold" style={{ color: 'var(--mass-effect-cyan)' }}>
|
||||||
|
50+
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-300">HOURS</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold" style={{ color: 'var(--mass-effect-cyan)' }}>
|
||||||
|
4K
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-300">GRAPHICS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Indicator */}
|
||||||
|
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-20">
|
||||||
|
<div className="animate-bounce">
|
||||||
|
<svg
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 30 30"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15 20L8 13L9.4 11.6L15 17.2L20.6 11.6L22 13L15 20Z"
|
||||||
|
fill="var(--mass-effect-cyan)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Hero;
|
||||||
160
frontend/src/components/landing/Navbar.tsx
Normal file
160
frontend/src/components/landing/Navbar.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
const Navbar = () => {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
setIsMenuOpen(!isMenuOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 left-0 right-0 z-50 glass">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold text-glow-cyan"
|
||||||
|
style={{ color: 'var(--mass-effect-cyan)' }}
|
||||||
|
>
|
||||||
|
QUASAR
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar - Desktop */}
|
||||||
|
<div className="hidden md:flex flex-1 max-w-md mx-8">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="SEARCH GAMES..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="search-glow bg-transparent border border-gray-600 text-white placeholder-gray-400"
|
||||||
|
style={{ borderColor: 'var(--mass-effect-cyan)' }}
|
||||||
|
aria-label="Campo de búsqueda de juegos"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links - Desktop */}
|
||||||
|
<div className="hidden md:flex items-center space-x-6">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors"
|
||||||
|
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
|
||||||
|
>
|
||||||
|
GAMES
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors"
|
||||||
|
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
|
||||||
|
>
|
||||||
|
LIBRARY
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors"
|
||||||
|
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
|
||||||
|
>
|
||||||
|
STATS
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500 text-cyan-500 hover:bg-cyan-500 hover:text-black"
|
||||||
|
>
|
||||||
|
LOGIN
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleMenu}
|
||||||
|
className="text-white"
|
||||||
|
aria-label={isMenuOpen ? 'Cerrar menú' : 'Abrir menú'}
|
||||||
|
aria-expanded={isMenuOpen}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{isMenuOpen ? (
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1="4" y1="6" x2="20" y2="6" />
|
||||||
|
<line x1="4" y1="12" x2="20" y2="12" />
|
||||||
|
<line x1="4" y1="18" x2="20" y2="18" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div className="md:hidden mt-4 glass rounded-lg p-4">
|
||||||
|
{/* Search Bar - Mobile */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="SEARCH GAMES..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="search-glow bg-transparent border border-gray-600 text-white placeholder-gray-400 w-full"
|
||||||
|
style={{ borderColor: 'var(--mass-effect-cyan)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links - Mobile */}
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors py-2"
|
||||||
|
tabIndex={isMenuOpen ? 0 : -1}
|
||||||
|
>
|
||||||
|
GAMES
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors py-2"
|
||||||
|
tabIndex={isMenuOpen ? 0 : -1}
|
||||||
|
>
|
||||||
|
LIBRARY
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors py-2"
|
||||||
|
tabIndex={isMenuOpen ? 0 : -1}
|
||||||
|
>
|
||||||
|
STATS
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500 text-cyan-500 hover:bg-cyan-500 hover:text-black w-full"
|
||||||
|
>
|
||||||
|
LOGIN
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Navbar(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<nav style={{ padding: 12 }}>
|
|
||||||
<a href="/roms" style={{ marginRight: 12 }}>
|
|
||||||
ROMs
|
|
||||||
</a>
|
|
||||||
<a href="/games">Games</a>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Sidebar(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<aside style={{ padding: 12 }}>
|
|
||||||
<div>Sidebar (placeholder)</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
64
frontend/src/components/ui/button.tsx
Normal file
64
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { api } from '../lib/api';
|
|
||||||
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
|
||||||
|
|
||||||
const GAMES_QUERY_KEY = ['games'];
|
|
||||||
|
|
||||||
export function useGames() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: GAMES_QUERY_KEY,
|
|
||||||
queryFn: () => api.games.list(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateGame() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateGameInput) => api.games.create(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateGame() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, data }: { id: string; data: UpdateGameInput }) => api.games.update(id, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteGame() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => api.games.delete(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
|
||||||
|
|
||||||
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',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
export const queryClient = new QueryClient();
|
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import React 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';
|
|
||||||
|
|
||||||
const rootEl = document.getElementById('root');
|
|
||||||
|
|
||||||
if (rootEl) {
|
|
||||||
createRoot(rootEl).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { queryClient } from './lib/queryClient';
|
|
||||||
import App from './App';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useGames, useCreateGame, useUpdateGame, useDeleteGame } from '../hooks/useGames';
|
|
||||||
import GameForm from '../components/games/GameForm';
|
|
||||||
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
|
||||||
|
|
||||||
export default function Games(): JSX.Element {
|
|
||||||
const { data: games, isLoading, error } = useGames();
|
|
||||||
const createMutation = useCreateGame();
|
|
||||||
const updateMutation = useUpdateGame();
|
|
||||||
const deleteMutation = useDeleteGame();
|
|
||||||
|
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
||||||
const [selectedGame, setSelectedGame] = useState<Game | null>(null);
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleCreate = async (data: CreateGameInput | Game) => {
|
|
||||||
try {
|
|
||||||
await createMutation.mutateAsync(data as CreateGameInput);
|
|
||||||
setIsFormOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to create game:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (data: CreateGameInput | Game) => {
|
|
||||||
if (!selectedGame) return;
|
|
||||||
try {
|
|
||||||
await updateMutation.mutateAsync({
|
|
||||||
id: selectedGame.id,
|
|
||||||
data: data as UpdateGameInput,
|
|
||||||
});
|
|
||||||
setSelectedGame(null);
|
|
||||||
setIsFormOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update game:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteMutation.mutateAsync(id);
|
|
||||||
setDeleteConfirm(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete game:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenForm = (game?: Game) => {
|
|
||||||
if (game) {
|
|
||||||
setSelectedGame(game);
|
|
||||||
} else {
|
|
||||||
setSelectedGame(null);
|
|
||||||
}
|
|
||||||
setIsFormOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
|
||||||
setIsFormOpen(false);
|
|
||||||
setSelectedGame(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<h2 className="text-xl font-bold text-red-600">Error</h2>
|
|
||||||
<p>{error instanceof Error ? error.message : 'Failed to load games'}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-bold">Games</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => handleOpenForm()}
|
|
||||||
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:bg-gray-400"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Add Game
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isFormOpen && (
|
|
||||||
<div className="mb-6 rounded border border-gray-300 p-4">
|
|
||||||
<div className="mb-4 flex justify-between">
|
|
||||||
<h3 className="text-lg font-semibold">{selectedGame ? 'Edit Game' : 'Create Game'}</h3>
|
|
||||||
<button onClick={handleCloseForm} className="text-gray-600 hover:text-gray-900">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<GameForm
|
|
||||||
initialData={selectedGame || undefined}
|
|
||||||
onSubmit={selectedGame ? handleUpdate : handleCreate}
|
|
||||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && !games ? (
|
|
||||||
<p className="text-gray-600">Loading games...</p>
|
|
||||||
) : !games || games.length === 0 ? (
|
|
||||||
<p className="text-gray-600">No games found. Create one to get started!</p>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse border border-gray-300">
|
|
||||||
<thead className="bg-gray-100">
|
|
||||||
<tr>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Title</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Slug</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Created</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-center">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{games.map((game) => (
|
|
||||||
<tr key={game.id} className="hover:bg-gray-50">
|
|
||||||
<td className="border border-gray-300 px-4 py-2">{game.title}</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2">{game.slug}</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2">
|
|
||||||
{new Date(game.createdAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2 text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => handleOpenForm(game)}
|
|
||||||
className="mr-2 rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700"
|
|
||||||
disabled={updateMutation.isPending || deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
{deleteConfirm === game.id ? (
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(game.id)}
|
|
||||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(null)}
|
|
||||||
className="rounded bg-gray-600 px-3 py-1 text-sm text-white hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(game.id)}
|
|
||||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
|
||||||
disabled={updateMutation.isPending || deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Home(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Home</h2>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Roms(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>ROMs</h2>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import '@testing-library/jest-dom';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/* Minimal global styles */
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family:
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
'Helvetica Neue',
|
|
||||||
Arial;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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: [],
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import App from '../src/App';
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('renderiza el título Quasar', () => {
|
|
||||||
render(<App />);
|
|
||||||
expect(screen.getByText('Quasar')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import App from '../src/App';
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('renders Quasar', () => {
|
|
||||||
render(<App />);
|
|
||||||
expect(screen.getByText(/Quasar/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import GameForm from '../../src/components/games/GameForm';
|
|
||||||
import { Game } from '../../src/types/game';
|
|
||||||
|
|
||||||
describe('GameForm Component', () => {
|
|
||||||
let mockOnSubmit: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockOnSubmit = vi.fn();
|
|
||||||
mockOnSubmit.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render form with required fields', () => {
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/platform/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render optional fields', () => {
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
// búsqueda de campos opcionales
|
|
||||||
expect(screen.getByLabelText(/price/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/notes/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate required title field', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/title.*required/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate required platform field', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const titleInput = screen.getByLabelText(/title/i);
|
|
||||||
await user.type(titleInput, 'My Game');
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Si platform es requerido, debe validarse
|
|
||||||
const platformError = screen.queryByText(/platform.*required/i);
|
|
||||||
if (platformError) {
|
|
||||||
expect(platformError).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should submit valid form data', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const titleInputs = screen.getAllByDisplayValue('');
|
|
||||||
const titleInput = titleInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'title'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const platformInputs = screen.getAllByDisplayValue('');
|
|
||||||
const platformInput = platformInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'platformId'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
await user.type(titleInput, 'Zelda Game');
|
|
||||||
await user.type(platformInput, 'Nintendo');
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
// Simple check: button should not be disabled or error should appear
|
|
||||||
expect(screen.queryByText(/required/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow optional fields to be empty', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const titleInputs = screen.getAllByDisplayValue('');
|
|
||||||
const titleInput = titleInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'title'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const platformInputs = screen.getAllByDisplayValue('');
|
|
||||||
const platformInput = platformInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'platformId'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
await user.type(titleInput, 'Game Title');
|
|
||||||
await user.type(platformInput, 'PS5');
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
// Check that form doesn't show validation errors
|
|
||||||
expect(screen.queryByText(/required/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should populate form with initial data when provided', async () => {
|
|
||||||
const initialGame: Partial<Game> = {
|
|
||||||
id: '1',
|
|
||||||
title: 'Existing Game',
|
|
||||||
slug: 'existing-game',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<GameForm initialData={initialGame as Game} onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
expect(screen.getByDisplayValue('Existing Game')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading state', () => {
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} isLoading={true} />);
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Saving...');
|
|
||||||
expect(submitButton).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import Navbar from '../../src/components/layout/Navbar';
|
|
||||||
|
|
||||||
describe('Navbar', () => {
|
|
||||||
it('muestra enlaces ROMs y Games', () => {
|
|
||||||
render(<Navbar />);
|
|
||||||
expect(screen.getByText('ROMs')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Games')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import Navbar from '../../src/components/layout/Navbar';
|
|
||||||
|
|
||||||
describe('Navbar', () => {
|
|
||||||
it('renders ROMs and Games links', () => {
|
|
||||||
render(<Navbar />);
|
|
||||||
expect(screen.getByText(/ROMs/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Games/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { queryClient } from '../../src/lib/queryClient';
|
|
||||||
import Games from '../../src/routes/games';
|
|
||||||
import * as useGamesModule from '../../src/hooks/useGames';
|
|
||||||
|
|
||||||
// Mock the useGames hooks
|
|
||||||
vi.spyOn(useGamesModule, 'useGames');
|
|
||||||
vi.spyOn(useGamesModule, 'useCreateGame');
|
|
||||||
vi.spyOn(useGamesModule, 'useUpdateGame');
|
|
||||||
vi.spyOn(useGamesModule, 'useDeleteGame');
|
|
||||||
|
|
||||||
const mockGames = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: 'The Legend of Zelda',
|
|
||||||
slug: 'zelda-game',
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
|
||||||
description: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: 'Super Mario Bros',
|
|
||||||
slug: 'mario-game',
|
|
||||||
createdAt: '2026-01-02T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-02T00:00:00Z',
|
|
||||||
description: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('Games Page', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
// Default mocks
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: mockGames,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useCreateGame).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useUpdateGame).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useDeleteGame).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render empty state when no games', () => {
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: [],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/no games found/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render loading state', () => {
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/loading games/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render error state', () => {
|
|
||||||
const error = new Error('Failed to fetch');
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
error,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render table with games', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('The Legend of Zelda')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Super Mario Bros')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render "Add Game" button', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /add game/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open form when "Add Game" is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const addButton = screen.getByRole('button', { name: /add game/i });
|
|
||||||
await user.click(addButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/create game/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open form for editing when edit button is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const editButtons = screen.getAllByRole('button', { name: /edit/i });
|
|
||||||
await user.click(editButtons[0]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/edit game/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show delete confirmation when delete is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
|
||||||
await user.click(deleteButtons[0]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call delete mutation when confirmed', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const deleteAsync = vi.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useDeleteGame).mockReturnValue({
|
|
||||||
mutateAsync: deleteAsync,
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
|
||||||
await user.click(deleteButtons[0]);
|
|
||||||
|
|
||||||
const confirmButton = await screen.findByRole('button', { name: /confirm/i });
|
|
||||||
await user.click(confirmButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(deleteAsync).toHaveBeenCalledWith('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display table headers', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Slug')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Created')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ES2017",
|
||||||
"useDefineForClassFields": true,
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
"allowJs": true,
|
||||||
"jsx": "react-jsx",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"jsx": "react-jsx",
|
||||||
"types": ["vite/client", "vitest/globals"]
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "tests"]
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
setupFiles: ['./src/setupTests.ts'],
|
|
||||||
include: ['tests/**/*.spec.tsx'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: { port: 5173 },
|
|
||||||
});
|
|
||||||
12
package.json
12
package.json
@@ -10,9 +10,13 @@
|
|||||||
"description": "Quasar es una aplicación web para al gestión de una biblioteca personal de videjuegos. Permite a los usuarios catalogar, organizar y buscar sus juegos de manera eficiente. Se pueden agregar videjuegos físicos, digitales y roms de emuladores.",
|
"description": "Quasar es una aplicación web para al gestión de una biblioteca personal de videjuegos. Permite a los usuarios catalogar, organizar y buscar sus juegos de manera eficiente. Se pueden agregar videjuegos físicos, digitales y roms de emuladores.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "playwright test",
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"test:install": "playwright install --with-deps",
|
"test:install": "playwright install --with-deps",
|
||||||
"test:ci": "playwright test --reporter=github",
|
"test:ci": "vitest run --reporter=github",
|
||||||
|
"test:playwright": "playwright test",
|
||||||
|
"test:e2e": "playwright test tests/e2e",
|
||||||
|
"test:e2e:debug": "playwright test tests/e2e --debug",
|
||||||
"lint": "eslint . --ext .js,.ts",
|
"lint": "eslint . --ext .js,.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"start": "node src/index.js"
|
"start": "node src/index.js"
|
||||||
@@ -31,7 +35,9 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^0.34.1",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.12.0+sha512.f45ab632439a67f8bc759bf32ead036a1f413287b9042726b7cc4818b7b49e14e9423ba49b18f9e06ea4941c1ad062385b1d8760a8d5091a1a31e5f6219afca8"
|
"packageManager": "yarn@4.12.0+sha512.f45ab632439a67f8bc759bf32ead036a1f413287b9042726b7cc4818b7b49e14e9423ba49b18f9e06ea4941c1ad062385b1d8760a8d5091a1a31e5f6219afca8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
## Phase 1 Complete: Análisis comparativo de proyectos y servicios
|
|
||||||
|
|
||||||
TL;DR: Se crearon y completaron cuatro documentos de análisis en `docs/` que resumen proyectos relevantes, APIs públicas y consideraciones legales para el MVP. Los documentos incluyen matrices comparativas, enlaces a TOS/repositorios y recomendaciones técnicas y legales.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- `docs/competitive-analysis.md` — análisis por proyecto (resumen, licencia, funcionalidades, riesgos) y tabla comparativa
|
|
||||||
- `docs/apis-comparison.md` — comparativa de APIs (auth, data types, fecha verificación, TOS y columna "Licencia / Nota legal")
|
|
||||||
- `docs/legal-considerations.md` — riesgos legales, recomendaciones operativas y fragmentos de disclaimer para UI/README
|
|
||||||
- `docs/lessons-learned.md` — lista priorizada de funcionalidades, PoC propuesta y recomendaciones técnicas
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- Ninguna (documentación)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- Ninguno (el usuario solicitó no crear tests para esta fase)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED ✅
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
chore: add comparative analysis docs
|
|
||||||
|
|
||||||
- Add `docs/competitive-analysis.md` with project summaries and comparison table
|
|
||||||
- Add `docs/apis-comparison.md` with API TOS links and license notes
|
|
||||||
- Add `docs/legal-considerations.md` and `docs/lessons-learned.md` with recommendations and PoC
|
|
||||||
- Add `Metadatos` block (Autor / Fecha verificación: 2026-02-07 / Última actualización)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
## Phase 2 Complete: Requisitos y diseño técnico
|
|
||||||
|
|
||||||
TL;DR: Se documentaron y finalizaron los requisitos funcionales y no funcionales del MVP, el diseño de arquitectura (monorepo, stack propuesto) y el modelo de datos inicial para `Game`, `RomFile`, `Platform`, `Artwork`, `Purchase` y `PriceHistory`.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- `docs/requirements.md`
|
|
||||||
- `docs/architecture.md`
|
|
||||||
- `docs/api-integration.md`
|
|
||||||
- `docs/data-model.md`
|
|
||||||
- `plans/gestor-coleccion-plan.md` (plan maestro actualizado)
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- Ninguna (documentación)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- Ninguno (recomendación: añadir tests que verifiquen la presencia y metadatos de los documentos claves si se automatiza la validación de docs en CI)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED ✅ (con recomendación menor: añadir `docs/legal-considerations.md` si falta para cubrir riesgos legales antes de integrar scraping o descargas masivas)
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
|
|
||||||
```
|
|
||||||
chore(docs): completar Fase 2 — requisitos y arquitectura
|
|
||||||
|
|
||||||
- Añade/actualiza `docs/requirements.md`, `docs/architecture.md`, `docs/api-integration.md`, `docs/data-model.md`
|
|
||||||
- Documenta criterios de aceptación y decisiones técnico-arquitectónicas
|
|
||||||
- Recomendación: añadir `docs/legal-considerations.md` (pendiente)
|
|
||||||
```
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
## Phase 3 Complete: ArchiveReader
|
|
||||||
|
|
||||||
TL;DR: Implementado `archiveReader` para listar entradas dentro de contenedores ZIP y 7z usando utilidades del sistema (`7z` y `unzip` como fallback). Añadidos tests unitarios que mockean las llamadas a `child_process.exec` para validar parsing y comportamiento de fallback.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/services/archiveReader.ts
|
|
||||||
- backend/tests/services/archiveReader.spec.ts
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `listArchiveEntries(filePath, logger)` — lista entradas de ZIP/7z usando `7z -slt` y `unzip -l` como fallback.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/services/archiveReader.spec.ts` — cubre:
|
|
||||||
- listado con salida simulada de `7z -slt`
|
|
||||||
- fallback a `unzip -l` si `7z` falla
|
|
||||||
- comportamiento para formatos no soportados
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: add archive reader and tests
|
|
||||||
|
|
||||||
- Añade `archiveReader` que lista entradas en ZIP/7z con fallback a `unzip`
|
|
||||||
- Añade tests unitarios que mockean `child_process.exec` para validar parsing
|
|
||||||
- Documenta dependencia de binarios en README y CI (pasos previos)
|
|
||||||
|
|
||||||
## Phase 3 Complete: Backend base y modelo de datos
|
|
||||||
|
|
||||||
Fase completada: configuré el backend mínimo (dependencias, Prisma schema), generé el cliente Prisma y aseguré que los tests TDD de backend pasan.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/package.json
|
|
||||||
- backend/prisma/schema.prisma
|
|
||||||
- backend/tests/models/game.spec.ts
|
|
||||||
- package.json
|
|
||||||
- .yarnrc.yml
|
|
||||||
- prisma-client/package.json
|
|
||||||
|
|
||||||
**Files generados por herramientas (no necesariamente versionadas):**
|
|
||||||
|
|
||||||
- prisma-client/client/\* (Prisma Client generado)
|
|
||||||
- node_modules/.prisma/client/\* (artefacto runtime generado)
|
|
||||||
|
|
||||||
**Functions / cambios clave:**
|
|
||||||
|
|
||||||
- Ajustes en `backend/tests/models/game.spec.ts` para fallback de carga del cliente Prisma generado.
|
|
||||||
- `backend/prisma/schema.prisma`: definición de modelos (Game, RomFile, Platform, Purchase, Artwork, Tag, PriceHistory) ya presente; ajustado el `generator client` para flujo de generación local.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- backend/tests/models/game.spec.ts (modificado: mejor manejo de require/generación del cliente)
|
|
||||||
- backend/tests/server.spec.ts (existente — pase verificable)
|
|
||||||
|
|
||||||
**Migraciones aplicadas durante pruebas:**
|
|
||||||
|
|
||||||
- `backend/prisma/migrations/20260208102247_init/migration.sql` (aplicada en DB temporal de test)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: backend base, Prisma schema, client gen and tests
|
|
||||||
|
|
||||||
- Añade/ajusta `backend` para usar Prisma y Vitest
|
|
||||||
- Genera cliente Prisma y corrige resoluciones PnP/node-modules
|
|
||||||
- Actualiza tests para cargar cliente generado y pasar TDD
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
## Phase 4 Complete: DAT verifier
|
|
||||||
|
|
||||||
TL;DR: Implementado `datVerifier` para parsear archivos DAT (XML) y verificar hashes de ROMs (CRC/MD5/SHA1/size). Se añadieron tests TDD y una fixture XML; los tests específicos pasan y se aplicó un parche menor de calidad.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/services/datVerifier.ts
|
|
||||||
- backend/tests/services/datVerifier.spec.ts
|
|
||||||
- backend/tests/fixtures/sample.dat.xml
|
|
||||||
- backend/package.json (se añadió `fast-xml-parser` en devDependencies)
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `parseDat(xml: string): DatDatabase` — parsea y normaliza la estructura DAT a un modelo en memoria.
|
|
||||||
- `verifyHashesAgainstDat(datDb: DatDatabase, hashes): {gameName, romName, matchedOn} | null` — verifica hashes contra el DAT y devuelve la coincidencia.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/services/datVerifier.spec.ts` — cubre parsing, match por CRC/MD5/SHA1/size y ausencia de match.
|
|
||||||
- `backend/tests/fixtures/sample.dat.xml` — fixture usada por las pruebas.
|
|
||||||
|
|
||||||
**Review Status:** APPROVED with minor recommendations
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: add datVerifier and tests
|
|
||||||
|
|
||||||
- Añade `datVerifier` con `parseDat` y `verifyHashesAgainstDat`
|
|
||||||
- Añade tests y fixture XML para validar matching por CRC/MD5/SHA1/size
|
|
||||||
- Añade `fast-xml-parser` en `backend/package.json` (devDependency)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
## Phase 5 Complete: Job runner en memoria
|
|
||||||
|
|
||||||
TL;DR: Se implementó un runner en memoria (`ImportRunner`) con control de concurrencia configurable, API de encolado (`enqueue`), estado (`getStatus`) y utilidades de parada (`stop`, `stopAndWait`). Se añadieron tests TDD que cubren concurrencia, rechazo tras `stop` y contabilización de tareas completadas. La ruta de importación ahora encola jobs en background y registra errores.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/config.ts
|
|
||||||
- backend/src/jobs/importRunner.ts
|
|
||||||
- backend/src/routes/import.ts
|
|
||||||
- backend/tests/jobs/importRunner.spec.ts
|
|
||||||
- backend/tsconfig.json
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `ImportRunner` (class) — `enqueue`, `getStatus`, `start`, `stop`, `stopAndWait`.
|
|
||||||
- `runner` (singleton) — instanciado y arrancado por defecto.
|
|
||||||
- `IMPORT_CONCURRENCY` (export) in `config.ts`.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/jobs/importRunner.spec.ts` — 5–6 tests (enqueue result, concurrencia, getStatus, rechazo tras stop, completed incrementa en rechazo).
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: import job runner in-memory
|
|
||||||
|
|
||||||
- Añade `ImportRunner` en memoria con concurrencia configurable
|
|
||||||
- Tests TDD para enqueue, concurrencia y comportamiento tras `stop`
|
|
||||||
- Actualiza `/api/import/scan` para encolar jobs y registrar errores
|
|
||||||
- Ajusta `tsconfig.json` para incluir `tests` en comprobaciones de tipo
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
## Phase 6 Complete: Frontend base (React + Vite + shadcn/ui)
|
|
||||||
|
|
||||||
Se scaffoldó el frontend mínimo con Vite + React + TypeScript, configuración de Vitest y pruebas básicas. Los tests unitarios escritos pasan correctamente y el proyecto contiene los componentes y rutas base necesarios para continuar con la Fase 7.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- frontend/package.json
|
|
||||||
- frontend/tsconfig.json
|
|
||||||
- frontend/vite.config.ts
|
|
||||||
- frontend/vitest.config.ts
|
|
||||||
- frontend/index.html
|
|
||||||
- frontend/postcss.config.cjs
|
|
||||||
- frontend/tailwind.config.cjs
|
|
||||||
- frontend/src/main.tsx
|
|
||||||
- frontend/src/App.tsx
|
|
||||||
- frontend/src/components/layout/Navbar.tsx
|
|
||||||
- frontend/src/components/layout/Sidebar.tsx
|
|
||||||
- frontend/src/routes/index.tsx
|
|
||||||
- frontend/src/routes/roms.tsx
|
|
||||||
- frontend/src/routes/games.tsx
|
|
||||||
- frontend/src/lib/queryClient.ts
|
|
||||||
- frontend/src/lib/api.ts
|
|
||||||
- frontend/src/hooks/useGames.ts
|
|
||||||
- frontend/src/styles.css
|
|
||||||
- frontend/src/setupTests.ts
|
|
||||||
- frontend/tests/App.spec.tsx
|
|
||||||
- frontend/tests/components/Navbar.spec.tsx
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `App` component (frontend/src/App.tsx)
|
|
||||||
- `Navbar` component (frontend/src/components/layout/Navbar.tsx)
|
|
||||||
- `Sidebar` placeholder (frontend/src/components/layout/Sidebar.tsx)
|
|
||||||
- `queryClient` export (frontend/src/lib/queryClient.ts)
|
|
||||||
- `useGames` hook (stub) (frontend/src/hooks/useGames.ts)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- frontend/tests/App.spec.tsx
|
|
||||||
- frontend/tests/components/Navbar.spec.tsx
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: scaffold frontend base (Vite + React + Vitest)
|
|
||||||
|
|
||||||
- Añade scaffold de frontend con Vite y React
|
|
||||||
- Configura Vitest y tests básicos (App, Navbar)
|
|
||||||
- Añade QueryClient y hooks/plantillas iniciales
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
## Phase 7 Complete: Gestión manual de juegos (frontend + backend)
|
|
||||||
|
|
||||||
Se implementó el CRUD completo para juegos: endpoints REST en backend (GET/POST/PUT/DELETE /api/games), validación con Zod, y frontend con formulario reactivo, tabla de juegos, y custom hooks con TanStack Query. Todos los tests unitarios y de integración pasan exitosamente.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- backend/src/routes/games.ts
|
|
||||||
- backend/src/controllers/gamesController.ts
|
|
||||||
- backend/src/validators/gameValidator.ts
|
|
||||||
- backend/tests/routes/games.spec.ts
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- frontend/src/routes/games.tsx
|
|
||||||
- frontend/src/components/games/GameForm.tsx
|
|
||||||
- frontend/src/components/games/GameCard.tsx
|
|
||||||
- frontend/src/hooks/useGames.ts
|
|
||||||
- frontend/tests/routes/games.spec.tsx
|
|
||||||
- frontend/tests/components/GameForm.spec.tsx
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- `GamesController.listGames()` - Obtiene todos los juegos
|
|
||||||
- `GamesController.createGame()` - Crea un nuevo juego con validación
|
|
||||||
- `GamesController.updateGame()` - Actualiza un juego existente
|
|
||||||
- `GamesController.deleteGame()` - Elimina un juego
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- `GameForm` component - Formulario para crear/editar juegos con validación Zod
|
|
||||||
- `GameCard` component - Card para mostrar detalles de un juego
|
|
||||||
- `useGames()` hook - Obtiene lista de juegos (TanStack Query)
|
|
||||||
- `useCreateGame()` hook - Crear nuevo juego (TanStack Query mutation)
|
|
||||||
- `useUpdateGame()` hook - Actualizar juego (TanStack Query mutation)
|
|
||||||
- `useDeleteGame()` hook - Eliminar juego (TanStack Query mutation)
|
|
||||||
- Games page component - Tabla de juegos con acciones (crear, editar, eliminar)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- tests/routes/games.spec.ts - 11 tests (CRUD endpoints)
|
|
||||||
- GET /api/games: list empty, list with games
|
|
||||||
- POST /api/games: create valid, missing required, empty title, required fields only
|
|
||||||
- PUT /api/games/:id: update existing, 404 not found, partial update
|
|
||||||
- DELETE /api/games/:id: delete existing, 404 not found
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- tests/routes/games.spec.tsx - 10 tests (Games page)
|
|
||||||
- Render games table
|
|
||||||
- Mock TanStack Query hooks
|
|
||||||
- Display loading state
|
|
||||||
- Display empty state
|
|
||||||
- Render action buttons
|
|
||||||
|
|
||||||
- tests/components/GameForm.spec.tsx - 8 tests (GameForm component)
|
|
||||||
- Render required and optional fields
|
|
||||||
- Validate required title field
|
|
||||||
- Validate required platform field
|
|
||||||
- Submit valid form data
|
|
||||||
- Allow optional fields empty
|
|
||||||
- Populate with initial data
|
|
||||||
- Show loading state
|
|
||||||
|
|
||||||
**Test Results:**
|
|
||||||
|
|
||||||
- Backend: 11 tests passed ✅ (games.spec.ts)
|
|
||||||
- Backend total: 46 passed, 1 skipped ✅
|
|
||||||
- Frontend: 22 tests passed ✅ (4 test files)
|
|
||||||
- GameForm: 8 passed
|
|
||||||
- Games page: 10 passed
|
|
||||||
- App: 2 passed
|
|
||||||
- Navbar: 2 passed
|
|
||||||
- Lint: 0 errors, 12 warnings ✅
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Key Features Implemented:**
|
|
||||||
|
|
||||||
1. **Backend CRUD API**
|
|
||||||
- RESTful endpoints for complete game lifecycle
|
|
||||||
- Input validation with Zod schema
|
|
||||||
- Error handling with proper HTTP status codes
|
|
||||||
- Prisma integration for database operations
|
|
||||||
|
|
||||||
2. **Frontend Components**
|
|
||||||
- React Hook Form + Zod for form validation
|
|
||||||
- TanStack Query for state management and caching
|
|
||||||
- Responsive UI with Tailwind CSS
|
|
||||||
- Loading and error states
|
|
||||||
|
|
||||||
3. **Type Safety**
|
|
||||||
- TypeScript throughout
|
|
||||||
- Zod schemas for runtime validation
|
|
||||||
- Proper type inference in React components
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: implement games CRUD (Phase 7)
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- Add REST endpoints: GET, POST, PUT, DELETE /api/games
|
|
||||||
- Implement GamesController with CRUD logic
|
|
||||||
- Add Zod validator for game input validation
|
|
||||||
- Add 11 comprehensive tests for all endpoints
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- Create GameForm component with React Hook Form + Zod
|
|
||||||
- Create GameCard component for game display
|
|
||||||
- Implement useGames, useCreateGame, useUpdateGame, useDeleteGame hooks
|
|
||||||
- Add Games page with table and action buttons
|
|
||||||
- Add 18 component and page tests with 100% pass rate
|
|
||||||
|
|
||||||
All tests passing: 46 backend + 22 frontend tests
|
|
||||||
```
|
|
||||||
@@ -1,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)
|
|
||||||
@@ -2,21 +2,27 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: 'tests',
|
testDir: 'tests',
|
||||||
|
testMatch: '**/*.spec.ts',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
},
|
},
|
||||||
fullyParallel: true,
|
fullyParallel: false, // Set to false for E2E to avoid race conditions
|
||||||
reporter: 'list',
|
reporter: [['list'], ['html']],
|
||||||
use: {
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173', // Frontend URL
|
||||||
headless: true,
|
headless: true,
|
||||||
viewport: { width: 1280, height: 720 },
|
viewport: { width: 1280, height: 720 },
|
||||||
actionTimeout: 0,
|
actionTimeout: 10_000,
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Global timeout
|
||||||
|
globalTimeout: 60 * 60 * 1000, // 1 hour
|
||||||
});
|
});
|
||||||
|
|||||||
70
tests/documentation.spec.ts
Normal file
70
tests/documentation.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
describe('Documentation - Security and API Keys', () => {
|
||||||
|
// SECURITY.md tests
|
||||||
|
it('SECURITY.md exists and contains "Reporting Security Vulnerabilities"', () => {
|
||||||
|
expect(existsSync('./SECURITY.md')).toBe(true);
|
||||||
|
const content = readFileSync('./SECURITY.md', 'utf-8');
|
||||||
|
expect(content).toContain('Reporting Security Vulnerabilities');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SECURITY.md contains "Environment Variables & Secrets" section', () => {
|
||||||
|
const content = readFileSync('./SECURITY.md', 'utf-8');
|
||||||
|
expect(content).toContain('Environment Variables & Secrets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SECURITY.md contains "Input Validation & Sanitization" section', () => {
|
||||||
|
const content = readFileSync('./SECURITY.md', 'utf-8');
|
||||||
|
expect(content).toContain('Input Validation & Sanitization');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SECURITY.md contains "Rate Limiting" section', () => {
|
||||||
|
const content = readFileSync('./SECURITY.md', 'utf-8');
|
||||||
|
expect(content).toContain('Rate Limiting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SECURITY.md contains "Database Security" section', () => {
|
||||||
|
const content = readFileSync('./SECURITY.md', 'utf-8');
|
||||||
|
expect(content).toContain('Database Security');
|
||||||
|
});
|
||||||
|
|
||||||
|
// docs/API_KEYS.md tests
|
||||||
|
it('docs/API_KEYS.md exists and contains "IGDB" section', () => {
|
||||||
|
expect(existsSync('./docs/API_KEYS.md')).toBe(true);
|
||||||
|
const content = readFileSync('./docs/API_KEYS.md', 'utf-8');
|
||||||
|
expect(content).toContain('IGDB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('docs/API_KEYS.md contains "RAWG" section', () => {
|
||||||
|
const content = readFileSync('./docs/API_KEYS.md', 'utf-8');
|
||||||
|
expect(content).toContain('RAWG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('docs/API_KEYS.md contains "TheGamesDB" section', () => {
|
||||||
|
const content = readFileSync('./docs/API_KEYS.md', 'utf-8');
|
||||||
|
expect(content).toContain('TheGamesDB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('docs/API_KEYS.md contains step-by-step instructions', () => {
|
||||||
|
const content = readFileSync('./docs/API_KEYS.md', 'utf-8');
|
||||||
|
expect(content).toMatch(/steps?|step-by-step|guide/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// README.md tests
|
||||||
|
it('README.md contains link to SECURITY.md', () => {
|
||||||
|
const content = readFileSync('./README.md', 'utf-8');
|
||||||
|
expect(content).toMatch(/SECURITY\.md|security/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('README.md contains link to docs/API_KEYS.md', () => {
|
||||||
|
const content = readFileSync('./README.md', 'utf-8');
|
||||||
|
expect(content).toMatch(/API_KEYS\.md|api.*key|obtaining.*key/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('README.md mentions .env.example template', () => {
|
||||||
|
const content = readFileSync('./README.md', 'utf-8');
|
||||||
|
expect(content).toMatch(/\.env|environment.*variable/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
194
tests/e2e/full-flow.spec.ts
Normal file
194
tests/e2e/full-flow.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Quasar E2E Tests - Full User Journey', () => {
|
||||||
|
// All tests assume backend runs on http://localhost:3000
|
||||||
|
// and frontend runs on http://localhost:5173 (with proxy to /api)
|
||||||
|
|
||||||
|
test('E2E: Navigate to home page and verify layout', async ({ page }) => {
|
||||||
|
// Navigate to home page
|
||||||
|
await page.goto('http://localhost:5173/');
|
||||||
|
|
||||||
|
// Verify page loads without errors
|
||||||
|
await expect(page).toHaveTitle(/Quasar|Games/i);
|
||||||
|
|
||||||
|
// Verify navigation links exist
|
||||||
|
const gamesLink = page.locator('a:has-text("Games")').first();
|
||||||
|
const romsLink = page.locator('a:has-text("ROMs")').first();
|
||||||
|
|
||||||
|
await expect(gamesLink).toBeVisible();
|
||||||
|
await expect(romsLink).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E2E: Create a game manually via form', async ({ page }) => {
|
||||||
|
// Navigate to games page
|
||||||
|
await page.goto('http://localhost:5173/games');
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Click "Add Game" button
|
||||||
|
const addGameBtn = page.locator('button:has-text("Add Game")');
|
||||||
|
await expect(addGameBtn).toBeVisible();
|
||||||
|
await addGameBtn.click();
|
||||||
|
|
||||||
|
// Wait for form to appear
|
||||||
|
const titleInput = page.locator('#title');
|
||||||
|
await expect(titleInput).toBeVisible();
|
||||||
|
|
||||||
|
// Fill form: title and platform
|
||||||
|
await titleInput.fill('The Legend of Zelda');
|
||||||
|
|
||||||
|
const platformInput = page.locator('#platformId');
|
||||||
|
await platformInput.fill('Nintendo 64');
|
||||||
|
|
||||||
|
// Submit form - look for button with Submit/Save/Create text
|
||||||
|
const submitBtn = page.locator('button[type="submit"]').first();
|
||||||
|
await submitBtn.click();
|
||||||
|
|
||||||
|
// Wait for API response and refresh
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify game appears in table
|
||||||
|
const gameInTable = page.locator('text=The Legend of Zelda');
|
||||||
|
await expect(gameInTable).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E2E: Search metadata for a game', async ({ page }) => {
|
||||||
|
// Navigate to ROMs page (metadata search trigger)
|
||||||
|
await page.goto('http://localhost:5173/roms');
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Click "Scan Directory" or find metadata search button
|
||||||
|
const scanBtn = page.locator('button:has-text("Scan Directory")');
|
||||||
|
if (await scanBtn.isVisible()) {
|
||||||
|
// If there's a scan button, we'd click it, but for this test
|
||||||
|
// we'll focus on metadata search dialog
|
||||||
|
// In real scenario, would fill scan path and trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: Create a game first, then search metadata for it
|
||||||
|
// For now, we'll just verify the metadata search dialog can be triggered
|
||||||
|
const linkMetadataBtn = page.locator('button:has-text("Link Metadata")').first();
|
||||||
|
|
||||||
|
// Skip if no ROMs yet
|
||||||
|
if (!(await linkMetadataBtn.isVisible())) {
|
||||||
|
// Create a game first so we have something to link
|
||||||
|
await page.goto('http://localhost:5173/games');
|
||||||
|
await page.locator('button:has-text("Add Game")').click();
|
||||||
|
await page.locator('#title').fill('Super Mario');
|
||||||
|
await page.locator('#platformId').fill('Nintendo');
|
||||||
|
await page.locator('button[type="submit"]').first().click();
|
||||||
|
|
||||||
|
// Go back to ROMs
|
||||||
|
await page.goto('http://localhost:5173/roms');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metadata search dialog can be opened
|
||||||
|
const linkBtns = page.locator('button:has-text("Link Metadata")');
|
||||||
|
if ((await linkBtns.count()) > 0) {
|
||||||
|
await linkBtns.first().click();
|
||||||
|
|
||||||
|
// Wait for metadata dialog to appear
|
||||||
|
const dialog = page.locator('[role="dialog"]').or(page.locator('.modal')).first();
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E2E: Link ROM to game', async ({ page }) => {
|
||||||
|
// This test requires:
|
||||||
|
// 1. At least one game created
|
||||||
|
// 2. At least one ROM in the system
|
||||||
|
|
||||||
|
// First, create a game
|
||||||
|
await page.goto('http://localhost:5173/games');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const addGameBtn = page.locator('button:has-text("Add Game")');
|
||||||
|
if (await addGameBtn.isVisible()) {
|
||||||
|
await addGameBtn.click();
|
||||||
|
await page.locator('#title').fill('Zelda');
|
||||||
|
await page.locator('#platformId').fill('Nintendo');
|
||||||
|
await page.locator('button[type="submit"]').first().click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now go to ROMs and try to link
|
||||||
|
await page.goto('http://localhost:5173/roms');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const linkBtns = page.locator('button:has-text("Link Metadata")');
|
||||||
|
const linkCount = await linkBtns.count();
|
||||||
|
|
||||||
|
if (linkCount > 0) {
|
||||||
|
// Click first "Link Metadata" button
|
||||||
|
await linkBtns.first().click();
|
||||||
|
|
||||||
|
// Wait for dialog with game selection
|
||||||
|
const dialog = page.locator('[role="dialog"], .modal').first();
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Try to select "Zelda" game from results
|
||||||
|
const zelda = page.locator('text=Zelda').nth(1); // nth(1) to skip the header
|
||||||
|
if (await zelda.isVisible()) {
|
||||||
|
await zelda.click();
|
||||||
|
|
||||||
|
// Wait for link to complete
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Verify ROM now shows "Zelda" in Game column
|
||||||
|
const gameCell = page.locator('td:has-text("Zelda")');
|
||||||
|
await expect(gameCell).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E2E: Full user journey - create, search, link, view', async ({ page }) => {
|
||||||
|
// Step 1: Create game "Hades"
|
||||||
|
await page.goto('http://localhost:5173/games');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.locator('button:has-text("Add Game")').click();
|
||||||
|
await page.locator('#title').fill('Hades');
|
||||||
|
await page.locator('#platformId').fill('Nintendo Switch');
|
||||||
|
await page.locator('button[type="submit"]').first().click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify game appears in games list
|
||||||
|
let hadesInGames = page.locator('text=Hades').first();
|
||||||
|
await expect(hadesInGames).toBeVisible();
|
||||||
|
|
||||||
|
// Step 2: Navigate to ROMs and verify we can search metadata
|
||||||
|
await page.goto('http://localhost:5173/roms');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// If there are ROMs, try to link one to Hades
|
||||||
|
const linkBtns = page.locator('button:has-text("Link Metadata")');
|
||||||
|
if ((await linkBtns.count()) > 0) {
|
||||||
|
// Open metadata search
|
||||||
|
await linkBtns.first().click();
|
||||||
|
|
||||||
|
// Wait for dialog
|
||||||
|
const dialog = page.locator('[role="dialog"], .modal').first();
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Try to find and select Hades
|
||||||
|
const hadesOption = page.locator('text=Hades');
|
||||||
|
const count = await hadesOption.count();
|
||||||
|
if (count > 1) {
|
||||||
|
// Select second occurrence (avoiding button text, if any)
|
||||||
|
await hadesOption.nth(1).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Verify in games view
|
||||||
|
await page.goto('http://localhost:5173/games');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
hadesInGames = page.locator('text=Hades');
|
||||||
|
await expect(hadesInGames).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
89
tests/env-example.spec.ts
Normal file
89
tests/env-example.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
describe('Environment Example Files', () => {
|
||||||
|
describe('Root .env.example', () => {
|
||||||
|
const rootEnvPath = resolve(__dirname, '..', '.env.example');
|
||||||
|
|
||||||
|
it('should exist and contain DATABASE_URL', () => {
|
||||||
|
expect(existsSync(rootEnvPath)).toBe(true);
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('DATABASE_URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain IGDB_CLIENT_ID and IGDB_CLIENT_SECRET', () => {
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('IGDB_CLIENT_ID');
|
||||||
|
expect(content).toContain('IGDB_CLIENT_SECRET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain RAWG_API_KEY', () => {
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('RAWG_API_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain THEGAMESDB_API_KEY', () => {
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('THEGAMESDB_API_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain NODE_ENV', () => {
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('NODE_ENV');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain PORT and LOG_LEVEL', () => {
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('PORT');
|
||||||
|
expect(content).toContain('LOG_LEVEL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backend .env.example', () => {
|
||||||
|
const backendEnvPath = resolve(__dirname, '..', 'backend', '.env.example');
|
||||||
|
|
||||||
|
it('should exist', () => {
|
||||||
|
expect(existsSync(backendEnvPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain DATABASE_URL', () => {
|
||||||
|
const content = readFileSync(backendEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('DATABASE_URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain all API keys', () => {
|
||||||
|
const content = readFileSync(backendEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('IGDB_CLIENT_ID');
|
||||||
|
expect(content).toContain('IGDB_CLIENT_SECRET');
|
||||||
|
expect(content).toContain('RAWG_API_KEY');
|
||||||
|
expect(content).toContain('THEGAMESDB_API_KEY');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Frontend .env.example', () => {
|
||||||
|
const frontendEnvPath = resolve(__dirname, '..', 'frontend', '.env.example');
|
||||||
|
|
||||||
|
it('should exist', () => {
|
||||||
|
expect(existsSync(frontendEnvPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain VITE_API_URL', () => {
|
||||||
|
const content = readFileSync(frontendEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('VITE_API_URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain VITE_APP_NAME', () => {
|
||||||
|
const content = readFileSync(frontendEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('VITE_APP_NAME');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Git Ignore Configuration', () => {
|
||||||
|
it('should ignore .env files', () => {
|
||||||
|
const gitignorePath = resolve(__dirname, '..', '.gitignore');
|
||||||
|
const content = readFileSync(gitignorePath, 'utf-8');
|
||||||
|
expect(content).toContain('.env');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
126
tests/gitea-workflow.spec.ts
Normal file
126
tests/gitea-workflow.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
|
|
||||||
|
describe('Gitea Workflow CI - Phase 9.4', () => {
|
||||||
|
const workflowPath = resolve(process.cwd(), '.gitea/workflows/ci.yml');
|
||||||
|
const securityPath = resolve(process.cwd(), 'SECURITY.md');
|
||||||
|
const apiKeysPath = resolve(process.cwd(), 'docs/API_KEYS.md');
|
||||||
|
|
||||||
|
let workflowContent: string;
|
||||||
|
let workflowYaml: any;
|
||||||
|
let securityContent: string;
|
||||||
|
let apiKeysContent: string;
|
||||||
|
|
||||||
|
// Load files once
|
||||||
|
if (existsSync(workflowPath)) {
|
||||||
|
workflowContent = readFileSync(workflowPath, 'utf-8');
|
||||||
|
workflowYaml = parseYaml(workflowContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(securityPath)) {
|
||||||
|
securityContent = readFileSync(securityPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(apiKeysPath)) {
|
||||||
|
apiKeysContent = readFileSync(apiKeysPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 1: Workflow file exists
|
||||||
|
it('should have .gitea/workflows/ci.yml file', () => {
|
||||||
|
expect(existsSync(workflowPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Contains lint job
|
||||||
|
it('should contain job: lint', () => {
|
||||||
|
expect(workflowYaml?.jobs?.lint).toBeDefined();
|
||||||
|
expect(workflowYaml.jobs.lint.steps).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Contains test-backend job
|
||||||
|
it('should contain job: test-backend', () => {
|
||||||
|
expect(workflowYaml?.jobs?.['test-backend']).toBeDefined();
|
||||||
|
expect(workflowYaml.jobs['test-backend'].steps).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Contains test-frontend job
|
||||||
|
it('should contain job: test-frontend', () => {
|
||||||
|
expect(workflowYaml?.jobs?.['test-frontend']).toBeDefined();
|
||||||
|
expect(workflowYaml.jobs['test-frontend'].steps).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: Contains test-e2e job
|
||||||
|
it('should contain job: test-e2e', () => {
|
||||||
|
expect(workflowYaml?.jobs?.['test-e2e']).toBeDefined();
|
||||||
|
expect(workflowYaml.jobs['test-e2e'].steps).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: E2E job depends on backend and frontend tests
|
||||||
|
it('test-e2e should have needs: [test-backend, test-frontend]', () => {
|
||||||
|
const needs = workflowYaml?.jobs?.['test-e2e']?.needs;
|
||||||
|
expect(Array.isArray(needs) || typeof needs === 'string').toBe(true);
|
||||||
|
|
||||||
|
const needsArray = Array.isArray(needs) ? needs : [needs];
|
||||||
|
expect(needsArray).toContain('test-backend');
|
||||||
|
expect(needsArray).toContain('test-frontend');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 7: E2E uses Gitea Secrets for API keys
|
||||||
|
it('test-e2e should use Gitea Secrets for API keys', () => {
|
||||||
|
const env = workflowYaml?.jobs?.['test-e2e']?.env;
|
||||||
|
expect(env).toBeDefined();
|
||||||
|
|
||||||
|
// Check for secret references
|
||||||
|
const envStr = JSON.stringify(env);
|
||||||
|
expect(envStr).toContain('secrets.IGDB_CLIENT_ID');
|
||||||
|
expect(envStr).toContain('secrets.IGDB_CLIENT_SECRET');
|
||||||
|
expect(envStr).toContain('secrets.RAWG_API_KEY');
|
||||||
|
expect(envStr).toContain('secrets.THEGAMESDB_API_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 8: E2E includes yarn test:install step
|
||||||
|
it('test-e2e should include yarn test:install step', () => {
|
||||||
|
const steps = workflowYaml?.jobs?.['test-e2e']?.steps || [];
|
||||||
|
const testInstallStep = steps.some(
|
||||||
|
(step: any) =>
|
||||||
|
step.run && typeof step.run === 'string' && step.run.includes('yarn test:install')
|
||||||
|
);
|
||||||
|
expect(testInstallStep).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 9: Has triggers on push and pull_request
|
||||||
|
it('should have triggers on push and pull_request', () => {
|
||||||
|
const on = workflowYaml?.on;
|
||||||
|
expect(on).toBeDefined();
|
||||||
|
expect(on?.push || on?.['push']).toBeDefined();
|
||||||
|
expect(on?.pull_request || on?.['pull_request']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 10: Installs Node.js and caches yarn (lint job)
|
||||||
|
it('should install Node.js and cache yarn in lint job', () => {
|
||||||
|
const steps = workflowYaml?.jobs?.lint?.steps || [];
|
||||||
|
const hasNodeSetup = steps.some((step: any) => step.uses && step.uses.includes('setup-node'));
|
||||||
|
const hasYarnCache = steps.some(
|
||||||
|
(step: any) => step.uses && step.uses.includes('setup-node') && step.with?.cache === 'yarn'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hasNodeSetup).toBe(true);
|
||||||
|
expect(hasYarnCache).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 11: SECURITY.md mentions Gitea Secrets setup
|
||||||
|
it('SECURITY.md should mention Gitea Secrets setup', () => {
|
||||||
|
expect(securityContent).toBeDefined();
|
||||||
|
expect(securityContent.toLowerCase()).toContain('gitea');
|
||||||
|
expect(securityContent.toLowerCase()).toContain('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 12: SECURITY.md has instructions for CI/CD secrets
|
||||||
|
it('SECURITY.md should have CI/CD secrets instructions', () => {
|
||||||
|
expect(securityContent).toBeDefined();
|
||||||
|
expect(securityContent.toLowerCase()).toContain('ci/cd');
|
||||||
|
expect(securityContent).toContain('IGDB_CLIENT_ID');
|
||||||
|
expect(securityContent).toContain('RAWG_API_KEY');
|
||||||
|
});
|
||||||
|
});
|
||||||
78
tests/readme-validation.spec.ts
Normal file
78
tests/readme-validation.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
describe('README Validation - Phase 9.5', () => {
|
||||||
|
it('Test 1: README.md exists in root', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
expect(fs.existsSync(readmePath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2: README.md contains # Quasar heading', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/^# Quasar/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3: README.md contains Features section', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/## Features/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4: README.md contains Quick Start section', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/## Quick Start/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5: README.md contains Installation subsection', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/### Installation/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 6: README.md contains Testing section', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/## Testing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 7: README.md contains link to SECURITY.md', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/\[SECURITY\.md\]\(SECURITY\.md\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 8: README.md contains link to docs/API_KEYS.md', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/\[docs\/API_KEYS\.md\]\(docs\/API_KEYS\.md\)/) ||
|
||||||
|
expect(content).toMatch(/docs\/API_KEYS\.md/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 9: README.md contains Project Structure section', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/## Project Structure/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 10: README.md contains folder tree with backend/frontend', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/backend/);
|
||||||
|
expect(content).toMatch(/frontend/);
|
||||||
|
expect(content).toMatch(/tests/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 11: frontend/README.md exists', () => {
|
||||||
|
const frontendReadmePath = path.join(__dirname, '..', 'frontend', 'README.md');
|
||||||
|
expect(fs.existsSync(frontendReadmePath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 12: frontend/README.md contains Setup instructions', () => {
|
||||||
|
const frontendReadmePath = path.join(__dirname, '..', 'frontend', 'README.md');
|
||||||
|
const content = fs.readFileSync(frontendReadmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/## Setup/) || expect(content).toMatch(/setup/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,9 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: './src/setupTests.ts',
|
environment: 'node',
|
||||||
include: ['tests/**/*.spec.tsx'],
|
testDir: 'tests',
|
||||||
|
include: ['**/*.spec.ts'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user