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