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

@@ -0,0 +1,258 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Game, gamesApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ArrowLeftIcon, CalendarIcon, RefreshCwIcon, FileIcon } from 'lucide-react';
import { format } from 'date-fns';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
export default function GameDetailPage() {
const params = useParams();
const router = useRouter();
const gameId = params.id as string;
const [game, setGame] = useState<Game | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const loadGame = async () => {
setLoading(true);
setError(null);
try {
const gameData = await gamesApi.getById(gameId);
setGame(gameData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar el juego');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadGame();
}, [gameId]);
const handleDeleteGame = async () => {
try {
await gamesApi.delete(gameId);
router.push('/games');
} catch (err) {
console.error('Error deleting game:', err);
}
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const formatDate = (dateString?: string) => {
if (!dateString) return null;
try {
return format(new Date(dateString), 'yyyy-MM-dd');
} catch {
return null;
}
};
if (loading) {
return (
<div className="container mx-auto py-8">
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">Cargando juego...</div>
</div>
</div>
);
}
if (error || !game) {
return (
<div className="container mx-auto py-8">
<div className="flex items-center justify-center py-12">
<div className="text-destructive">{error || 'Juego no encontrado'}</div>
</div>
</div>
);
}
return (
<div className="container mx-auto py-8">
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">{game.title}</h1>
<p className="text-muted-foreground">{game.slug}</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={loadGame} disabled={refreshing}>
<RefreshCwIcon className={`size-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
Eliminar Juego
</Button>
</div>
</div>
{/* Game Info */}
<Card>
<CardHeader>
<CardTitle>Información del Juego</CardTitle>
<CardDescription>Detalles y metadatos</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{formatDate(game.releaseDate) && (
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
<span className="text-sm">
<span className="text-muted-foreground">Fecha de lanzamiento:</span>{' '}
{formatDate(game.releaseDate)}
</span>
</div>
)}
<div className="flex items-center gap-2">
<Badge variant="secondary">Fuente: {game.source}</Badge>
</div>
{game.genre && (
<div className="flex items-center gap-2">
<Badge variant="outline">Género: {game.genre}</Badge>
</div>
)}
{game.platform && (
<div className="flex items-center gap-2">
<Badge variant="outline">Plataforma: {game.platform}</Badge>
</div>
)}
{game.year && (
<div className="flex items-center gap-2">
<Badge variant="outline">Año: {game.year}</Badge>
</div>
)}
{game.sourceId && (
<div className="flex items-center gap-2">
<Badge variant="outline">ID de fuente: {game.sourceId}</Badge>
</div>
)}
{game.igdbId && (
<div className="flex items-center gap-2">
<Badge variant="outline">IGDB ID: {game.igdbId}</Badge>
</div>
)}
{game.rawgId && (
<div className="flex items-center gap-2">
<Badge variant="outline">RAWG ID: {game.rawgId}</Badge>
</div>
)}
{game.thegamesdbId && (
<div className="flex items-center gap-2">
<Badge variant="outline">TheGamesDB ID: {game.thegamesdbId}</Badge>
</div>
)}
</div>
{game.description && (
<div className="mt-4">
<h3 className="font-semibold mb-2">Descripción</h3>
<p className="text-sm text-muted-foreground">{game.description}</p>
</div>
)}
</CardContent>
</Card>
{/* ROM Info (if source = rom) */}
{game.source === 'rom' && (
<Card>
<CardHeader>
<CardTitle>Información del Archivo ROM</CardTitle>
<CardDescription>Detalles del archivo importado</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{game.romFilename && (
<div className="flex items-center gap-2">
<FileIcon className="size-4 text-muted-foreground" />
<span className="text-sm">
<span className="text-muted-foreground">Archivo:</span> {game.romFilename}
</span>
</div>
)}
{game.romPath && (
<div className="flex items-center gap-2">
<FileIcon className="size-4 text-muted-foreground" />
<span className="text-sm">
<span className="text-muted-foreground">Ruta:</span> {game.romPath}
</span>
</div>
)}
{game.romSize && (
<div className="flex items-center gap-2">
<Badge variant="outline">Tamaño: {formatFileSize(game.romSize)}</Badge>
</div>
)}
{game.romFormat && (
<div className="flex items-center gap-2">
<Badge variant="outline">Formato: {game.romFormat}</Badge>
</div>
)}
{game.romChecksum && (
<div className="flex items-center gap-2">
<Badge variant="outline">Checksum: {game.romChecksum}</Badge>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
{/* Delete Game Confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar juego?</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas eliminar &ldquo;{game.title}&rdquo;? Esta acción no se
puede deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteGame}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,219 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Game, gamesApi } from '@/lib/api';
import { GameTable } from '@/components/games/GameTable';
import { GameDialog } from '@/components/games/GameDialog';
import { GameFilters } from '@/components/games/GameFilters';
import { ImportSheet } from '@/components/games/ImportSheet';
import { Button } from '@/components/ui/button';
import { PlusIcon, LayoutGridIcon, TableIcon } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
export default function GamesPage() {
const router = useRouter();
const [games, setGames] = useState<Game[]>([]);
const [filteredGames, setFilteredGames] = useState<Game[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingGame, setEditingGame] = useState<Game | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [gameToDelete, setGameToDelete] = useState<Game | null>(null);
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
const loadGames = async () => {
setLoading(true);
try {
const data = await gamesApi.getAll();
setGames(data);
setFilteredGames(data);
} catch (err) {
console.error('Error loading games:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadGames();
}, []);
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredGames(games);
return;
}
const query = searchQuery.toLowerCase();
const filtered = games.filter(
(game) =>
game.title.toLowerCase().includes(query) || game.description?.toLowerCase().includes(query)
);
setFilteredGames(filtered);
}, [searchQuery, games]);
const handleCreate = () => {
setEditingGame(null);
setDialogOpen(true);
};
const handleEdit = (game: Game) => {
setEditingGame(game);
setDialogOpen(true);
};
const handleView = (game: Game) => {
router.push(`/games/${game.id}`);
};
const handleDelete = (game: Game) => {
setGameToDelete(game);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!gameToDelete) return;
try {
await gamesApi.delete(gameToDelete.id);
setDeleteDialogOpen(false);
setGameToDelete(null);
loadGames();
} catch (err) {
console.error('Error deleting game:', err);
}
};
return (
<div className="container mx-auto py-8">
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Gestión de Videojuegos</h1>
<p className="text-muted-foreground">
{filteredGames.length} {filteredGames.length === 1 ? 'juego' : 'juegos'} en tu
biblioteca
</p>
</div>
<div className="flex gap-2">
<ImportSheet onSuccess={loadGames} />
<Button onClick={handleCreate}>
<PlusIcon data-icon="inline-start" />
Nuevo Juego
</Button>
</div>
</div>
{/* Filters */}
<GameFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onClear={() => setSearchQuery('')}
/>
{/* View Mode Toggle */}
<div className="flex gap-2 self-end">
<Button
variant={viewMode === 'table' ? 'default' : 'outline'}
size="icon"
onClick={() => setViewMode('table')}
>
<TableIcon className="size-4" />
</Button>
<Button
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="icon"
onClick={() => setViewMode('grid')}
>
<LayoutGridIcon className="size-4" />
</Button>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">Cargando juegos...</div>
</div>
) : viewMode === 'table' ? (
<GameTable
games={filteredGames}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredGames.map((game) => (
<GameCard
key={game.id}
game={game}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Empty State */}
{!loading && filteredGames.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground mb-4">
{searchQuery
? 'No se encontraron juegos que coincidan con tu búsqueda.'
: 'No hay juegos en tu biblioteca.'}
</p>
{!searchQuery && (
<Button onClick={handleCreate}>
<PlusIcon data-icon="inline-start" />
Agregar primer juego
</Button>
)}
</div>
)}
</div>
{/* Dialog */}
<GameDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
game={editingGame}
onSuccess={loadGames}
/>
{/* Delete Confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar juego?</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas eliminar "{gameToDelete?.title}"? Esta acción no se puede
deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { useState } from 'react';
import { ImportRequest, ImportResult, importApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
UploadIcon,
FolderOpenIcon,
CheckCircleIcon,
XCircleIcon,
LoaderIcon,
ArrowLeftIcon,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
export default function ImportPage() {
const router = useRouter();
const [directory, setDirectory] = useState('');
const [recursive, setRecursive] = useState(true);
const [isImporting, setIsImporting] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const handleImport = async () => {
if (!directory.trim()) return;
setIsImporting(true);
setResult(null);
try {
const importData: ImportRequest = {
directory: directory.trim(),
recursive,
};
const importResult = await importApi.start(importData);
setResult(importResult);
} catch (err) {
setResult({
success: false,
message: err instanceof Error ? err.message : 'Error al importar',
imported: 0,
errors: [err instanceof Error ? err.message : 'Error desconocido'],
});
} finally {
setIsImporting(false);
}
};
const handleReset = () => {
setDirectory('');
setResult(null);
};
return (
<div className="container mx-auto py-8">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Importar Juegos</h1>
<p className="text-muted-foreground">
Importa juegos desde archivos ROM en un directorio local
</p>
</div>
</div>
{/* Import Form */}
<Card>
<CardHeader>
<CardTitle>Configuración de Importación</CardTitle>
<CardDescription>
Especifica el directorio que contiene los archivos ROM que deseas importar
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="directory">Directorio *</Label>
<div className="flex gap-2">
<Input
id="directory"
value={directory}
onChange={(e) => setDirectory(e.target.value)}
placeholder="/path/to/roms"
disabled={isImporting}
/>
<Button
type="button"
variant="outline"
size="icon"
disabled={isImporting}
title="Seleccionar directorio"
>
<FolderOpenIcon className="size-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Ruta absoluta del directorio que contiene las ROMs
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="recursive"
checked={recursive}
onCheckedChange={(checked) => setRecursive(checked === true)}
disabled={isImporting}
/>
<Label htmlFor="recursive" className="cursor-pointer">
Incluir subdirectorios recursivamente
</Label>
</div>
<div className="flex gap-2 mt-4">
<Button
variant="outline"
className="flex-1"
onClick={handleReset}
disabled={isImporting || !directory}
>
Limpiar
</Button>
<Button
className="flex-1"
onClick={handleImport}
disabled={isImporting || !directory.trim()}
>
{isImporting ? (
<>
<LoaderIcon className="size-4 animate-spin mr-2" />
Importando...
</>
) : (
<>
<UploadIcon data-icon="inline-start" />
Importar
</>
)}
</Button>
</div>
</CardContent>
</Card>
{/* Result */}
{result && (
<Card
className={`mt-6 ${result.success ? 'border-emerald-500/50' : 'border-destructive/50'}`}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{result.success ? (
<CheckCircleIcon className="size-5 text-emerald-500" />
) : (
<XCircleIcon className="size-5 text-destructive" />
)}
{result.success ? 'Importación Exitosa' : 'Error en la Importación'}
</CardTitle>
<CardDescription>{result.message}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<p className="text-sm font-medium">ROMs importadas</p>
<p className="text-2xl font-bold">{result.imported}</p>
</div>
{result.errors.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Errores encontrados</p>
<div className="bg-destructive/10 rounded-lg p-3 max-h-60 overflow-y-auto">
<ul className="text-sm text-destructive space-y-1">
{result.errors.map((error, index) => (
<li key={index} className="flex items-start gap-2">
<span className="text-destructive-foreground"></span>
<span>{error}</span>
</li>
))}
</ul>
</div>
</div>
)}
<div className="flex gap-2 pt-2">
<Button variant="outline" onClick={handleReset} className="flex-1">
Nueva Importación
</Button>
{result.success && (
<Button onClick={() => router.push('/games')} className="flex-1">
Ver Juegos
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Info */}
<Card className="mt-6 bg-muted/50">
<CardHeader>
<CardTitle className="text-base">Información</CardTitle>
</CardHeader>
<CardContent>
<ul className="text-sm text-muted-foreground space-y-1">
<li> El sistema escaneará el directorio especificado en busca de archivos ROM</li>
<li> Se calcularán los checksums (CRC32, MD5, SHA1) para cada archivo</li>
<li> Las ROMs se asociarán automáticamente con juegos existentes si coinciden</li>
<li> Los archivos duplicados se detectarán y omitirán</li>
<li> La importación puede tardar dependiendo de la cantidad de archivos</li>
</ul>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,28 +1,146 @@
import Navbar from '@/components/landing/Navbar';
import Hero from '@/components/landing/Hero';
import GameGrid from '@/components/landing/GameGrid';
import Footer from '@/components/landing/Footer';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Gamepad2, HardDrive, Import, Clock, Activity, Database } from 'lucide-react';
export default function Home() {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--mass-effect-dark)' }}>
{/* Starfield Background */}
<div className="starfield"></div>
{/* Navbar */}
<Navbar />
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Gamepad2 className="w-6 h-6 text-primary" />
<h1 className="text-xl font-bold">Quasar</h1>
</div>
<Badge variant="outline">v1.0.0</Badge>
</div>
</header>
{/* Main Content */}
<main id="main-content" className="pt-16">
{/* Hero Section */}
<Hero />
<main className="container mx-auto px-4 py-8">
<div className="space-y-8">
{/* Statistics Cards */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Activity className="w-5 h-5" />
Estadísticas
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardDescription>Total de Juegos</CardDescription>
<CardTitle className="text-3xl">0</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Database className="w-4 h-4" />
<span>Base de datos</span>
</div>
</CardContent>
</Card>
{/* Game Grid Section */}
<GameGrid />
<Card>
<CardHeader className="pb-3">
<CardDescription>Juegos Importados</CardDescription>
<CardTitle className="text-3xl">0</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Database className="w-4 h-4" />
<span>Desde archivos ROM</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Última Importación</CardDescription>
<CardTitle className="text-3xl">-</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>Sin registros</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Estado del Sistema</CardDescription>
<CardTitle className="text-3xl">
<Badge variant="default">Activo</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Activity className="w-4 h-4" />
<span>Operativo</span>
</div>
</CardContent>
</Card>
</div>
</section>
{/* Quick Actions */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Import className="w-5 h-5" />
Acciones Rápidas
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Gestión de Juegos</CardTitle>
<CardDescription>Ver y administrar tu biblioteca de videojuegos</CardDescription>
</CardHeader>
<CardContent>
<Link href="/games">
<Button className="w-full">
<Gamepad2 className="w-4 h-4 mr-2" />
Ir a Juegos
</Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Importar Juegos</CardTitle>
<CardDescription>Importa juegos desde archivos ROM</CardDescription>
</CardHeader>
<CardContent>
<Link href="/import">
<Button className="w-full" variant="outline">
<Import className="w-4 h-4 mr-2" />
Importar Archivos
</Button>
</Link>
</CardContent>
</Card>
</div>
</section>
{/* Recent Activity */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Clock className="w-5 h-5" />
Actividad Reciente
</h2>
<Card>
<CardContent className="py-6">
<div className="text-center text-muted-foreground py-8">
<Activity className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No hay actividad reciente</p>
<p className="text-sm">Las importaciones y cambios aparecerán aquí</p>
</div>
</CardContent>
</Card>
</section>
</div>
</main>
{/* Footer */}
<Footer />
</div>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { Game } from '@/lib/api';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EyeIcon, PencilIcon, TrashIcon, CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
interface GameCardProps {
game: Game;
onView: (game: Game) => void;
onEdit: (game: Game) => void;
onDelete: (game: Game) => void;
}
export function GameCard({ game, onView, onEdit, onDelete }: GameCardProps) {
const formatDate = (dateString?: string) => {
if (!dateString) return null;
try {
return format(new Date(dateString), 'yyyy');
} catch {
return null;
}
};
return (
<Card className="flex flex-col h-full hover:border-primary transition-colors">
<CardHeader>
<CardTitle className="line-clamp-2">{game.title}</CardTitle>
<CardDescription className="line-clamp-3">
{game.description || 'Sin descripción'}
</CardDescription>
</CardHeader>
<CardContent className="flex-1">
<div className="flex flex-wrap gap-2">
{formatDate(game.releaseDate) && (
<Badge variant="outline" className="gap-1">
<CalendarIcon className="size-3" />
{formatDate(game.releaseDate)}
</Badge>
)}
<Badge variant="secondary">{game.source}</Badge>
{game.genre && <Badge variant="outline">{game.genre}</Badge>}
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button
variant="ghost"
size="icon"
className="flex-1"
onClick={() => onView(game)}
title="Ver detalles"
>
<EyeIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="flex-1"
onClick={() => onEdit(game)}
title="Editar"
>
<PencilIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="flex-1"
onClick={() => onDelete(game)}
title="Eliminar"
>
<TrashIcon className="size-4 text-destructive" />
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,272 @@
'use client';
import { useState, useEffect } from 'react';
import { Game, CreateGameInput, UpdateGameInput, gamesApi } from '@/lib/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface GameDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
game?: Game | null;
onSuccess: () => void;
}
export function GameDialog({ open, onOpenChange, game, onSuccess }: GameDialogProps) {
const [formData, setFormData] = useState<CreateGameInput>({
title: '',
slug: '',
description: '',
releaseDate: '',
genre: '',
platform: '',
year: undefined,
cover: '',
source: 'manual',
sourceId: '',
platformId: '',
priceCents: undefined,
currency: 'USD',
store: '',
date: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (game) {
setFormData({
title: game.title,
slug: game.slug,
description: game.description || '',
releaseDate: game.releaseDate ? game.releaseDate.split('T')[0] : '',
genre: game.genre || '',
platform: game.platform || '',
year: game.year,
cover: game.cover || '',
source: game.source,
sourceId: game.sourceId || '',
platformId: '',
priceCents: undefined,
currency: 'USD',
store: '',
date: '',
});
} else {
setFormData({
title: '',
slug: '',
description: '',
releaseDate: '',
genre: '',
platform: '',
year: undefined,
cover: '',
source: 'manual',
sourceId: '',
platformId: '',
priceCents: undefined,
currency: 'USD',
store: '',
date: '',
});
}
setError(null);
}, [game, open]);
const handleChange = (field: keyof CreateGameInput, value: string | number | undefined) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const generateSlug = () => {
const slug = formData.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
setFormData((prev) => ({ ...prev, slug }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
if (game) {
await gamesApi.update(game.id, formData as UpdateGameInput);
} else {
await gamesApi.create(formData);
}
onSuccess();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar el juego');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{game ? 'Editar Juego' : 'Nuevo Juego'}</DialogTitle>
<DialogDescription>
{game ? 'Edita la información del juego.' : 'Añade un nuevo juego a tu biblioteca.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="title">Título *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => handleChange('title', e.target.value)}
onBlur={generateSlug}
required
placeholder="Ej: Super Mario World"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="slug">Slug</Label>
<div className="flex gap-2">
<Input
id="slug"
value={formData.slug}
onChange={(e) => handleChange('slug', e.target.value)}
placeholder="super-mario-world"
/>
<Button
type="button"
variant="outline"
onClick={generateSlug}
disabled={!formData.title}
>
Generar
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="description">Descripción</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
placeholder="Descripción del juego..."
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="releaseDate">Fecha de lanzamiento</Label>
<Input
id="releaseDate"
type="date"
value={formData.releaseDate}
onChange={(e) => handleChange('releaseDate', e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="year">Año</Label>
<Input
id="year"
type="number"
value={formData.year || ''}
onChange={(e) =>
handleChange('year', e.target.value ? parseInt(e.target.value) : undefined)
}
placeholder="1990"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="genre">Género</Label>
<Input
id="genre"
value={formData.genre}
onChange={(e) => handleChange('genre', e.target.value)}
placeholder="Plataformas"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="platform">Plataforma</Label>
<Input
id="platform"
value={formData.platform}
onChange={(e) => handleChange('platform', e.target.value)}
placeholder="SNES"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="cover">URL de la portada</Label>
<Input
id="cover"
value={formData.cover}
onChange={(e) => handleChange('cover', e.target.value)}
placeholder="https://example.com/cover.jpg"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="source">Fuente</Label>
<Select
value={formData.source}
onValueChange={(value) => handleChange('source', value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona fuente" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="rom">ROM</SelectItem>
<SelectItem value="igdb">IGDB</SelectItem>
<SelectItem value="rawg">RAWG</SelectItem>
<SelectItem value="thegamesdb">TheGamesDB</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sourceId">ID de fuente externa</Label>
<Input
id="sourceId"
value={formData.sourceId}
onChange={(e) => handleChange('sourceId', e.target.value)}
placeholder="ID en la fuente externa"
/>
</div>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Guardando...' : game ? 'Actualizar' : 'Crear'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { SearchIcon, XIcon } from 'lucide-react';
interface GameFiltersProps {
searchQuery: string;
onSearchChange: (query: string) => void;
onClear: () => void;
}
export function GameFilters({ searchQuery, onSearchChange, onClear }: GameFiltersProps) {
return (
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-md">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Buscar juegos..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
onClick={onClear}
>
<XIcon className="size-4" />
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import { Game } from '@/lib/api';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EyeIcon, PencilIcon, TrashIcon } from 'lucide-react';
import { format } from 'date-fns';
interface GameTableProps {
games: Game[];
onView: (game: Game) => void;
onEdit: (game: Game) => void;
onDelete: (game: Game) => void;
}
export function GameTable({ games, onView, onEdit, onDelete }: GameTableProps) {
const [sortField, setSortField] = useState<keyof Game>('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const handleSort = (field: keyof Game) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedGames = [...games].sort((a, b) => {
const aVal = a[sortField] || '';
const bVal = b[sortField] || '';
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortDirection === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
}
return 0;
});
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
try {
return format(new Date(dateString), 'yyyy-MM-dd');
} catch {
return '-';
}
};
return (
<div className="rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:text-primary"
onClick={() => handleSort('title')}
>
Título {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead>Descripción</TableHead>
<TableHead
className="cursor-pointer hover:text-primary"
onClick={() => handleSort('releaseDate')}
>
Fecha {sortField === 'releaseDate' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead>Fuente</TableHead>
<TableHead>Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedGames.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
No hay juegos encontrados
</TableCell>
</TableRow>
) : (
sortedGames.map((game) => (
<TableRow key={game.id}>
<TableCell className="font-medium">{game.title}</TableCell>
<TableCell className="max-w-xs truncate text-muted-foreground">
{game.description || '-'}
</TableCell>
<TableCell>{formatDate(game.releaseDate)}</TableCell>
<TableCell>
<Badge variant="secondary">{game.source}</Badge>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => onView(game)}
title="Ver detalles"
>
<EyeIcon className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onEdit(game)} title="Editar">
<PencilIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(game)}
title="Eliminar"
>
<TrashIcon className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useState } from 'react';
import { ImportRequest, ImportResult, importApi } from '@/lib/api';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { UploadIcon, FolderOpenIcon, CheckCircleIcon, XCircleIcon, LoaderIcon } from 'lucide-react';
interface ImportSheetProps {
onSuccess: () => void;
}
export function ImportSheet({ onSuccess }: ImportSheetProps) {
const [open, setOpen] = useState(false);
const [directory, setDirectory] = useState('');
const [recursive, setRecursive] = useState(true);
const [isImporting, setIsImporting] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const handleImport = async () => {
if (!directory.trim()) return;
setIsImporting(true);
setResult(null);
try {
const importData: ImportRequest = {
directory: directory.trim(),
recursive,
};
const importResult = await importApi.start(importData);
setResult(importResult);
if (importResult.success) {
onSuccess();
}
} catch (err) {
setResult({
success: false,
message: err instanceof Error ? err.message : 'Error al importar',
imported: 0,
errors: [err instanceof Error ? err.message : 'Error desconocido'],
});
} finally {
setIsImporting(false);
}
};
const handleClose = () => {
setOpen(false);
setDirectory('');
setResult(null);
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button>
<UploadIcon data-icon="inline-start" />
Importar Juegos
</Button>
</SheetTrigger>
<SheetContent className="sm:max-w-md">
<SheetHeader>
<SheetTitle>Importar Juegos</SheetTitle>
<SheetDescription>
Importa juegos desde archivos ROM en un directorio local.
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="directory">Directorio</Label>
<div className="flex gap-2">
<Input
id="directory"
value={directory}
onChange={(e) => setDirectory(e.target.value)}
placeholder="/path/to/roms"
disabled={isImporting}
/>
<Button
type="button"
variant="outline"
size="icon"
disabled={isImporting}
title="Seleccionar directorio"
>
<FolderOpenIcon className="size-4" />
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="recursive"
checked={recursive}
onCheckedChange={(checked) => setRecursive(checked === true)}
disabled={isImporting}
/>
<Label htmlFor="recursive" className="cursor-pointer">
Incluir subdirectorios
</Label>
</div>
{result && (
<div className="rounded-lg border border-border p-4">
<div className="flex items-center gap-2 mb-3">
{result.success ? (
<CheckCircleIcon className="size-5 text-emerald-500" />
) : (
<XCircleIcon className="size-5 text-destructive" />
)}
<span className="font-medium">
{result.success ? 'Importación completada' : 'Error en la importación'}
</span>
</div>
<p className="text-sm text-muted-foreground mb-2">{result.message}</p>
<p className="text-sm font-medium">ROMs importadas: {result.imported}</p>
{result.errors.length > 0 && (
<div className="mt-3">
<p className="text-sm font-medium mb-1">Errores:</p>
<ul className="text-sm text-destructive list-disc list-inside">
{result.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
</div>
)}
{isImporting && (
<div className="flex items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-4 animate-spin" />
<span>Importando ROMs...</span>
</div>
)}
<div className="flex gap-2 mt-auto">
<Button
variant="outline"
className="flex-1"
onClick={handleClose}
disabled={isImporting}
>
Cancelar
</Button>
<Button
className="flex-1"
onClick={handleImport}
disabled={isImporting || !directory.trim()}
>
{isImporting ? 'Importando...' : 'Importar'}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -42,32 +42,19 @@ const Navbar = () => {
{/* Navigation Links - Desktop */}
<div className="hidden md:flex items-center space-x-6">
<a
href="#"
href="/games"
className="text-white hover:text-glow-cyan transition-colors"
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
>
GAMES
</a>
<a
href="#"
href="/import"
className="text-white hover:text-glow-cyan transition-colors"
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
>
LIBRARY
IMPORT
</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 */}
@@ -123,32 +110,19 @@ const Navbar = () => {
{/* Navigation Links - Mobile */}
<div className="flex flex-col space-y-3">
<a
href="#"
href="/games"
className="text-white hover:text-glow-cyan transition-colors py-2"
tabIndex={isMenuOpen ? 0 : -1}
>
GAMES
</a>
<a
href="#"
href="/import"
className="text-white hover:text-glow-cyan transition-colors py-2"
tabIndex={isMenuOpen ? 0 : -1}
>
LIBRARY
IMPORT
</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>
)}

View File

@@ -0,0 +1,117 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

189
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,189 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
export interface Game {
id: string;
title: string;
slug: string;
description?: string;
releaseDate?: string;
genre?: string;
platform?: string;
year?: number;
cover?: string;
source: string;
sourceId?: string;
// Campos específicos de ROM (si source = "rom")
romPath?: string;
romFilename?: string;
romSize?: number;
romChecksum?: string;
romFormat?: string;
romHashes?: string;
// IDs de integraciones externas
igdbId?: number;
rawgId?: number;
thegamesdbId?: number;
// Metadatos adicionales
metadata?: string;
// Timestamps
addedAt?: string;
lastSeenAt?: string;
createdAt: string;
updatedAt: string;
// Relaciones
artworks?: any[];
purchases?: any[];
gamePlatforms?: any[];
tags?: any[];
}
export interface CreateGameInput {
title: string;
slug?: string;
description?: string;
releaseDate?: string;
genre?: string;
platform?: string;
year?: number;
cover?: string;
source?: string;
sourceId?: string;
platformId?: string;
priceCents?: number;
currency?: string;
store?: string;
date?: string;
condition?: 'Loose' | 'CIB' | 'New';
}
export interface UpdateGameInput {
title?: string;
slug?: string;
description?: string;
releaseDate?: string;
genre?: string;
platform?: string;
year?: number;
cover?: string;
source?: string;
sourceId?: string;
platformId?: string;
priceCents?: number;
currency?: string;
store?: string;
date?: string;
condition?: 'Loose' | 'CIB' | 'New';
}
export interface ImportRequest {
directory: string;
recursive?: boolean;
}
export interface ImportResult {
success: boolean;
message: string;
imported: number;
errors: string[];
}
export const gamesApi = {
getAll: async (): Promise<Game[]> => {
const response = await fetch(`${API_BASE}/games`);
if (!response.ok) {
throw new Error(`Error fetching games: ${response.statusText}`);
}
return response.json();
},
getById: async (id: string): Promise<Game> => {
const response = await fetch(`${API_BASE}/games/${id}`);
if (!response.ok) {
throw new Error(`Error fetching game: ${response.statusText}`);
}
return response.json();
},
getBySource: async (source: string): Promise<Game[]> => {
const response = await fetch(`${API_BASE}/games/source/${source}`);
if (!response.ok) {
throw new Error(`Error fetching games by source: ${response.statusText}`);
}
return response.json();
},
create: async (data: CreateGameInput): Promise<Game> => {
const response = await fetch(`${API_BASE}/games`, {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`Error creating game: ${response.statusText}`);
}
return response.json();
},
update: async (id: string, data: UpdateGameInput): Promise<Game> => {
const response = await fetch(`${API_BASE}/games/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`Error updating game: ${response.statusText}`);
}
return response.json();
},
delete: async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE}/games/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Error deleting game: ${response.statusText}`);
}
},
};
export const importApi = {
start: async (data: ImportRequest): Promise<ImportResult> => {
const response = await fetch(`${API_BASE}/import`, {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`Error starting import: ${response.statusText}`);
}
return response.json();
},
};
export const metadataApi = {
searchIGDB: async (query: string): Promise<any[]> => {
const response = await fetch(`${API_BASE}/metadata/igdb/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error(`Error searching IGDB: ${response.statusText}`);
}
return response.json();
},
searchRAWG: async (query: string): Promise<any[]> => {
const response = await fetch(`${API_BASE}/metadata/rawg/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error(`Error searching RAWG: ${response.statusText}`);
}
return response.json();
},
searchTheGamesDB: async (query: string): Promise<any[]> => {
const response = await fetch(
`${API_BASE}/metadata/thegamesdb/search?q=${encodeURIComponent(query)}`
);
if (!response.ok) {
throw new Error(`Error searching TheGamesDB: ${response.statusText}`);
}
return response.json();
},
};