feat: add UI components for alert dialog, badge, checkbox, dialog, label, select, sheet, table, textarea
Some checks failed
CI / lint (push) Failing after 1m5s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped

- Implemented AlertDialog component with overlay, content, header, footer, title, description, action, and cancel functionalities.
- Created Badge component with variant support for different styles.
- Developed Checkbox component with custom styling and indicator.
- Added Dialog component with trigger, close, overlay, content, header, footer, title, and description.
- Introduced Label component for form elements.
- Built Select component with trigger, content, group, item, label, separator, and scroll buttons.
- Created Sheet component with trigger, close, overlay, content, header, footer, title, and description.
- Implemented Table component with header, body, footer, row, head, cell, and caption.
- Added Textarea component with custom styling.
- Established API service for game management with CRUD operations and metadata search functionalities.
- Updated dependencies in package lock files.
This commit is contained in:
2026-03-18 19:21:36 +01:00
parent b92cc19137
commit a07096d7c7
95 changed files with 8176 additions and 615 deletions

View File

@@ -5,7 +5,6 @@ import rateLimit from '@fastify/rate-limit';
import healthRoutes from './routes/health';
import importRoutes from './routes/import';
import gamesRoutes from './routes/games';
import romsRoutes from './routes/roms';
import metadataRoutes from './routes/metadata';
export function buildApp(): FastifyInstance {
@@ -19,7 +18,6 @@ export function buildApp(): FastifyInstance {
void app.register(healthRoutes, { prefix: '/api' });
void app.register(importRoutes, { prefix: '/api' });
void app.register(gamesRoutes, { prefix: '/api' });
void app.register(romsRoutes, { prefix: '/api' });
void app.register(metadataRoutes, { prefix: '/api' });
return app;

View File

@@ -22,11 +22,67 @@ export class GamesController {
});
}
/**
* Obtener un juego por ID
*/
static async getGameById(id: string) {
const game = await prisma.game.findUnique({
where: { id },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
artworks: true,
tags: true,
},
});
if (!game) {
throw new Error('Juego no encontrado');
}
return game;
}
/**
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/
static async listGamesBySource(source: string) {
return await prisma.game.findMany({
where: { source },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
orderBy: {
title: 'asc',
},
});
}
/**
* Crear un juego nuevo
*/
static async createGame(input: CreateGameInput) {
const { title, platformId, description, priceCents, currency, store, date, condition } = input;
const {
title,
platformId,
description,
priceCents,
currency,
store,
date,
condition,
source,
sourceId,
} = input;
// Generar slug basado en el título
const slug = title
@@ -38,6 +94,8 @@ export class GamesController {
title,
slug: `${slug}-${Date.now()}`, // Hacer slug único agregando timestamp
description: description || null,
source: source || 'manual',
sourceId: sourceId || null,
};
// Si se proporciona una plataforma, crearla en gamePlatforms
@@ -78,7 +136,8 @@ export class GamesController {
* Actualizar un juego existente
*/
static async updateGame(id: string, input: UpdateGameInput) {
const { title, platformId, description, priceCents, currency, store, date } = input;
const { title, platformId, description, priceCents, currency, store, date, source, sourceId } =
input;
const updateData: Prisma.GameUpdateInput = {};
@@ -96,6 +155,14 @@ export class GamesController {
updateData.description = description;
}
if (source !== undefined) {
updateData.source = source;
}
if (sourceId !== undefined) {
updateData.sourceId = sourceId;
}
const game = await prisma.game.update({
where: { id },
data: updateData,
@@ -176,5 +243,6 @@ export class GamesController {
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
* Última actualización: 2026-03-18
* Actualizado para soportar fuente (source) en juegos
*/

View File

@@ -1,96 +0,0 @@
import { prisma } from '../plugins/prisma';
export class RomsController {
/**
* Listar todos los ROMs con sus juegos asociados
*/
static async listRoms() {
return await prisma.romFile.findMany({
include: {
game: true,
},
orderBy: {
filename: 'asc',
},
});
}
/**
* Obtener un ROM por ID con su juego asociado
*/
static async getRomById(id: string) {
const rom = await prisma.romFile.findUnique({
where: { id },
include: {
game: true,
},
});
if (!rom) {
throw new Error('ROM no encontrado');
}
return rom;
}
/**
* Vincular un juego a un ROM existente
*/
static async linkGameToRom(romId: string, gameId: string) {
// Validar que el ROM existe
const rom = await prisma.romFile.findUnique({
where: { id: romId },
});
if (!rom) {
throw new Error('ROM no encontrado');
}
// Validar que el juego existe
const game = await prisma.game.findUnique({
where: { id: gameId },
});
if (!game) {
throw new Error('Juego no encontrado');
}
// Actualizar el ROM con el nuevo gameId
return await prisma.romFile.update({
where: { id: romId },
data: {
gameId,
},
include: {
game: true,
},
});
}
/**
* Eliminar un ROM por ID
*/
static async deleteRom(id: string) {
// Validar que el ROM existe
const rom = await prisma.romFile.findUnique({
where: { id },
});
if (!rom) {
throw new Error('ROM no encontrado');
}
// Eliminar el ROM
await prisma.romFile.delete({
where: { id },
});
return { message: 'ROM eliminado correctamente' };
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -13,6 +13,24 @@ async function gamesRoutes(app: FastifyInstance) {
return reply.code(200).send(games);
});
/**
* GET /api/games/:id
* Obtener un juego por ID
*/
app.get<{ Params: { id: string }; Reply: any }>('/games/:id', async (request, reply) => {
try {
const game = await GamesController.getGameById(request.params.id);
return reply.code(200).send(game);
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'Juego no encontrado',
});
}
throw error;
}
});
/**
* POST /api/games
* Crear un nuevo juego
@@ -80,6 +98,18 @@ async function gamesRoutes(app: FastifyInstance) {
throw error;
}
});
/**
* GET /api/games/source/:source
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/
app.get<{ Params: { source: string }; Reply: any[] }>(
'/games/source/:source',
async (request, reply) => {
const games = await GamesController.listGamesBySource(request.params.source);
return reply.code(200).send(games);
}
);
}
export default gamesRoutes;
@@ -87,5 +117,6 @@ export default gamesRoutes;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
* Última actualización: 2026-03-18
* Actualizado para soportar fuente (source) en juegos
*/

View File

@@ -1,95 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { RomsController } from '../controllers/romsController';
import { linkGameSchema } from '../validators/romValidator';
import { ZodError } from 'zod';
async function romsRoutes(app: FastifyInstance) {
/**
* GET /api/roms
* Listar todos los ROMs
*/
app.get<{ Reply: any[] }>('/roms', async (request, reply) => {
const roms = await RomsController.listRoms();
return reply.code(200).send(roms);
});
/**
* GET /api/roms/:id
* Obtener un ROM por ID
*/
app.get<{ Params: { id: string }; Reply: any }>('/roms/:id', async (request, reply) => {
try {
const rom = await RomsController.getRomById(request.params.id);
return reply.code(200).send(rom);
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'ROM no encontrado',
});
}
throw error;
}
});
/**
* PUT /api/roms/:id/game
* Vincular un juego a un ROM
*/
app.put<{ Params: { id: string }; Body: any; Reply: any }>(
'/roms/:id/game',
async (request, reply) => {
try {
// Validar entrada con Zod
const validated = linkGameSchema.parse(request.body);
const rom = await RomsController.linkGameToRom(request.params.id, validated.gameId);
return reply.code(200).send(rom);
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({
error: 'Validación fallida',
details: error.errors,
});
}
if (error instanceof Error) {
if (error.message.includes('ROM no encontrado')) {
return reply.code(404).send({
error: 'ROM no encontrado',
});
}
if (error.message.includes('Juego no encontrado')) {
return reply.code(400).send({
error: 'Game ID inválido o no encontrado',
});
}
}
throw error;
}
}
);
/**
* DELETE /api/roms/:id
* Eliminar un ROM
*/
app.delete<{ Params: { id: string }; Reply: any }>('/roms/:id', async (request, reply) => {
try {
await RomsController.deleteRom(request.params.id);
return reply.code(204).send();
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'ROM no encontrado',
});
}
throw error;
}
});
}
export default romsRoutes;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -1,11 +1,11 @@
/**
* Servicio: importService
*
* Orquesta el proceso de importación de ROMs desde un directorio:
* Orquesta el proceso de importación de juegos desde un directorio:
* 1. Lista archivos usando `scanDirectory`.
* 2. Calcula hashes y tamaño con `computeHashes` (streaming).
* 3. Normaliza el nombre a un `slug` y, si `persist` es true, crea/obtiene
* el `Game` correspondiente y hace `upsert` del `RomFile` en Prisma.
* el `Game` correspondiente con source="rom".
*
* `importDirectory` devuelve un resumen con contadores `{ processed, createdCount, upserted }`.
*/
@@ -89,31 +89,44 @@ export async function importDirectory(
const baseName = path.parse(file.filename).name;
const slug = createSlug(baseName);
let game = null;
if (persist) {
game = await prisma.game.findUnique({ where: { slug } });
if (!game) {
game = await prisma.game.create({ data: { title: baseName, slug } });
createdCount++;
}
await prisma.romFile.upsert({
where: { checksum },
update: { lastSeenAt: new Date(), size, hashes: JSON.stringify(hashes) },
create: {
path: file.path,
filename: file.filename,
checksum,
size,
format: file.format,
hashes: JSON.stringify(hashes),
gameId: game?.id,
// Buscar si ya existe un juego con este checksum (source=rom)
let game = await prisma.game.findFirst({
where: {
source: 'rom',
romChecksum: checksum,
},
});
upserted++;
if (!game) {
// Crear nuevo juego con source="rom"
game = await prisma.game.create({
data: {
title: baseName,
slug: `${slug}-${Date.now()}`,
source: 'rom',
romPath: file.path,
romFilename: file.filename,
romSize: size,
romChecksum: checksum,
romFormat: file.format,
romHashes: JSON.stringify(hashes),
addedAt: new Date(),
lastSeenAt: new Date(),
},
});
createdCount++;
} else {
// Actualizar lastSeenAt si ya existe
game = await prisma.game.update({
where: { id: game.id },
data: {
lastSeenAt: new Date(),
romHashes: JSON.stringify(hashes),
},
});
upserted++;
}
}
} catch (err) {
logger.warn?.(

View File

@@ -3,6 +3,9 @@ import { z } from 'zod';
// Enum para condiciones (Loose, CIB, New)
export const GameCondition = z.enum(['Loose', 'CIB', 'New']).optional();
// Enum para fuentes de juegos
export const GameSource = z.enum(['manual', 'rom', 'igdb', 'rawg', 'thegamesdb']).optional();
// Esquema de validación para crear un juego
export const createGameSchema = z.object({
title: z.string().min(1, 'El título es requerido').trim(),
@@ -13,6 +16,8 @@ export const createGameSchema = z.object({
store: z.string().optional(),
date: z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo)
condition: GameCondition,
source: z.string().optional().default('manual'), // Fuente del juego
sourceId: z.string().optional(), // ID en la fuente externa
});
// Esquema de validación para actualizar un juego (todos los campos son opcionales)
@@ -26,6 +31,8 @@ export const updateGameSchema = z
store: z.string().optional(),
date: z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo)
condition: GameCondition,
source: z.string().optional(), // Fuente del juego
sourceId: z.string().optional(), // ID en la fuente externa
})
.strict();