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:
258
frontend/src/app/games/[id]/page.tsx
Normal file
258
frontend/src/app/games/[id]/page.tsx
Normal 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 “{game.title}”? 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>
|
||||
);
|
||||
}
|
||||
219
frontend/src/app/games/page.tsx
Normal file
219
frontend/src/app/games/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
frontend/src/app/import/page.tsx
Normal file
222
frontend/src/app/import/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
117
frontend/src/components/ui/alert-dialog.tsx
Normal file
117
frontend/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
};
|
||||
48
frontend/src/components/ui/badge.tsx
Normal file
48
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
32
frontend/src/components/ui/checkbox.tsx
Normal file
32
frontend/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
158
frontend/src/components/ui/dialog.tsx
Normal file
158
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal 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 }
|
||||
190
frontend/src/components/ui/select.tsx
Normal file
190
frontend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
143
frontend/src/components/ui/sheet.tsx
Normal file
143
frontend/src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
116
frontend/src/components/ui/table.tsx
Normal file
116
frontend/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal 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
189
frontend/src/lib/api.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user