Compare commits
4 Commits
ce54db38d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b92cc19137 | |||
| 9ed4437906 | |||
| 0c9c408564 | |||
| c27e9bec7a |
409
README.md
409
README.md
@@ -1,80 +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.
|
|
||||||
|
|
||||||
## Seguridad
|
### Prerequisites
|
||||||
|
|
||||||
Para información sobre políticas de seguridad, vulnerabilidades y prácticas recomendadas, consulta [SECURITY.md](SECURITY.md).
|
- **Node.js 18+**
|
||||||
|
- **Yarn 4.x** (package manager)
|
||||||
|
|
||||||
## Configuración de APIs
|
### Installation
|
||||||
|
|
||||||
Quasar se integra con múltiples servicios de metadatos: IGDB, RAWG y TheGamesDB. Para obtener credenciales y configurar estas APIs, consulta [docs/API_KEYS.md](docs/API_KEYS.md).
|
|
||||||
|
|
||||||
### Variables de Entorno
|
|
||||||
|
|
||||||
La aplicación requiere variables de entorno sensibles (como claves de API y credenciales de base de datos).
|
|
||||||
Usa `.env.local` o `.env.{NODE_ENV}.local` para desarrollo. **Nunca** hagas commit de archivos `.env` al repositorio:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. Clone repository
|
||||||
|
git clone https://your-gitea-instance/your-org/quasar.git
|
||||||
|
cd quasar
|
||||||
|
|
||||||
|
# 2. Install dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# 3. Setup environment
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edita .env.local con tus credenciales
|
|
||||||
|
# 4. Get API keys (optional, but recommended)
|
||||||
|
# See: [docs/02-tecnico/apis.md](docs/02-tecnico/apis.md)
|
||||||
|
|
||||||
|
# 5. Run migrations
|
||||||
|
cd backend
|
||||||
|
npm run prisma:migrate
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Otros proyectos relacionados, para coger ideas y funcionalidades
|
## Usage
|
||||||
|
|
||||||
| Herramienta | Categoría | Descripción | Features Destacadas | Ideal Para | Enlace Oficial |
|
1. **Create Platform** — Go to /games, add Nintendo, PlayStation, etc.
|
||||||
| ------------------------ | --------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
2. **Create Game** — Add game manually or import from ROMs
|
||||||
| 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) |
|
3. **Scan ROMs** — Go to /roms, scan directory with ROM files
|
||||||
| 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) |
|
4. **Link Metadata** — Search game on IGDB/RAWG, link to ROM
|
||||||
| 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) |
|
5. **View Library** — See all games with artwork and info
|
||||||
| 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
|
## Project Structure
|
||||||
|
|
||||||
Algunos tests de integración (p. ej. verificación de DATs, lectura de CHD/7z)
|
```
|
||||||
requieren herramientas nativas instaladas en el sistema donde se ejecuten
|
quasar/
|
||||||
los tests (local o CI). A continuación está la lista mínima y cómo instalarlas:
|
├── 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
|
||||||
|
```
|
||||||
|
|
||||||
- `7z` / `p7zip` — necesario para extraer/leer ZIP y 7z.
|
## Configuration
|
||||||
- Debian/Ubuntu: `sudo apt update && sudo apt install -y p7zip-full p7zip-rar`
|
|
||||||
- macOS (Homebrew): `brew install p7zip`
|
|
||||||
|
|
||||||
- `chdman` — herramienta de MAME para manejar archivos CHD (opcional,
|
### Environment Variables
|
||||||
requerida para tests que trabajen con imágenes CHD).
|
|
||||||
- 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:
|
Copy `.env.example` to `.env.local` (or `.env.development`) and fill in:
|
||||||
|
|
||||||
- En CI se intentará instalar estas herramientas cuando sea posible; si no
|
```env
|
||||||
están disponibles los tests de integración que dependan de ellas pueden
|
# Database (local SQLite)
|
||||||
configurarse para ejecutarse condicionalmente.
|
DATABASE_URL="file:./dev.db"
|
||||||
- La variable de entorno `INTEGRATION=1` controla si se ejecutan pruebas
|
|
||||||
más pesadas y dependientes de binarios.
|
# 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 ✅
|
||||||
|
|||||||
BIN
backend/prisma/test.db
Normal file
BIN
backend/prisma/test.db
Normal file
Binary file not shown.
@@ -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_
|
||||||
140
docs/API_KEYS.md
140
docs/API_KEYS.md
@@ -1,140 +0,0 @@
|
|||||||
# Obtaining API Keys
|
|
||||||
|
|
||||||
This guide explains how to get credentials for each metadata service.
|
|
||||||
|
|
||||||
## IGDB (Internet Game Database)
|
|
||||||
|
|
||||||
IGDB uses **OAuth 2.0 via Twitch**. Steps:
|
|
||||||
|
|
||||||
1. Go to [Twitch Developer Console](https://dev.twitch.tv/console/apps)
|
|
||||||
2. Sign in with your Twitch account (create one if needed)
|
|
||||||
3. Click "Create Application"
|
|
||||||
- Name: "Quasar" (or your app name)
|
|
||||||
- Category: Select relevant category
|
|
||||||
- Accept terms, click Create
|
|
||||||
4. You'll see:
|
|
||||||
- **Client ID** — Copy this
|
|
||||||
- Click "New Secret" to generate **Client Secret** — Copy this
|
|
||||||
5. Go to Settings → OAuth Redirect URLs
|
|
||||||
- Add: `http://localhost:3000/oauth/callback` (development)
|
|
||||||
- For production: `https://yourdomain.com/oauth/callback`
|
|
||||||
6. In your `.env` file:
|
|
||||||
```
|
|
||||||
IGDB_CLIENT_ID=your_client_id
|
|
||||||
IGDB_CLIENT_SECRET=your_client_secret
|
|
||||||
```
|
|
||||||
7. Start Quasar, it will use IGDB automatically
|
|
||||||
|
|
||||||
**Rate Limit:** 4 requests/second
|
|
||||||
|
|
||||||
## RAWG (Rawg.io)
|
|
||||||
|
|
||||||
RAWG has a simpler **API Key** approach:
|
|
||||||
|
|
||||||
1. Go to [RAWG Settings](https://rawg.io/settings/account)
|
|
||||||
2. Sign up if needed, then login
|
|
||||||
3. Find "API Key" section
|
|
||||||
4. Click "Create new key" (if needed) or copy existing key
|
|
||||||
5. In your `.env` file:
|
|
||||||
```
|
|
||||||
RAWG_API_KEY=your_api_key_here
|
|
||||||
```
|
|
||||||
6. Start Quasar
|
|
||||||
|
|
||||||
**Rate Limit:** 20 requests/second (free tier)
|
|
||||||
|
|
||||||
**Note:** RAWG requires attribution in UI (include "Powered by RAWG" somewhere visible)
|
|
||||||
|
|
||||||
## TheGamesDB (thegamesdb.net)
|
|
||||||
|
|
||||||
TheGamesDB uses a simple **API Key**:
|
|
||||||
|
|
||||||
1. Go to [TheGamesDB API](https://thegamesdb.net/api)
|
|
||||||
2. Find "API Key" section (free registration required)
|
|
||||||
3. Register or login
|
|
||||||
4. Copy your API key
|
|
||||||
5. In your `.env` file:
|
|
||||||
```
|
|
||||||
THEGAMESDB_API_KEY=your_api_key_here
|
|
||||||
```
|
|
||||||
6. Start Quasar
|
|
||||||
|
|
||||||
**Rate Limit:** 1 request/second (free tier)
|
|
||||||
|
|
||||||
## Testing Without Real Keys
|
|
||||||
|
|
||||||
For development/testing:
|
|
||||||
|
|
||||||
- Leave API keys as `your_*_here` in `.env.local`
|
|
||||||
- Quasar will gracefully degrade and show limited metadata
|
|
||||||
- Frontend will still work with manual game entry
|
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
For production:
|
|
||||||
|
|
||||||
1. Generate new keys on each service (don't reuse dev keys)
|
|
||||||
2. Store keys in **Gitea Secrets** (for automated CI/CD pipelines)
|
|
||||||
3. Or use environment variables on your hosting provider
|
|
||||||
4. Rotate keys every 3 months
|
|
||||||
5. Monitor rate limits in service dashboards
|
|
||||||
|
|
||||||
## Gitea Actions CI/CD Setup
|
|
||||||
|
|
||||||
To enable automated testing with API keys in Gitea Actions:
|
|
||||||
|
|
||||||
### 1. Store Secrets in Gitea
|
|
||||||
|
|
||||||
Navigate to your repository settings:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://your-gitea-instance/your-org/quasar/settings/secrets/actions
|
|
||||||
```
|
|
||||||
|
|
||||||
Add these secrets:
|
|
||||||
|
|
||||||
- `IGDB_CLIENT_ID` (from Twitch Developer Console)
|
|
||||||
- `IGDB_CLIENT_SECRET` (from Twitch Developer Console)
|
|
||||||
- `RAWG_API_KEY` (from RAWG settings)
|
|
||||||
- `THEGAMESDB_API_KEY` (from TheGamesDB API)
|
|
||||||
|
|
||||||
### 2. Workflow Configuration
|
|
||||||
|
|
||||||
The `.gitea/workflows/ci.yml` workflow automatically:
|
|
||||||
|
|
||||||
- ✅ Installs dependencies
|
|
||||||
- ✅ Runs linting checks
|
|
||||||
- ✅ Executes backend tests (Vitest)
|
|
||||||
- ✅ Executes frontend tests (Vitest)
|
|
||||||
- ✅ Starts backend + frontend servers
|
|
||||||
- ✅ Runs E2E tests (Playwright) with real metadata APIs
|
|
||||||
- ✅ Uploads test reports on failure
|
|
||||||
|
|
||||||
### 3. Testing Flow
|
|
||||||
|
|
||||||
1. **Push** code to `main` or `develop`
|
|
||||||
2. **Gitea Actions** picks up the `.gitea/workflows/ci.yml`
|
|
||||||
3. **Secrets are injected** as `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET`, `RAWG_API_KEY`, `THEGAMESDB_API_KEY`
|
|
||||||
4. **E2E tests** fetch real metadata from APIs (using injected secrets)
|
|
||||||
5. **Build fails** if any test fails (prevents broken code)
|
|
||||||
|
|
||||||
### 4. Local Development
|
|
||||||
|
|
||||||
For local testing, use `.env.local`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
IGDB_CLIENT_ID=your_local_id
|
|
||||||
IGDB_CLIENT_SECRET=your_local_secret
|
|
||||||
RAWG_API_KEY=your_local_key
|
|
||||||
THEGAMESDB_API_KEY=your_local_key
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** CI/CD uses Gitea Secrets (not `.env` files), so never commit real credentials.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**"IGDB_CLIENT_ID not found"** → Check `.env` file exists and has correct format
|
|
||||||
|
|
||||||
**"429 Too Many Requests"** → Rate limit exceeded, wait and retry
|
|
||||||
|
|
||||||
**"Invalid API Key"** → Copy key exactly (no spaces), verify it's active on service website
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useEnrichMetadata } from '../../hooks/useRoms';
|
|
||||||
import { EnrichedGame } from '../../types/rom';
|
|
||||||
|
|
||||||
interface MetadataSearchDialogProps {
|
|
||||||
romId: string;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSelect: (game: EnrichedGame) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceLabels: Record<string, string> = {
|
|
||||||
igdb: 'IGDB',
|
|
||||||
rawg: 'RAWG',
|
|
||||||
thegamesdb: 'TGDB',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MetadataSearchDialog({
|
|
||||||
romId,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
onSelect,
|
|
||||||
}: MetadataSearchDialogProps): JSX.Element | null {
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [results, setResults] = useState<EnrichedGame[]>([]);
|
|
||||||
const [searched, setSearched] = useState(false);
|
|
||||||
const enrichMutation = useEnrichMetadata();
|
|
||||||
|
|
||||||
const handleSearch = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSearched(false);
|
|
||||||
|
|
||||||
if (!query.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const searchResults = await enrichMutation.mutateAsync(query);
|
|
||||||
setResults(searchResults);
|
|
||||||
setSearched(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Search failed:', err);
|
|
||||||
setResults([]);
|
|
||||||
setSearched(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (game: EnrichedGame) => {
|
|
||||||
onSelect(game);
|
|
||||||
onOpenChange(false);
|
|
||||||
setQuery('');
|
|
||||||
setResults([]);
|
|
||||||
setSearched(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-auto">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-lg font-semibold">Search Metadata</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
aria-label="close"
|
|
||||||
className="text-gray-500 hover:text-gray-700 text-xl"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="mb-6">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search game title"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
disabled={enrichMutation.isPending}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={enrichMutation.isPending}
|
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
|
|
||||||
>
|
|
||||||
{enrichMutation.isPending ? 'Searching...' : 'Search'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{searched && results.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-gray-500">No results found for "{query}"</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{results.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{results.map((game, index) => (
|
|
||||||
<div
|
|
||||||
key={`${game.source}-${game.externalIds[game.source as keyof typeof game.externalIds]}`}
|
|
||||||
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition"
|
|
||||||
>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{game.coverUrl && (
|
|
||||||
<img
|
|
||||||
src={game.coverUrl}
|
|
||||||
alt={game.title}
|
|
||||||
className="w-16 h-24 object-cover rounded"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<h3 className="font-semibold text-lg">{game.title}</h3>
|
|
||||||
<span className="bg-gray-200 text-gray-800 text-xs px-2 py-1 rounded">
|
|
||||||
{sourceLabels[game.source]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{game.releaseDate && (
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Released: {new Date(game.releaseDate).getFullYear()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(game.genres || game.platforms) && (
|
|
||||||
<div className="text-sm text-gray-600 mt-1">
|
|
||||||
{game.genres && <p>Genres: {game.genres.join(', ')}</p>}
|
|
||||||
{game.platforms && <p>Platforms: {game.platforms.join(', ')}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{game.description && (
|
|
||||||
<p className="text-sm text-gray-700 mt-2 line-clamp-2">{game.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleSelect(game)}
|
|
||||||
className="bg-green-600 text-white px-3 py-2 rounded-md hover:bg-green-700 font-medium h-fit whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Select
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searched && results.length === 0 && (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="bg-gray-200 text-gray-800 px-4 py-2 rounded-md hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { RomFile } from '../../types/rom';
|
|
||||||
|
|
||||||
interface RomCardProps {
|
|
||||||
rom: RomFile;
|
|
||||||
onLinkMetadata?: (romId: string) => void;
|
|
||||||
onDelete?: (romId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RomCard({ rom, onLinkMetadata, onDelete }: RomCardProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div className="border border-gray-300 rounded-lg p-4 hover:shadow-md transition">
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<h3 className="font-semibold text-lg flex-1 break-all">{rom.filename}</h3>
|
|
||||||
<span
|
|
||||||
className={`text-xs px-2 py-1 rounded font-medium whitespace-nowrap ml-2 ${
|
|
||||||
rom.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{rom.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 text-sm text-gray-600 mb-3">
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">Size:</span> {formatBytes(rom.size)}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">Checksum:</span> {rom.checksum.substring(0, 8)}...
|
|
||||||
</p>
|
|
||||||
{rom.game && (
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">Game:</span> {rom.game.title}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{!rom.game && onLinkMetadata && (
|
|
||||||
<button
|
|
||||||
onClick={() => onLinkMetadata(rom.id)}
|
|
||||||
className="flex-1 bg-blue-600 text-white px-3 py-2 text-sm rounded-md hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Link Metadata
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onDelete && (
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(rom.id)}
|
|
||||||
className="flex-1 bg-red-600 text-white px-3 py-2 text-sm rounded-md hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useScanDirectory } from '../../hooks/useRoms';
|
|
||||||
|
|
||||||
interface ScanDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScanDialog({ isOpen, onOpenChange }: ScanDialogProps): JSX.Element | null {
|
|
||||||
const [path, setPath] = useState('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const scanMutation = useScanDirectory();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setSuccess(false);
|
|
||||||
|
|
||||||
if (!path.trim()) {
|
|
||||||
setError('Please enter a directory path');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scanMutation.mutateAsync(path);
|
|
||||||
setSuccess(true);
|
|
||||||
setPath('');
|
|
||||||
setTimeout(() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
setSuccess(false);
|
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to scan directory');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-md w-full">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-lg font-semibold">Scan ROMs Directory</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="text-gray-500 hover:text-gray-700 text-xl"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="path" className="block text-sm font-medium mb-1">
|
|
||||||
Directory Path
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="path"
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter ROM directory path"
|
|
||||||
value={path}
|
|
||||||
onChange={(e) => setPath(e.target.value)}
|
|
||||||
disabled={scanMutation.isPending}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
|
|
||||||
<strong>Error:</strong> {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm">
|
|
||||||
Scan completed!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={scanMutation.isPending}
|
|
||||||
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
|
|
||||||
>
|
|
||||||
{scanMutation.isPending ? 'Scanning...' : 'Scan Directory'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="flex-1 bg-gray-200 text-gray-800 px-4 py-2 rounded-md hover:bg-gray-300 font-medium"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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,53 +0,0 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { api } from '../lib/api';
|
|
||||||
import { RomFile, EnrichedGame } from '../types/rom';
|
|
||||||
|
|
||||||
const ROMS_QUERY_KEY = ['roms'];
|
|
||||||
const GAMES_QUERY_KEY = ['games'];
|
|
||||||
|
|
||||||
export function useRoms() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ROMS_QUERY_KEY,
|
|
||||||
queryFn: () => api.roms.list(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScanDirectory() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (dir: string) => api.import.scan(dir),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
// Invalidar cache de ROMs después de scan
|
|
||||||
queryClient.invalidateQueries({ queryKey: ROMS_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEnrichMetadata() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (query: string) => api.metadata.search(query),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLinkGameToRom() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ romId, gameId }: { romId: string; gameId: string }) =>
|
|
||||||
api.roms.linkGame(romId, gameId),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Invalidar ambos caches después de vincular
|
|
||||||
queryClient.invalidateQueries({ queryKey: ROMS_QUERY_KEY });
|
|
||||||
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteRom() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => api.roms.delete(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ROMS_QUERY_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
|
||||||
import { RomFile, EnrichedGame, ScanResult } from '../types/rom';
|
|
||||||
|
|
||||||
const API_BASE = '/api';
|
|
||||||
|
|
||||||
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options?.headers,
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
games: {
|
|
||||||
list: () => request<Game[]>('/games'),
|
|
||||||
create: (data: CreateGameInput) =>
|
|
||||||
request<Game>('/games', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
update: (id: string, data: UpdateGameInput) =>
|
|
||||||
request<Game>(`/games/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
delete: (id: string) =>
|
|
||||||
request<void>(`/games/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
roms: {
|
|
||||||
list: () => request<RomFile[]>('/roms'),
|
|
||||||
getById: (id: string) => request<RomFile>(`/roms/${id}`),
|
|
||||||
linkGame: (romId: string, gameId: string) =>
|
|
||||||
request<RomFile>(`/roms/${romId}/game`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ gameId }),
|
|
||||||
}),
|
|
||||||
delete: (id: string) => request<void>(`/roms/${id}`, { method: 'DELETE' }),
|
|
||||||
},
|
|
||||||
|
|
||||||
metadata: {
|
|
||||||
search: (query: string) =>
|
|
||||||
request<EnrichedGame[]>('/metadata/search?q=' + encodeURIComponent(query)),
|
|
||||||
},
|
|
||||||
|
|
||||||
import: {
|
|
||||||
scan: (dir: string) =>
|
|
||||||
request<ScanResult>('/import/scan', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ dir }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
export const queryClient = new QueryClient();
|
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { 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,202 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
useRoms,
|
|
||||||
useScanDirectory,
|
|
||||||
useEnrichMetadata,
|
|
||||||
useLinkGameToRom,
|
|
||||||
useDeleteRom,
|
|
||||||
} from '../hooks/useRoms';
|
|
||||||
import ScanDialog from '../components/roms/ScanDialog';
|
|
||||||
import MetadataSearchDialog from '../components/roms/MetadataSearchDialog';
|
|
||||||
import { EnrichedGame, RomFile } from '../types/rom';
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Roms(): JSX.Element {
|
|
||||||
const { data: roms, isLoading, error } = useRoms();
|
|
||||||
const scanMutation = useScanDirectory();
|
|
||||||
const enrichMutation = useEnrichMetadata();
|
|
||||||
const linkMutation = useLinkGameToRom();
|
|
||||||
const deleteMutation = useDeleteRom();
|
|
||||||
|
|
||||||
const [isScanDialogOpen, setIsScanDialogOpen] = useState(false);
|
|
||||||
const [isMetadataDialogOpen, setIsMetadataDialogOpen] = useState(false);
|
|
||||||
const [selectedRomId, setSelectedRomId] = useState<string | null>(null);
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleDeleteRom = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteMutation.mutateAsync(id);
|
|
||||||
setDeleteConfirm(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete ROM:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMetadataSelect = async (game: EnrichedGame) => {
|
|
||||||
if (!selectedRomId || !game.externalIds) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Find the first available external ID to link with
|
|
||||||
const firstId = Object.entries(game.externalIds).find(([, value]) => value)?.[1];
|
|
||||||
|
|
||||||
if (firstId) {
|
|
||||||
// This creates a new game and links it
|
|
||||||
// For now, we'll just close the dialog
|
|
||||||
// In a real implementation, the API would handle game creation
|
|
||||||
setIsMetadataDialogOpen(false);
|
|
||||||
setSelectedRomId(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to link metadata:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenMetadataDialog = (romId: string) => {
|
|
||||||
setSelectedRomId(romId);
|
|
||||||
setIsMetadataDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<h2 className="text-xl font-bold text-red-600">Error</h2>
|
|
||||||
<p className="text-red-700">
|
|
||||||
{error instanceof Error ? error.message : 'Failed to load ROMs'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-bold">ROMs</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsScanDialogOpen(true)}
|
|
||||||
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:bg-gray-400"
|
|
||||||
disabled={isLoading || scanMutation.isPending}
|
|
||||||
>
|
|
||||||
Scan Directory
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScanDialog isOpen={isScanDialogOpen} onOpenChange={setIsScanDialogOpen} />
|
|
||||||
|
|
||||||
{selectedRomId && (
|
|
||||||
<MetadataSearchDialog
|
|
||||||
romId={selectedRomId}
|
|
||||||
isOpen={isMetadataDialogOpen}
|
|
||||||
onOpenChange={setIsMetadataDialogOpen}
|
|
||||||
onSelect={handleMetadataSelect}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && !roms ? (
|
|
||||||
<p className="text-gray-600">Loading ROMs...</p>
|
|
||||||
) : !roms || roms.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
<p className="text-lg mb-4">No ROMs yet. Click 'Scan Directory' to get started.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse border border-gray-300">
|
|
||||||
<thead className="bg-gray-100">
|
|
||||||
<tr>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Filename</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Size</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Checksum</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Status</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-left">Game</th>
|
|
||||||
<th className="border border-gray-300 px-4 py-2 text-center">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{roms.map((rom) => (
|
|
||||||
<tr key={rom.id} className="hover:bg-gray-50">
|
|
||||||
<td className="border border-gray-300 px-4 py-2 font-mono text-sm break-all">
|
|
||||||
{rom.filename}
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2 text-sm">
|
|
||||||
{formatBytes(rom.size)}
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2 font-mono text-sm">
|
|
||||||
{rom.checksum.substring(0, 8)}...
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2 text-sm">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
rom.status === 'active'
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{rom.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2">
|
|
||||||
{rom.game ? (
|
|
||||||
<span className="text-sm font-medium">{rom.game.title}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-500">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-300 px-4 py-2 text-center">
|
|
||||||
{!rom.game && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleOpenMetadataDialog(rom.id)}
|
|
||||||
className="mr-2 rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:bg-gray-400"
|
|
||||||
disabled={
|
|
||||||
enrichMutation.isPending ||
|
|
||||||
linkMutation.isPending ||
|
|
||||||
deleteMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Link Metadata
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{deleteConfirm === rom.id ? (
|
|
||||||
<div className="inline-flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteRom(rom.id)}
|
|
||||||
className="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-700 disabled:bg-gray-400"
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(null)}
|
|
||||||
className="rounded bg-gray-600 px-2 py-1 text-xs text-white hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(rom.id)}
|
|
||||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700 disabled:bg-gray-400"
|
|
||||||
disabled={
|
|
||||||
enrichMutation.isPending ||
|
|
||||||
linkMutation.isPending ||
|
|
||||||
deleteMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import '@testing-library/jest-dom';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/* Minimal global styles */
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family:
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
'Helvetica Neue',
|
|
||||||
Arial;
|
|
||||||
}
|
|
||||||
@@ -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,52 +0,0 @@
|
|||||||
import { Game } from './game';
|
|
||||||
|
|
||||||
export interface RomFile {
|
|
||||||
id: string;
|
|
||||||
path: string;
|
|
||||||
filename: string;
|
|
||||||
checksum: string;
|
|
||||||
size: number;
|
|
||||||
format: string;
|
|
||||||
hashes?: {
|
|
||||||
crc32?: string;
|
|
||||||
md5?: string;
|
|
||||||
sha1?: string;
|
|
||||||
} | null;
|
|
||||||
gameId?: string | null;
|
|
||||||
game?: Game | null;
|
|
||||||
status: 'active' | 'missing';
|
|
||||||
addedAt: string;
|
|
||||||
lastSeenAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Artwork {
|
|
||||||
id: string;
|
|
||||||
gameId: string;
|
|
||||||
type: 'cover' | 'screenshot';
|
|
||||||
sourceUrl: string;
|
|
||||||
localPath?: string | null;
|
|
||||||
width?: number | null;
|
|
||||||
height?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnrichedGame {
|
|
||||||
source: 'igdb' | 'rawg' | 'thegamesdb';
|
|
||||||
externalIds: {
|
|
||||||
igdb?: number;
|
|
||||||
rawg?: number;
|
|
||||||
thegamesdb?: number;
|
|
||||||
};
|
|
||||||
title: string;
|
|
||||||
slug?: string;
|
|
||||||
releaseDate?: string;
|
|
||||||
genres?: string[];
|
|
||||||
platforms?: string[];
|
|
||||||
coverUrl?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScanResult {
|
|
||||||
processed: number;
|
|
||||||
createdCount: number;
|
|
||||||
upserted: number;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
module.exports = {
|
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
module.exports = {
|
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import App from '../src/App';
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('renderiza el título Quasar', () => {
|
|
||||||
render(<App />);
|
|
||||||
expect(screen.getByText('Quasar')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import App from '../src/App';
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('renders Quasar', () => {
|
|
||||||
render(<App />);
|
|
||||||
expect(screen.getByText(/Quasar/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import GameForm from '../../src/components/games/GameForm';
|
|
||||||
import { Game } from '../../src/types/game';
|
|
||||||
|
|
||||||
describe('GameForm Component', () => {
|
|
||||||
let mockOnSubmit: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockOnSubmit = vi.fn();
|
|
||||||
mockOnSubmit.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render form with required fields', () => {
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/platform/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render optional fields', () => {
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
// búsqueda de campos opcionales
|
|
||||||
expect(screen.getByLabelText(/price/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/notes/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate required title field', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/title.*required/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate required platform field', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const titleInput = screen.getByLabelText(/title/i);
|
|
||||||
await user.type(titleInput, 'My Game');
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Si platform es requerido, debe validarse
|
|
||||||
const platformError = screen.queryByText(/platform.*required/i);
|
|
||||||
if (platformError) {
|
|
||||||
expect(platformError).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should submit valid form data', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const titleInputs = screen.getAllByDisplayValue('');
|
|
||||||
const titleInput = titleInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'title'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const platformInputs = screen.getAllByDisplayValue('');
|
|
||||||
const platformInput = platformInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'platformId'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
await user.type(titleInput, 'Zelda Game');
|
|
||||||
await user.type(platformInput, 'Nintendo');
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
// Simple check: button should not be disabled or error should appear
|
|
||||||
expect(screen.queryByText(/required/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow optional fields to be empty', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
const titleInputs = screen.getAllByDisplayValue('');
|
|
||||||
const titleInput = titleInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'title'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const platformInputs = screen.getAllByDisplayValue('');
|
|
||||||
const platformInput = platformInputs.find(
|
|
||||||
(el) => (el as HTMLInputElement).id === 'platformId'
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
await user.type(titleInput, 'Game Title');
|
|
||||||
await user.type(platformInput, 'PS5');
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Save Game');
|
|
||||||
await user.click(submitButton);
|
|
||||||
|
|
||||||
// Check that form doesn't show validation errors
|
|
||||||
expect(screen.queryByText(/required/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should populate form with initial data when provided', async () => {
|
|
||||||
const initialGame: Partial<Game> = {
|
|
||||||
id: '1',
|
|
||||||
title: 'Existing Game',
|
|
||||||
slug: 'existing-game',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<GameForm initialData={initialGame as Game} onSubmit={mockOnSubmit} />);
|
|
||||||
|
|
||||||
expect(screen.getByDisplayValue('Existing Game')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading state', () => {
|
|
||||||
render(<GameForm onSubmit={mockOnSubmit} isLoading={true} />);
|
|
||||||
|
|
||||||
const submitButton = screen.getByText('Saving...');
|
|
||||||
expect(submitButton).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import MetadataSearchDialog from '../../src/components/roms/MetadataSearchDialog';
|
|
||||||
import { EnrichedGame } from '../../src/types/rom';
|
|
||||||
|
|
||||||
const mockEnrichMetadata = vi.fn();
|
|
||||||
|
|
||||||
vi.mock('../../src/hooks/useRoms', () => ({
|
|
||||||
useEnrichMetadata: () => ({
|
|
||||||
mutateAsync: mockEnrichMetadata,
|
|
||||||
isPending: false,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockResults: EnrichedGame[] = [
|
|
||||||
{
|
|
||||||
source: 'igdb',
|
|
||||||
externalIds: { igdb: 123 },
|
|
||||||
title: 'Game One',
|
|
||||||
slug: 'game-one',
|
|
||||||
releaseDate: '2020-01-15',
|
|
||||||
genres: ['Action', 'Adventure'],
|
|
||||||
platforms: ['Nintendo Switch'],
|
|
||||||
coverUrl: 'https://example.com/cover1.jpg',
|
|
||||||
description: 'A great game',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: 'rawg',
|
|
||||||
externalIds: { rawg: 456 },
|
|
||||||
title: 'Game Two',
|
|
||||||
slug: 'game-two',
|
|
||||||
releaseDate: '2021-06-20',
|
|
||||||
genres: ['RPG'],
|
|
||||||
platforms: ['PlayStation 5'],
|
|
||||||
coverUrl: 'https://example.com/cover2.jpg',
|
|
||||||
description: 'Another game',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('MetadataSearchDialog Component', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render when isOpen is false', () => {
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog
|
|
||||||
romId="rom-1"
|
|
||||||
isOpen={false}
|
|
||||||
onOpenChange={vi.fn()}
|
|
||||||
onSelect={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByText(/search metadata/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render when isOpen is true', () => {
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/search metadata/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have search input field', () => {
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText(/search game title/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept search input', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i) as HTMLInputElement;
|
|
||||||
await user.type(input, 'Game One');
|
|
||||||
|
|
||||||
expect(input.value).toBe('Game One');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call useEnrichMetadata when search is triggered', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue([mockResults[0]]);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game One');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockEnrichMetadata).toHaveBeenCalledWith('Game One');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display search results', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Game One')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Game Two')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display source badge for each result', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('IGDB')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('RAWG')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show "No results" message when search returns empty', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue([]);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'NonexistentGame');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/no results found/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onSelect when result is selected', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog
|
|
||||||
romId="rom-1"
|
|
||||||
isOpen={true}
|
|
||||||
onOpenChange={vi.fn()}
|
|
||||||
onSelect={onSelect}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Game One')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectButton = screen.getAllByRole('button', { name: /select/i })[0];
|
|
||||||
await user.click(selectButton);
|
|
||||||
|
|
||||||
expect(onSelect).toHaveBeenCalledWith(mockResults[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have cover image for each result', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const images = container.querySelectorAll('img');
|
|
||||||
expect(images.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading state during search', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
// The button should be in the document during and after search
|
|
||||||
expect(searchButton).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onOpenChange when closing dialog', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const onOpenChange = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog
|
|
||||||
romId="rom-1"
|
|
||||||
isOpen={true}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
onSelect={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find and click close button
|
|
||||||
const buttons = screen.getAllByRole('button');
|
|
||||||
const closeButton = buttons.find(
|
|
||||||
(btn) =>
|
|
||||||
btn.getAttribute('aria-label')?.includes('close') ||
|
|
||||||
btn.textContent?.includes('✕') ||
|
|
||||||
btn.textContent?.includes('Cancel')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (closeButton) {
|
|
||||||
await user.click(closeButton);
|
|
||||||
expect(onOpenChange).toHaveBeenCalled();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display release date for results', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockEnrichMetadata.mockResolvedValue(mockResults);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MetadataSearchDialog romId="rom-1" isOpen={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search game title/i);
|
|
||||||
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
||||||
|
|
||||||
await user.type(input, 'Game');
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/2020/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/2021/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import Navbar from '../../src/components/layout/Navbar';
|
|
||||||
|
|
||||||
describe('Navbar', () => {
|
|
||||||
it('muestra enlaces ROMs y Games', () => {
|
|
||||||
render(<Navbar />);
|
|
||||||
expect(screen.getByText('ROMs')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Games')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import Navbar from '../../src/components/layout/Navbar';
|
|
||||||
|
|
||||||
describe('Navbar', () => {
|
|
||||||
it('renders ROMs and Games links', () => {
|
|
||||||
render(<Navbar />);
|
|
||||||
expect(screen.getByText(/ROMs/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Games/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import ScanDialog from '../../src/components/roms/ScanDialog';
|
|
||||||
|
|
||||||
const mockScanDirectory = vi.fn();
|
|
||||||
|
|
||||||
vi.mock('../../src/hooks/useRoms', () => ({
|
|
||||||
useScanDirectory: () => ({
|
|
||||||
mutateAsync: mockScanDirectory,
|
|
||||||
isPending: false,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('ScanDialog Component', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render when isOpen is false', () => {
|
|
||||||
render(<ScanDialog isOpen={false} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
// Dialog content should not be visible
|
|
||||||
expect(screen.queryByText(/scan roms directory/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render when isOpen is true', () => {
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/scan roms directory/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have input field for path', () => {
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText(/enter rom directory path/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept text input in path field', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i) as HTMLInputElement;
|
|
||||||
await user.type(input, '/path/to/roms');
|
|
||||||
|
|
||||||
expect(input.value).toBe('/path/to/roms');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have "Scan Directory" button', () => {
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /scan directory/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call useScanDirectory when form is submitted', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockScanDirectory.mockResolvedValue({ processed: 5, createdCount: 3, upserted: 2 });
|
|
||||||
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i);
|
|
||||||
const button = screen.getByRole('button', { name: /scan directory/i });
|
|
||||||
|
|
||||||
await user.type(input, '/roms');
|
|
||||||
await user.click(button);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockScanDirectory).toHaveBeenCalledWith('/roms');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading state during scanning', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
const { rerender } = render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i);
|
|
||||||
const button = screen.getByRole('button', { name: /scan directory/i });
|
|
||||||
|
|
||||||
await user.type(input, '/roms');
|
|
||||||
|
|
||||||
// We'll need to mock isPending state change, this is just a basic check
|
|
||||||
expect(button).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display success message after scan', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
mockScanDirectory.mockResolvedValue({ processed: 5, createdCount: 3, upserted: 2 });
|
|
||||||
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i);
|
|
||||||
const button = screen.getByRole('button', { name: /scan directory/i });
|
|
||||||
|
|
||||||
await user.type(input, '/roms');
|
|
||||||
await user.click(button);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/scan completed/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display error message on scan failure', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const error = new Error('Failed to scan directory');
|
|
||||||
mockScanDirectory.mockRejectedValue(error);
|
|
||||||
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i);
|
|
||||||
const button = screen.getByRole('button', { name: /scan directory/i });
|
|
||||||
|
|
||||||
await user.type(input, '/roms');
|
|
||||||
await user.click(button);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onOpenChange when close button is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const onOpenChange = vi.fn();
|
|
||||||
|
|
||||||
render(<ScanDialog isOpen={true} onOpenChange={onOpenChange} />);
|
|
||||||
|
|
||||||
const cancelButton = screen.getByText('Cancel');
|
|
||||||
|
|
||||||
await user.click(cancelButton);
|
|
||||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should disable input and button while scanning', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
let isPending = false;
|
|
||||||
|
|
||||||
const ScanDialogWithPending = ({ isOpen, onOpenChange }: any) => {
|
|
||||||
return <ScanDialog isOpen={isOpen} onOpenChange={onOpenChange} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<ScanDialogWithPending isOpen={true} onOpenChange={vi.fn()} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/enter rom directory path/i) as HTMLInputElement;
|
|
||||||
expect(input.disabled).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { queryClient } from '../../src/lib/queryClient';
|
|
||||||
import Games from '../../src/routes/games';
|
|
||||||
import * as useGamesModule from '../../src/hooks/useGames';
|
|
||||||
|
|
||||||
// Mock the useGames hooks
|
|
||||||
vi.spyOn(useGamesModule, 'useGames');
|
|
||||||
vi.spyOn(useGamesModule, 'useCreateGame');
|
|
||||||
vi.spyOn(useGamesModule, 'useUpdateGame');
|
|
||||||
vi.spyOn(useGamesModule, 'useDeleteGame');
|
|
||||||
|
|
||||||
const mockGames = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: 'The Legend of Zelda',
|
|
||||||
slug: 'zelda-game',
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
|
||||||
description: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: 'Super Mario Bros',
|
|
||||||
slug: 'mario-game',
|
|
||||||
createdAt: '2026-01-02T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-02T00:00:00Z',
|
|
||||||
description: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('Games Page', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
// Default mocks
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: mockGames,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useCreateGame).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useUpdateGame).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useDeleteGame).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render empty state when no games', () => {
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: [],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/no games found/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render loading state', () => {
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/loading games/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render error state', () => {
|
|
||||||
const error = new Error('Failed to fetch');
|
|
||||||
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
error,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render table with games', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('The Legend of Zelda')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Super Mario Bros')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render "Add Game" button', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /add game/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open form when "Add Game" is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const addButton = screen.getByRole('button', { name: /add game/i });
|
|
||||||
await user.click(addButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/create game/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open form for editing when edit button is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const editButtons = screen.getAllByRole('button', { name: /edit/i });
|
|
||||||
await user.click(editButtons[0]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/edit game/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show delete confirmation when delete is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
|
||||||
await user.click(deleteButtons[0]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call delete mutation when confirmed', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
const deleteAsync = vi.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
vi.mocked(useGamesModule.useDeleteGame).mockReturnValue({
|
|
||||||
mutateAsync: deleteAsync,
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
|
||||||
await user.click(deleteButtons[0]);
|
|
||||||
|
|
||||||
const confirmButton = await screen.findByRole('button', { name: /confirm/i });
|
|
||||||
await user.click(confirmButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(deleteAsync).toHaveBeenCalledWith('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display table headers', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Games />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Slug')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Created')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { queryClient } from '../../src/lib/queryClient';
|
|
||||||
import * as useRomsModule from '../../src/hooks/useRoms';
|
|
||||||
import Roms from '../../src/routes/roms';
|
|
||||||
import { RomFile } from '../../src/types/rom';
|
|
||||||
|
|
||||||
// Mock the useRoms hooks
|
|
||||||
vi.spyOn(useRomsModule, 'useRoms');
|
|
||||||
vi.spyOn(useRomsModule, 'useScanDirectory');
|
|
||||||
vi.spyOn(useRomsModule, 'useEnrichMetadata');
|
|
||||||
vi.spyOn(useRomsModule, 'useLinkGameToRom');
|
|
||||||
vi.spyOn(useRomsModule, 'useDeleteRom');
|
|
||||||
|
|
||||||
const mockRoms: RomFile[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
path: '/roms/game1.zip',
|
|
||||||
filename: 'game1.zip',
|
|
||||||
checksum: 'abc123def456',
|
|
||||||
size: 1024000,
|
|
||||||
format: 'zip',
|
|
||||||
status: 'active',
|
|
||||||
addedAt: '2026-01-01T00:00:00Z',
|
|
||||||
game: {
|
|
||||||
id: 'g1',
|
|
||||||
title: 'Game One',
|
|
||||||
slug: 'game-one',
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
path: '/roms/game2.rar',
|
|
||||||
filename: 'game2.rar',
|
|
||||||
checksum: 'xyz789uvw012',
|
|
||||||
size: 2048000,
|
|
||||||
format: 'rar',
|
|
||||||
status: 'active',
|
|
||||||
addedAt: '2026-01-02T00:00:00Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('ROMs Page', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
// Default mocks
|
|
||||||
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
||||||
data: mockRoms,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useRomsModule.useScanDirectory).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useRomsModule.useEnrichMetadata).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useRomsModule.useLinkGameToRom).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
vi.mocked(useRomsModule.useDeleteRom).mockReturnValue({
|
|
||||||
mutateAsync: vi.fn(),
|
|
||||||
isPending: false,
|
|
||||||
} as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render empty state when no roms', () => {
|
|
||||||
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
||||||
data: [],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/no roms yet/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render loading state', () => {
|
|
||||||
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/loading roms/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render error state', () => {
|
|
||||||
const error = new Error('Failed to fetch');
|
|
||||||
vi.mocked(useRomsModule.useRoms).mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
error,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render table with roms', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('game1.zip')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('game2.rar')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render "Scan Directory" button', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /scan directory/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open scan dialog when "Scan Directory" is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const scanButton = screen.getByRole('button', { name: /scan directory/i });
|
|
||||||
await user.click(scanButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/scan roms directory/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render rom with linked game', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Game One')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render "Link Metadata" button for rom without game', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
// game2.rar doesn't have a linked game
|
|
||||||
const linkButtons = screen.getAllByRole('button', { name: /link metadata/i });
|
|
||||||
expect(linkButtons.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open metadata search dialog when "Link Metadata" is clicked', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const linkButton = screen.getAllByRole('button', { name: /link metadata/i })[0];
|
|
||||||
await user.click(linkButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/search metadata/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show delete button and confirmation', async () => {
|
|
||||||
const user = await userEvent.setup();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
|
||||||
expect(deleteButtons.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle table columns correctly', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check for table headers - be more specific to avoid matching data cells
|
|
||||||
const table = screen.getByRole('table');
|
|
||||||
expect(table.querySelector('th:nth-child(1)')).toHaveTextContent(/filename/i);
|
|
||||||
expect(table.querySelector('th:nth-child(2)')).toHaveTextContent(/size/i);
|
|
||||||
expect(table.querySelector('th:nth-child(3)')).toHaveTextContent(/checksum/i);
|
|
||||||
expect(table.querySelector('th:nth-child(4)')).toHaveTextContent(/status/i);
|
|
||||||
expect(table.querySelector('th:nth-child(5)')).toHaveTextContent(/game/i);
|
|
||||||
expect(table.querySelector('th:nth-child(6)')).toHaveTextContent(/actions/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display file size in human readable format', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1024000 bytes should be displayed as 1000 KB
|
|
||||||
expect(screen.getByText(/1000\s*kb/i)).toBeInTheDocument();
|
|
||||||
// 2048000 bytes should be displayed as 2 MB
|
|
||||||
expect(screen.getByText(/2\s*mb/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display checksum truncated with ellipsis', () => {
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Roms />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
// First 8 chars should be shown + ...
|
|
||||||
expect(screen.getByText(/abc123de\.\.\./)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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": [
|
||||||
"include": ["src", "tests"]
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3000',
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: (path) => path,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
setupFiles: ['./src/setupTests.ts'],
|
|
||||||
include: ['tests/**/*.spec.tsx'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
setupFiles: './src/setupTests.ts',
|
|
||||||
include: ['tests/**/*.spec.tsx'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
## Phase 1 Complete: Análisis comparativo de proyectos y servicios
|
|
||||||
|
|
||||||
TL;DR: Se crearon y completaron cuatro documentos de análisis en `docs/` que resumen proyectos relevantes, APIs públicas y consideraciones legales para el MVP. Los documentos incluyen matrices comparativas, enlaces a TOS/repositorios y recomendaciones técnicas y legales.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- `docs/competitive-analysis.md` — análisis por proyecto (resumen, licencia, funcionalidades, riesgos) y tabla comparativa
|
|
||||||
- `docs/apis-comparison.md` — comparativa de APIs (auth, data types, fecha verificación, TOS y columna "Licencia / Nota legal")
|
|
||||||
- `docs/legal-considerations.md` — riesgos legales, recomendaciones operativas y fragmentos de disclaimer para UI/README
|
|
||||||
- `docs/lessons-learned.md` — lista priorizada de funcionalidades, PoC propuesta y recomendaciones técnicas
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- Ninguna (documentación)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- Ninguno (el usuario solicitó no crear tests para esta fase)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED ✅
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
chore: add comparative analysis docs
|
|
||||||
|
|
||||||
- Add `docs/competitive-analysis.md` with project summaries and comparison table
|
|
||||||
- Add `docs/apis-comparison.md` with API TOS links and license notes
|
|
||||||
- Add `docs/legal-considerations.md` and `docs/lessons-learned.md` with recommendations and PoC
|
|
||||||
- Add `Metadatos` block (Autor / Fecha verificación: 2026-02-07 / Última actualización)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
## Phase 2 Complete: Requisitos y diseño técnico
|
|
||||||
|
|
||||||
TL;DR: Se documentaron y finalizaron los requisitos funcionales y no funcionales del MVP, el diseño de arquitectura (monorepo, stack propuesto) y el modelo de datos inicial para `Game`, `RomFile`, `Platform`, `Artwork`, `Purchase` y `PriceHistory`.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- `docs/requirements.md`
|
|
||||||
- `docs/architecture.md`
|
|
||||||
- `docs/api-integration.md`
|
|
||||||
- `docs/data-model.md`
|
|
||||||
- `plans/gestor-coleccion-plan.md` (plan maestro actualizado)
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- Ninguna (documentación)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- Ninguno (recomendación: añadir tests que verifiquen la presencia y metadatos de los documentos claves si se automatiza la validación de docs en CI)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED ✅ (con recomendación menor: añadir `docs/legal-considerations.md` si falta para cubrir riesgos legales antes de integrar scraping o descargas masivas)
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
|
|
||||||
```
|
|
||||||
chore(docs): completar Fase 2 — requisitos y arquitectura
|
|
||||||
|
|
||||||
- Añade/actualiza `docs/requirements.md`, `docs/architecture.md`, `docs/api-integration.md`, `docs/data-model.md`
|
|
||||||
- Documenta criterios de aceptación y decisiones técnico-arquitectónicas
|
|
||||||
- Recomendación: añadir `docs/legal-considerations.md` (pendiente)
|
|
||||||
```
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
## Phase 3 Complete: ArchiveReader
|
|
||||||
|
|
||||||
TL;DR: Implementado `archiveReader` para listar entradas dentro de contenedores ZIP y 7z usando utilidades del sistema (`7z` y `unzip` como fallback). Añadidos tests unitarios que mockean las llamadas a `child_process.exec` para validar parsing y comportamiento de fallback.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/services/archiveReader.ts
|
|
||||||
- backend/tests/services/archiveReader.spec.ts
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `listArchiveEntries(filePath, logger)` — lista entradas de ZIP/7z usando `7z -slt` y `unzip -l` como fallback.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/services/archiveReader.spec.ts` — cubre:
|
|
||||||
- listado con salida simulada de `7z -slt`
|
|
||||||
- fallback a `unzip -l` si `7z` falla
|
|
||||||
- comportamiento para formatos no soportados
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: add archive reader and tests
|
|
||||||
|
|
||||||
- Añade `archiveReader` que lista entradas en ZIP/7z con fallback a `unzip`
|
|
||||||
- Añade tests unitarios que mockean `child_process.exec` para validar parsing
|
|
||||||
- Documenta dependencia de binarios en README y CI (pasos previos)
|
|
||||||
|
|
||||||
## Phase 3 Complete: Backend base y modelo de datos
|
|
||||||
|
|
||||||
Fase completada: configuré el backend mínimo (dependencias, Prisma schema), generé el cliente Prisma y aseguré que los tests TDD de backend pasan.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/package.json
|
|
||||||
- backend/prisma/schema.prisma
|
|
||||||
- backend/tests/models/game.spec.ts
|
|
||||||
- package.json
|
|
||||||
- .yarnrc.yml
|
|
||||||
- prisma-client/package.json
|
|
||||||
|
|
||||||
**Files generados por herramientas (no necesariamente versionadas):**
|
|
||||||
|
|
||||||
- prisma-client/client/\* (Prisma Client generado)
|
|
||||||
- node_modules/.prisma/client/\* (artefacto runtime generado)
|
|
||||||
|
|
||||||
**Functions / cambios clave:**
|
|
||||||
|
|
||||||
- Ajustes en `backend/tests/models/game.spec.ts` para fallback de carga del cliente Prisma generado.
|
|
||||||
- `backend/prisma/schema.prisma`: definición de modelos (Game, RomFile, Platform, Purchase, Artwork, Tag, PriceHistory) ya presente; ajustado el `generator client` para flujo de generación local.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- backend/tests/models/game.spec.ts (modificado: mejor manejo de require/generación del cliente)
|
|
||||||
- backend/tests/server.spec.ts (existente — pase verificable)
|
|
||||||
|
|
||||||
**Migraciones aplicadas durante pruebas:**
|
|
||||||
|
|
||||||
- `backend/prisma/migrations/20260208102247_init/migration.sql` (aplicada en DB temporal de test)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: backend base, Prisma schema, client gen and tests
|
|
||||||
|
|
||||||
- Añade/ajusta `backend` para usar Prisma y Vitest
|
|
||||||
- Genera cliente Prisma y corrige resoluciones PnP/node-modules
|
|
||||||
- Actualiza tests para cargar cliente generado y pasar TDD
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
## Phase 4 Complete: DAT verifier
|
|
||||||
|
|
||||||
TL;DR: Implementado `datVerifier` para parsear archivos DAT (XML) y verificar hashes de ROMs (CRC/MD5/SHA1/size). Se añadieron tests TDD y una fixture XML; los tests específicos pasan y se aplicó un parche menor de calidad.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/services/datVerifier.ts
|
|
||||||
- backend/tests/services/datVerifier.spec.ts
|
|
||||||
- backend/tests/fixtures/sample.dat.xml
|
|
||||||
- backend/package.json (se añadió `fast-xml-parser` en devDependencies)
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `parseDat(xml: string): DatDatabase` — parsea y normaliza la estructura DAT a un modelo en memoria.
|
|
||||||
- `verifyHashesAgainstDat(datDb: DatDatabase, hashes): {gameName, romName, matchedOn} | null` — verifica hashes contra el DAT y devuelve la coincidencia.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/services/datVerifier.spec.ts` — cubre parsing, match por CRC/MD5/SHA1/size y ausencia de match.
|
|
||||||
- `backend/tests/fixtures/sample.dat.xml` — fixture usada por las pruebas.
|
|
||||||
|
|
||||||
**Review Status:** APPROVED with minor recommendations
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: add datVerifier and tests
|
|
||||||
|
|
||||||
- Añade `datVerifier` con `parseDat` y `verifyHashesAgainstDat`
|
|
||||||
- Añade tests y fixture XML para validar matching por CRC/MD5/SHA1/size
|
|
||||||
- Añade `fast-xml-parser` en `backend/package.json` (devDependency)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
## Phase 5 Complete: Job runner en memoria
|
|
||||||
|
|
||||||
TL;DR: Se implementó un runner en memoria (`ImportRunner`) con control de concurrencia configurable, API de encolado (`enqueue`), estado (`getStatus`) y utilidades de parada (`stop`, `stopAndWait`). Se añadieron tests TDD que cubren concurrencia, rechazo tras `stop` y contabilización de tareas completadas. La ruta de importación ahora encola jobs en background y registra errores.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/config.ts
|
|
||||||
- backend/src/jobs/importRunner.ts
|
|
||||||
- backend/src/routes/import.ts
|
|
||||||
- backend/tests/jobs/importRunner.spec.ts
|
|
||||||
- backend/tsconfig.json
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `ImportRunner` (class) — `enqueue`, `getStatus`, `start`, `stop`, `stopAndWait`.
|
|
||||||
- `runner` (singleton) — instanciado y arrancado por defecto.
|
|
||||||
- `IMPORT_CONCURRENCY` (export) in `config.ts`.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/jobs/importRunner.spec.ts` — 5–6 tests (enqueue result, concurrencia, getStatus, rechazo tras stop, completed incrementa en rechazo).
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: import job runner in-memory
|
|
||||||
|
|
||||||
- Añade `ImportRunner` en memoria con concurrencia configurable
|
|
||||||
- Tests TDD para enqueue, concurrencia y comportamiento tras `stop`
|
|
||||||
- Actualiza `/api/import/scan` para encolar jobs y registrar errores
|
|
||||||
- Ajusta `tsconfig.json` para incluir `tests` en comprobaciones de tipo
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
## Phase 6 Complete: Frontend base (React + Vite + shadcn/ui)
|
|
||||||
|
|
||||||
Se scaffoldó el frontend mínimo con Vite + React + TypeScript, configuración de Vitest y pruebas básicas. Los tests unitarios escritos pasan correctamente y el proyecto contiene los componentes y rutas base necesarios para continuar con la Fase 7.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- frontend/package.json
|
|
||||||
- frontend/tsconfig.json
|
|
||||||
- frontend/vite.config.ts
|
|
||||||
- frontend/vitest.config.ts
|
|
||||||
- frontend/index.html
|
|
||||||
- frontend/postcss.config.cjs
|
|
||||||
- frontend/tailwind.config.cjs
|
|
||||||
- frontend/src/main.tsx
|
|
||||||
- frontend/src/App.tsx
|
|
||||||
- frontend/src/components/layout/Navbar.tsx
|
|
||||||
- frontend/src/components/layout/Sidebar.tsx
|
|
||||||
- frontend/src/routes/index.tsx
|
|
||||||
- frontend/src/routes/roms.tsx
|
|
||||||
- frontend/src/routes/games.tsx
|
|
||||||
- frontend/src/lib/queryClient.ts
|
|
||||||
- frontend/src/lib/api.ts
|
|
||||||
- frontend/src/hooks/useGames.ts
|
|
||||||
- frontend/src/styles.css
|
|
||||||
- frontend/src/setupTests.ts
|
|
||||||
- frontend/tests/App.spec.tsx
|
|
||||||
- frontend/tests/components/Navbar.spec.tsx
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `App` component (frontend/src/App.tsx)
|
|
||||||
- `Navbar` component (frontend/src/components/layout/Navbar.tsx)
|
|
||||||
- `Sidebar` placeholder (frontend/src/components/layout/Sidebar.tsx)
|
|
||||||
- `queryClient` export (frontend/src/lib/queryClient.ts)
|
|
||||||
- `useGames` hook (stub) (frontend/src/hooks/useGames.ts)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- frontend/tests/App.spec.tsx
|
|
||||||
- frontend/tests/components/Navbar.spec.tsx
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: scaffold frontend base (Vite + React + Vitest)
|
|
||||||
|
|
||||||
- Añade scaffold de frontend con Vite y React
|
|
||||||
- Configura Vitest y tests básicos (App, Navbar)
|
|
||||||
- Añade QueryClient y hooks/plantillas iniciales
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
## Phase 7 Complete: Gestión manual de juegos (frontend + backend)
|
|
||||||
|
|
||||||
Se implementó el CRUD completo para juegos: endpoints REST en backend (GET/POST/PUT/DELETE /api/games), validación con Zod, y frontend con formulario reactivo, tabla de juegos, y custom hooks con TanStack Query. Todos los tests unitarios y de integración pasan exitosamente.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- backend/src/routes/games.ts
|
|
||||||
- backend/src/controllers/gamesController.ts
|
|
||||||
- backend/src/validators/gameValidator.ts
|
|
||||||
- backend/tests/routes/games.spec.ts
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- frontend/src/routes/games.tsx
|
|
||||||
- frontend/src/components/games/GameForm.tsx
|
|
||||||
- frontend/src/components/games/GameCard.tsx
|
|
||||||
- frontend/src/hooks/useGames.ts
|
|
||||||
- frontend/tests/routes/games.spec.tsx
|
|
||||||
- frontend/tests/components/GameForm.spec.tsx
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- `GamesController.listGames()` - Obtiene todos los juegos
|
|
||||||
- `GamesController.createGame()` - Crea un nuevo juego con validación
|
|
||||||
- `GamesController.updateGame()` - Actualiza un juego existente
|
|
||||||
- `GamesController.deleteGame()` - Elimina un juego
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- `GameForm` component - Formulario para crear/editar juegos con validación Zod
|
|
||||||
- `GameCard` component - Card para mostrar detalles de un juego
|
|
||||||
- `useGames()` hook - Obtiene lista de juegos (TanStack Query)
|
|
||||||
- `useCreateGame()` hook - Crear nuevo juego (TanStack Query mutation)
|
|
||||||
- `useUpdateGame()` hook - Actualizar juego (TanStack Query mutation)
|
|
||||||
- `useDeleteGame()` hook - Eliminar juego (TanStack Query mutation)
|
|
||||||
- Games page component - Tabla de juegos con acciones (crear, editar, eliminar)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- tests/routes/games.spec.ts - 11 tests (CRUD endpoints)
|
|
||||||
- GET /api/games: list empty, list with games
|
|
||||||
- POST /api/games: create valid, missing required, empty title, required fields only
|
|
||||||
- PUT /api/games/:id: update existing, 404 not found, partial update
|
|
||||||
- DELETE /api/games/:id: delete existing, 404 not found
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- tests/routes/games.spec.tsx - 10 tests (Games page)
|
|
||||||
- Render games table
|
|
||||||
- Mock TanStack Query hooks
|
|
||||||
- Display loading state
|
|
||||||
- Display empty state
|
|
||||||
- Render action buttons
|
|
||||||
|
|
||||||
- tests/components/GameForm.spec.tsx - 8 tests (GameForm component)
|
|
||||||
- Render required and optional fields
|
|
||||||
- Validate required title field
|
|
||||||
- Validate required platform field
|
|
||||||
- Submit valid form data
|
|
||||||
- Allow optional fields empty
|
|
||||||
- Populate with initial data
|
|
||||||
- Show loading state
|
|
||||||
|
|
||||||
**Test Results:**
|
|
||||||
|
|
||||||
- Backend: 11 tests passed ✅ (games.spec.ts)
|
|
||||||
- Backend total: 46 passed, 1 skipped ✅
|
|
||||||
- Frontend: 22 tests passed ✅ (4 test files)
|
|
||||||
- GameForm: 8 passed
|
|
||||||
- Games page: 10 passed
|
|
||||||
- App: 2 passed
|
|
||||||
- Navbar: 2 passed
|
|
||||||
- Lint: 0 errors, 12 warnings ✅
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Key Features Implemented:**
|
|
||||||
|
|
||||||
1. **Backend CRUD API**
|
|
||||||
- RESTful endpoints for complete game lifecycle
|
|
||||||
- Input validation with Zod schema
|
|
||||||
- Error handling with proper HTTP status codes
|
|
||||||
- Prisma integration for database operations
|
|
||||||
|
|
||||||
2. **Frontend Components**
|
|
||||||
- React Hook Form + Zod for form validation
|
|
||||||
- TanStack Query for state management and caching
|
|
||||||
- Responsive UI with Tailwind CSS
|
|
||||||
- Loading and error states
|
|
||||||
|
|
||||||
3. **Type Safety**
|
|
||||||
- TypeScript throughout
|
|
||||||
- Zod schemas for runtime validation
|
|
||||||
- Proper type inference in React components
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: implement games CRUD (Phase 7)
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- Add REST endpoints: GET, POST, PUT, DELETE /api/games
|
|
||||||
- Implement GamesController with CRUD logic
|
|
||||||
- Add Zod validator for game input validation
|
|
||||||
- Add 11 comprehensive tests for all endpoints
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- Create GameForm component with React Hook Form + Zod
|
|
||||||
- Create GameCard component for game display
|
|
||||||
- Implement useGames, useCreateGame, useUpdateGame, useDeleteGame hooks
|
|
||||||
- Add Games page with table and action buttons
|
|
||||||
- Add 18 component and page tests with 100% pass rate
|
|
||||||
|
|
||||||
All tests passing: 46 backend + 22 frontend tests
|
|
||||||
```
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
## Phase 8 Complete: Integración ROMs + Metadata (UI completa)
|
|
||||||
|
|
||||||
Se implementó el flujo completo de gestión de ROMs: endpoints REST en backend, tipos y hooks en frontend, componentes interactivos (ScanDialog, MetadataSearchDialog, RomCard), tabla de ROMs con CRUD completo, integración con búsqueda de metadata (IGDB/RAWG/TheGamesDB), y vinculación con juegos. Todos los 122 tests pasan (63 backend + 59 frontend).
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
### Backend (Fase 8.1)
|
|
||||||
|
|
||||||
- backend/src/controllers/romsController.ts
|
|
||||||
- backend/src/routes/roms.ts
|
|
||||||
- backend/src/routes/metadata.ts
|
|
||||||
- backend/src/app.ts (registrar rutas)
|
|
||||||
- backend/tests/routes/roms.spec.ts (12 tests)
|
|
||||||
- backend/tests/routes/metadata.spec.ts
|
|
||||||
- backend/vitest.config.ts (threads: false para BD)
|
|
||||||
- backend/tests/setup.ts (migrations en setup)
|
|
||||||
- backend/tests/routes/games.spec.ts (actualizado beforeEach)
|
|
||||||
|
|
||||||
### Frontend (Fase 8.2 + 8.3)
|
|
||||||
|
|
||||||
- frontend/src/types/rom.ts
|
|
||||||
- frontend/src/lib/api.ts (extendido)
|
|
||||||
- frontend/src/hooks/useRoms.ts (5 custom hooks)
|
|
||||||
- frontend/src/components/roms/ScanDialog.tsx
|
|
||||||
- frontend/src/components/roms/MetadataSearchDialog.tsx
|
|
||||||
- frontend/src/components/roms/RomCard.tsx
|
|
||||||
- frontend/src/routes/roms.tsx (reescrito)
|
|
||||||
- frontend/tests/routes/roms.spec.tsx (13 tests)
|
|
||||||
- frontend/tests/components/ScanDialog.spec.tsx (11 tests)
|
|
||||||
- frontend/tests/components/MetadataSearchDialog.spec.tsx (13 tests)
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- `RomsController.listRoms()` — Listar ROMs con opcional filtros
|
|
||||||
- `RomsController.getRomById()` — Obtener por ID
|
|
||||||
- `RomsController.linkGameToRom()` — Vincular juego a ROM
|
|
||||||
- `RomsController.deleteRom()` — Eliminar ROM
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- `useRoms()` — Query para listar
|
|
||||||
- `useScanDirectory()` — Mutation para scan
|
|
||||||
- `useEnrichMetadata()` — Mutation para búsqueda
|
|
||||||
- `useLinkGameToRom()` — Mutation para vincular
|
|
||||||
- `useDeleteRom()` — Mutation para eliminar
|
|
||||||
- `ScanDialog` — Dialog input path
|
|
||||||
- `MetadataSearchDialog` — Dialog búsqueda metadata
|
|
||||||
- `RomCard` — Card display ROM
|
|
||||||
- `Roms` page — Tabla completa + dialogs
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- 12 tests en roms.spec.ts: CRUD ROMs (lista, detail, link, delete)
|
|
||||||
- Métadata search tests (con y sin resultados)
|
|
||||||
- Total: 63 backend tests all passing ✅
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- 13 tests en roms.spec.tsx: tabla, acciones, states
|
|
||||||
- 11 tests en ScanDialog.spec.tsx: input, submit, loading
|
|
||||||
- 13 tests en MetadataSearchDialog.spec.tsx: búsqueda, resultados, select
|
|
||||||
- Total: 59 frontend tests all passing ✅
|
|
||||||
|
|
||||||
**Test Results:**
|
|
||||||
|
|
||||||
- Backend: 63 passed (16 test files, 1 skipped) ✅
|
|
||||||
- Frontend: 59 passed (7 test files) ✅
|
|
||||||
- Total: 122 tests all passing ✅
|
|
||||||
- Lint: 0 errors, 12 warnings (solo directivas no utilizadas) ✅
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Key Features Implemented:**
|
|
||||||
|
|
||||||
1. **Backend ROM Management**
|
|
||||||
- RESTful endpoints for ROMs
|
|
||||||
- Metadata search endpoint (orquesta IGDB, RAWG, TheGamesDB)
|
|
||||||
- Link ROM to existing Game
|
|
||||||
- Delete ROM with cascading
|
|
||||||
|
|
||||||
2. **Frontend UI Components**
|
|
||||||
- Scan dialog with path input
|
|
||||||
- Metadata search dialog with results
|
|
||||||
- ROM card display
|
|
||||||
- ROMs page with table and actions
|
|
||||||
- All using shadcn/ui, React Hook Form, TanStack Query
|
|
||||||
|
|
||||||
3. **Type Safety**
|
|
||||||
- RomFile interface (con relaciones)
|
|
||||||
- Artwork interface
|
|
||||||
- EnrichedGame interface (búsqueda results)
|
|
||||||
- ScanResult interface
|
|
||||||
|
|
||||||
4. **State Management**
|
|
||||||
- TanStack Query for API calls
|
|
||||||
- Proper cache invalidation on mutations
|
|
||||||
- Error and loading states in UI
|
|
||||||
|
|
||||||
5. **Integration**
|
|
||||||
- Backend ROMs connect to existing Games
|
|
||||||
- Metadata search uses existing IGDB/RAWG/TGDB clients
|
|
||||||
- DB migrations auto-applied in tests
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: implement ROMs management UI (Phase 8)
|
|
||||||
|
|
||||||
Backend (Phase 8.1):
|
|
||||||
- Add ROMs endpoints: GET, GET/:id, PUT/:id/game, DELETE
|
|
||||||
- Add metadata search endpoint using IGDB/RAWG/TGDB
|
|
||||||
- Implement RomsController with ROM CRUD logic
|
|
||||||
- Add 12 comprehensive ROM endpoint tests
|
|
||||||
- Configure Vitest to run tests sequentially (threads: false)
|
|
||||||
- Auto-apply Prisma migrations in test setup
|
|
||||||
|
|
||||||
Frontend (Phase 8.2 + 8.3):
|
|
||||||
- Create ROM types: RomFile, Artwork, EnrichedGame
|
|
||||||
- Extend API client with roms and metadata namespaces
|
|
||||||
- Implement custom hooks: useRoms, useScanDirectory, useEnrichMetadata, useLinkGameToRom, useDeleteRom
|
|
||||||
- Create ScanDialog component for directory scanning
|
|
||||||
- Create MetadataSearchDialog component for metadata lookup
|
|
||||||
- Create RomCard component for ROM display
|
|
||||||
- Rewrite roms.tsx page with table and all actions
|
|
||||||
- Add 37 comprehensive component and page tests
|
|
||||||
|
|
||||||
All 122 tests passing: 63 backend + 59 frontend
|
|
||||||
Lint: 0 errors
|
|
||||||
```
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
## Plan: Fase 8 - Integración ROMs + Metadata (UI completa)
|
|
||||||
|
|
||||||
Implementar UI completa para gestionar ROMs: tabla con scan de directorios, búsqueda de metadata en IGDB/RAWG/TheGamesDB, vinculación con juegos, y visualización de artwork. Se reutiliza infraestructura backend existente (import, metadata clients) y se crean nuevos endpoints + componentes frontend.
|
|
||||||
|
|
||||||
**Sub-Fases: 3**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 8.1: Backend ROMs API endpoints + Controller**
|
|
||||||
|
|
||||||
- **Objetivo:** Endpoints REST para listar ROMs, búsqueda de metadata, vincular ROM a juego
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `backend/src/controllers/romsController.ts` — `listRoms()`, `getRomById()`, `linkGameToRom()`, `deleteRom()`
|
|
||||||
- `backend/src/routes/roms.ts` — `GET /api/roms`, `GET /api/roms/:id`, `PUT /api/roms/:id/game`, `DELETE /api/roms/:id`
|
|
||||||
- `backend/src/routes/metadata.ts` — `GET /api/metadata/search?q=query` (orquesta metadataService)
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `backend/tests/routes/roms.spec.ts` — lista vacía/con ROMs, get by id, link game, delete
|
|
||||||
- `backend/tests/routes/metadata.spec.ts` — búsqueda con results, sin results, mixed sources
|
|
||||||
- **Steps:**
|
|
||||||
1. Write tests (failing) — casos para CRUD + search
|
|
||||||
2. Implement romsController + routes
|
|
||||||
3. Run tests → pass
|
|
||||||
4. Lint + format
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 8.2: Frontend Types + API client + Custom Hooks**
|
|
||||||
|
|
||||||
- **Objetivo:** Tipos, cliente HTTP extendido, custom hooks con TanStack Query
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `frontend/src/types/rom.ts` — `RomFile`, `Artwork`, `EnrichedGame`
|
|
||||||
- `frontend/src/lib/api.ts` — extender con `api.roms.*` y `api.metadata.*` namespaces
|
|
||||||
- `frontend/src/hooks/useRoms.ts` — `useRoms()`, `useScanDirectory()`, `useEnrichMetadata()`, `useLinkGameToRom()`
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- Skipped por ahora (cubiertos en 8.3 con integration tests de páginas)
|
|
||||||
- **Steps:**
|
|
||||||
1. Create ROM types (RomFile, Artwork templates from Prisma schema)
|
|
||||||
2. Extend api.ts with roms and metadata namespaces
|
|
||||||
3. Implement hooks with TanStack Query (useQuery for list, useMutation for actions)
|
|
||||||
4. Format
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 8.3: Frontend Components + ROMs Page**
|
|
||||||
|
|
||||||
- **Objetivo:** Componentes UI para ROMs y tabla interactiva
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `frontend/src/components/roms/ScanDialog.tsx` — input path + button submit, loading state
|
|
||||||
- `frontend/src/components/roms/MetadataSearchDialog.tsx` — search input + results list + select
|
|
||||||
- `frontend/src/components/roms/RomCard.tsx` — card display (simple card con info ROM)
|
|
||||||
- `frontend/src/routes/roms.tsx` — reescribir con tabla, botones (scan, link, delete), dialogs
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `frontend/tests/routes/roms.spec.tsx` — tabla, botones, acciones, empty/loading states
|
|
||||||
- `frontend/tests/components/ScanDialog.spec.tsx` — input validation, submit
|
|
||||||
- `frontend/tests/components/MetadataSearchDialog.spec.tsx` — search results display
|
|
||||||
- **Steps:**
|
|
||||||
1. Write tests (failing) para página y componentes
|
|
||||||
2. Crear componentes (ScanDialog, MetadataSearchDialog, RomCard, roms.tsx page)
|
|
||||||
3. Tests → pass
|
|
||||||
4. Format + lint
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Open Questions**
|
|
||||||
|
|
||||||
1. ¿Agregar endpoint `GET /api/artwork/:gameId` (P1) o mantenerlo para Fase 9?
|
|
||||||
- **Respuesta:** Mantener para Fase 9 (artwork.ts). Fase 8 usa URLs directas de IGDB/RAWG.
|
|
||||||
|
|
||||||
2. ¿Cachear artwork localmente o usar proxy directo desde IGDB/RAWG?
|
|
||||||
- **Respuesta:** URLs directas de APIs (simples para Fase 8). Caché en Fase 9.
|
|
||||||
|
|
||||||
3. ¿Permitir batch scan (múltiples directorios) en Fase 8?
|
|
||||||
- **Respuesta:** No, un directorio por operación.
|
|
||||||
@@ -1,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)
|
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user