feat: add UI components for alert dialog, badge, checkbox, dialog, label, select, sheet, table, textarea
- 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:
85
frontend/src/components/games/GameCard.tsx
Normal file
85
frontend/src/components/games/GameCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
frontend/src/components/games/GameDialog.tsx
Normal file
272
frontend/src/components/games/GameDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/games/GameFilters.tsx
Normal file
37
frontend/src/components/games/GameFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
frontend/src/components/games/GameTable.tsx
Normal file
128
frontend/src/components/games/GameTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
frontend/src/components/games/ImportSheet.tsx
Normal file
172
frontend/src/components/games/ImportSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user