feat: Refactor Games and Import pages with new Navbar and improved UI
Some checks failed
CI / lint (push) Failing after 11s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped

- Removed ImportSheet component from GamesPage and integrated Navbar for consistent navigation.
- Enhanced layout with a starfield background and responsive design elements.
- Updated GameTable and GameCard components for better game display.
- Added loading indicators and improved empty state messages.
- Refactored ImportPage to include Navbar and updated styling for import settings.
- Introduced SettingsPage for configuring ROM directory with a new glassmorphic design.
- Updated Navbar component to streamline navigation and improve mobile responsiveness.
- Added glassmorphic styles to enhance UI aesthetics.
This commit is contained in:
2026-03-21 17:34:58 +01:00
parent 9f5569a838
commit 5eaf320fc5
7 changed files with 706 additions and 505 deletions

View File

@@ -18,6 +18,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import Navbar from '@/components/landing/Navbar';
export default function GameDetailPage() {
const params = useParams();
@@ -79,180 +80,278 @@ export default function GameDetailPage() {
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 className="min-h-screen bg-background">
<div className="starfield" />
<Navbar />
<main className="relative z-10 pt-20">
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="w-16 h-16 border-4 border-[var(--neon-cyan)]/20 border-t-[var(--neon-cyan)] rounded-full animate-spin mx-auto mb-4" />
<p className="text-muted-foreground mono text-sm tracking-wider">
CARGANDO JUEGO...
</p>
</div>
</div>
</div>
</main>
</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 className="min-h-screen bg-background">
<div className="starfield" />
<Navbar />
<main className="relative z-10 pt-20">
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-[var(--neon-coral)] mono text-sm tracking-wider mb-4">
{error || 'JUEGO NO ENCONTRADO'}
</p>
<Button
onClick={() => router.push('/games')}
className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]"
>
<span className="mono text-xs tracking-wider">VOLVER A JUEGOS</span>
</Button>
</div>
</div>
</div>
</main>
</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 className="min-h-screen bg-background">
<div className="starfield" />
<Navbar />
<main className="relative z-10 pt-20">
<div className="container mx-auto px-4 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-responsive-3xl font-bold mb-2">
<span className="gradient-text">{game.title.toUpperCase()}</span>
</h1>
<p className="text-muted-foreground mono text-sm tracking-wider">{game.slug}</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={loadGame}
disabled={refreshing}
className="transition-all duration-300 hover:translate-y-[-2px]"
>
<RefreshCwIcon className={`size-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
className="transition-all duration-300 hover:translate-y-[-2px]"
>
<span className="mono text-xs tracking-wider">ELIMINAR JUEGO</span>
</Button>
</div>
</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>
{/* Game Info */}
<Card className="glassmorphic">
<CardHeader>
<CardTitle className="mono text-sm tracking-wider">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 mono">FECHA DE LANZAMIENTO:</span>{' '}
{formatDate(game.releaseDate)}
</span>
</div>
)}
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className="mono text-xs tracking-wider"
style={{
backgroundColor: 'var(--neon-purple)/20',
color: 'var(--neon-purple)',
}}
>
FUENTE: {game.source.toUpperCase()}
</Badge>
</div>
{game.genre && (
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="mono text-xs tracking-wider"
style={{ borderColor: 'var(--neon-cyan)', color: 'var(--neon-cyan)' }}
>
GÉNERO: {game.genre.toUpperCase()}
</Badge>
</div>
)}
{game.platform && (
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="mono text-xs tracking-wider"
style={{ borderColor: 'var(--neon-lime)', color: 'var(--neon-lime)' }}
>
PLATAFORMA: {game.platform.toUpperCase()}
</Badge>
</div>
)}
{game.year && (
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="mono text-xs tracking-wider"
style={{ borderColor: 'var(--neon-gold)', color: 'var(--neon-gold)' }}
>
AÑO: {game.year}
</Badge>
</div>
)}
{game.sourceId && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="mono text-xs tracking-wider">
ID DE FUENTE: {game.sourceId}
</Badge>
</div>
)}
{game.igdbId && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="mono text-xs tracking-wider">
IGDB ID: {game.igdbId}
</Badge>
</div>
)}
{game.rawgId && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="mono text-xs tracking-wider">
RAWG ID: {game.rawgId}
</Badge>
</div>
)}
{game.thegamesdbId && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="mono text-xs tracking-wider">
THEGAMESDB ID: {game.thegamesdbId}
</Badge>
</div>
)}
</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>
{game.description && (
<div className="mt-4">
<h3 className="font-semibold mb-2 mono text-sm tracking-wider">DESCRIPCIÓN</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{game.description}
</p>
</div>
)}
</CardContent>
</Card>
{/* ROM Info (if source = rom) */}
{game.source === 'rom' && (
<Card className="glassmorphic">
<CardHeader>
<CardTitle className="mono text-sm tracking-wider">
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 mono">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 mono">RUTA:</span> {game.romPath}
</span>
</div>
)}
{game.romSize && (
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="mono text-xs tracking-wider"
style={{ borderColor: 'var(--neon-coral)', color: 'var(--neon-coral)' }}
>
TAMAÑO: {formatFileSize(game.romSize)}
</Badge>
</div>
)}
{game.romFormat && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="mono text-xs tracking-wider">
FORMATO: {game.romFormat.toUpperCase()}
</Badge>
</div>
)}
{game.romChecksum && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="mono text-xs tracking-wider">
CHECKSUM: {game.romChecksum}
</Badge>
</div>
)}
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
</div>
{/* ROM Info (if source = rom) */}
{game.source === 'rom' && (
<Card>
<CardHeader>
<CardTitle>Información del Archivo ROM</CardTitle>
<CardDescription>Detalles del archivo importado</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{game.romFilename && (
<div className="flex items-center gap-2">
<FileIcon className="size-4 text-muted-foreground" />
<span className="text-sm">
<span className="text-muted-foreground">Archivo:</span> {game.romFilename}
</span>
</div>
)}
{game.romPath && (
<div className="flex items-center gap-2">
<FileIcon className="size-4 text-muted-foreground" />
<span className="text-sm">
<span className="text-muted-foreground">Ruta:</span> {game.romPath}
</span>
</div>
)}
{game.romSize && (
<div className="flex items-center gap-2">
<Badge variant="outline">Tamaño: {formatFileSize(game.romSize)}</Badge>
</div>
)}
{game.romFormat && (
<div className="flex items-center gap-2">
<Badge variant="outline">Formato: {game.romFormat}</Badge>
</div>
)}
{game.romChecksum && (
<div className="flex items-center gap-2">
<Badge variant="outline">Checksum: {game.romChecksum}</Badge>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
{/* Delete Game Confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar juego?</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas eliminar &ldquo;{game.title}&rdquo;? Esta acción no se
puede deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteGame}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Game Confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="mono text-sm tracking-wider">
¿ELIMINAR JUEGO?
</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas eliminar &ldquo;{game.title}&rdquo;? Esta acción no se
puede deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="mono text-xs tracking-wider">
CANCELAR
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteGame}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 mono text-xs tracking-wider transition-all duration-300 hover:translate-y-[-2px]"
>
ELIMINAR
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</main>
</div>
);
}

View File

@@ -6,7 +6,6 @@ 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 {
@@ -19,6 +18,8 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import Navbar from '@/components/landing/Navbar';
import { GameCard } from '@/components/games/GameCard';
export default function GamesPage() {
const router = useRouter();
@@ -96,124 +97,149 @@ export default function GamesPage() {
};
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>
<div className="min-h-screen bg-background">
{/* Starfield background */}
<div className="starfield" />
{/* Filters */}
<GameFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onClear={() => setSearchQuery('')}
/>
{/* Navbar */}
<Navbar />
{/* 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>
{/* Main Content */}
<main className="relative z-10 pt-20">
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-responsive-3xl font-bold mb-2">
<span className="gradient-text">GESTIÓN DE VIDEOJUEGOS</span>
</h1>
<p className="text-muted-foreground mono text-sm tracking-wider">
{filteredGames.length} {filteredGames.length === 1 ? 'JUEGO' : 'JUEGOS'} EN TU
BIBLIOTECA
</p>
</div>
<div className="flex gap-2">
<Button
onClick={handleCreate}
className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]"
>
<PlusIcon data-icon="inline-start" />
<span className="mono text-xs tracking-wider ml-2">NUEVO JUEGO</span>
</Button>
</div>
</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}
{/* 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')}
className="transition-all duration-300 hover:translate-y-[-2px]"
>
<TableIcon className="size-4" />
</Button>
<Button
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="icon"
onClick={() => setViewMode('grid')}
className="transition-all duration-300 hover:translate-y-[-2px]"
>
<LayoutGridIcon className="size-4" />
</Button>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="w-16 h-16 border-4 border-[var(--neon-cyan)]/20 border-t-[var(--neon-cyan)] rounded-full animate-spin mx-auto mb-4" />
<p className="text-muted-foreground mono text-sm tracking-wider">
CARGANDO JUEGOS...
</p>
</div>
</div>
) : viewMode === 'table' ? (
<GameTable
games={filteredGames}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
) : (
<div className="discovery-wall">
{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>
{/* Empty State */}
{!loading && filteredGames.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground mono text-sm tracking-wider mb-4">
{searchQuery
? 'NO SE ENCONTRARON JUEGOS QUE COINCIDAN CON TU BÚSQUEDA.'
: 'NO HAY JUEGOS EN TU BIBLIOTECA.'}
</p>
{!searchQuery && (
<Button
onClick={handleCreate}
className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]"
>
<PlusIcon data-icon="inline-start" />
<span className="mono text-xs tracking-wider ml-2">AGREGAR PRIMER JUEGO</span>
</Button>
)}
</div>
)}
</div>
)}
</div>
{/* Dialog */}
<GameDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
game={editingGame}
onSuccess={loadGames}
/>
{/* 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>
{/* Delete Confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar juego?</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas eliminar &ldquo;{gameToDelete?.title}&rdquo;? 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>
</main>
</div>
);
}

View File

@@ -157,6 +157,13 @@
border: 1px solid var(--glass-border);
}
.glassmorphic {
background: rgba(15, 15, 15, 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Glow effects */
.glow-cyan {
box-shadow: 0 0 20px var(--glow-cyan), 0 0 40px rgba(0, 240, 255, 0.2);

View File

@@ -16,6 +16,7 @@ import {
ArrowLeftIcon,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import Navbar from '@/components/landing/Navbar';
export default function ImportPage() {
const router = useRouter();
@@ -56,167 +57,188 @@ export default function ImportPage() {
};
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 className="min-h-screen bg-background">
<div className="starfield" />
<Navbar />
<main className="relative z-10 pt-20">
<div className="container mx-auto px-4 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()}
className="transition-all duration-300 hover:translate-y-[-2px]"
>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
<h1 className="text-responsive-3xl font-bold mb-2">
<span className="gradient-text">IMPORTAR JUEGOS</span>
</h1>
<p className="text-muted-foreground mono text-sm tracking-wider">
Importa juegos desde archivos ROM en un directorio local
</p>
</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>
{/* Import Form */}
<Card className="glassmorphic">
<CardHeader>
<CardTitle className="mono text-sm tracking-wider">
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" className="mono text-xs tracking-wider">
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>
{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 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>
)}
<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>
{/* 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>
</main>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import Link from 'next/link';
import { GameCover } from '@/components/landing/GameCover';
import { EmptyState } from '@/components/landing/EmptyState';
import { ArrowRight, Grid3X3 } from 'lucide-react';
import Navbar from '@/components/landing/Navbar';
export default function Home() {
const [games, setGames] = useState<Game[]>([]);
@@ -43,43 +44,11 @@ export default function Home() {
{/* Starfield background */}
<div className="starfield" />
{/* Header */}
<header className="glass sticky top-0 z-50 border-b border-border/50">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/" className="flex items-center gap-3">
<div className="relative">
<Gamepad2 className="w-8 h-8 text-[var(--neon-cyan)] icon-glow" />
</div>
<div>
<h1 className="text-responsive-xl font-bold gradient-text tracking-tight">QUASAR</h1>
<span className="text-xs text-muted-foreground mono tracking-wider">
GAME LIBRARY SYSTEM
</span>
</div>
</Link>
<div className="flex items-center gap-3">
<Link href="/games">
<Button
variant="ghost"
size="sm"
className="text-[var(--neon-cyan)] hover:bg-[var(--neon-cyan)]/10 hover:text-[var(--neon-cyan)]"
>
<Grid3X3 className="w-4 h-4 mr-2" />
<span className="mono text-xs tracking-wider">VER TODOS</span>
</Button>
</Link>
<Badge
variant="outline"
className="category-badge border-[var(--neon-purple)] text-[var(--neon-purple)] hover-glow"
>
v1.0.0
</Badge>
</div>
</div>
</header>
{/* Navbar */}
<Navbar />
{/* Main Content */}
<main className="relative z-10">
<main className="relative z-10 pt-20">
{isLoading ? (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">

View File

@@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import Navbar from '@/components/landing/Navbar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { FolderIcon, SaveIcon } from 'lucide-react';
export default function SettingsPage() {
const [romPath, setRomPath] = useState('');
const [saved, setSaved] = useState(false);
const handleSave = () => {
// Aquí se implementaría la lógica para guardar la configuración
console.log('Guardando path:', romPath);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
return (
<div className="min-h-screen bg-background">
{/* Starfield background */}
<div className="starfield" />
{/* Navbar */}
<Navbar />
{/* Main Content */}
<main className="relative z-10 pt-20">
<div className="container mx-auto px-4 py-8">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-responsive-3xl font-bold mb-2">
<span className="gradient-text">CONFIGURACIÓN</span>
</h1>
<p className="text-muted-foreground mono text-sm tracking-wider">
CONFIGURA EL DIRECTORIO DE TUS ROMS
</p>
</div>
{/* Settings Card */}
<Card className="glassmorphic-card border-white/10 bg-black/40 backdrop-blur-md">
<CardHeader>
<CardTitle className="mono text-lg tracking-wider flex items-center gap-2">
<FolderIcon className="size-5 text-[var(--neon-purple)]" />
DIRECTORIO DE ROMS
</CardTitle>
<CardDescription className="mono text-xs tracking-wider text-white/60">
ESPECIFICA LA RUTA DEL DIRECTORIO DONDE SE ALMACENAN TUS ARCHIVOS DE JUEGO
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Path Input */}
<div className="space-y-2">
<Label
htmlFor="rom-path"
className="mono text-xs tracking-wider text-white/80 uppercase"
>
Path del Directorio
</Label>
<Input
id="rom-path"
type="text"
placeholder="/path/to/roms"
value={romPath}
onChange={(e) => setRomPath(e.target.value)}
className="mono text-sm bg-black/60 border-white/20 text-white placeholder:text-white/40 focus:border-[var(--neon-purple)] focus:ring-[var(--neon-purple)]/20 transition-all duration-300"
/>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={!romPath.trim()}
className="btn-neon bg-[var(--neon-purple)] text-background hover:bg-[var(--neon-purple)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(192,132,252,0.4)] disabled:opacity-50 disabled:cursor-not-allowed disabled:translate-y-0 disabled:shadow-none"
>
<SaveIcon data-icon="inline-start" />
<span className="mono text-xs tracking-wider ml-2">
{saved ? 'GUARDADO' : 'GUARDAR'}
</span>
</Button>
</div>
{/* Info Box */}
<div className="mt-6 p-4 rounded-lg bg-white/5 border border-white/10">
<p className="mono text-xs text-white/60 leading-relaxed">
<span className="text-[var(--neon-cyan)]">NOTA:</span> El directorio debe ser
accesible desde el servidor backend. Asegúrate de que la ruta sea correcta y
tenga los permisos necesarios para leer los archivos.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</main>
</div>
);
}

View File

@@ -1,60 +1,45 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
const Navbar = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen);
};
return (
<nav className="fixed top-0 left-0 right-0 z-50 glass">
<nav className="fixed top-0 left-0 right-0 z-50 backdrop-blur-md bg-black/70 border-b border-white/10">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
{/* Logo */}
<div className="flex items-center space-x-2">
<Link href="/" className="flex items-center space-x-2">
<h1
className="text-2xl font-bold text-glow-cyan"
className="text-2xl font-bold text-glow-cyan hover:scale-105 transition-transform duration-200 cursor-pointer"
style={{ color: 'var(--mass-effect-cyan)' }}
>
QUASAR
</h1>
</div>
{/* Search Bar - Desktop */}
<div className="hidden md:flex flex-1 max-w-md mx-8">
<Input
type="text"
placeholder="SEARCH GAMES..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-glow bg-transparent border border-gray-600 text-white placeholder-gray-400"
style={{ borderColor: 'var(--mass-effect-cyan)' }}
aria-label="Campo de búsqueda de juegos"
/>
</div>
</Link>
{/* Navigation Links - Desktop */}
<div className="hidden md:flex items-center space-x-6">
<a
<div className="hidden md:flex items-center space-x-4">
<Link
href="/games"
className="text-white hover:text-glow-cyan transition-colors"
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
className="font-mono text-sm text-white/80 hover:text-cyan-400 transition-all duration-300 hover:translate-y-[-2px]"
style={{ textShadow: '0 0 10px rgba(34, 211, 238, 0.3)' }}
>
GAMES
</a>
<a
href="/import"
className="text-white hover:text-glow-cyan transition-colors"
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
</Link>
<Link
href="/settings"
className="font-mono text-sm text-white/80 hover:text-purple-400 transition-all duration-300 hover:translate-y-[-2px]"
style={{ textShadow: '0 0 10px rgba(192, 132, 252, 0.3)' }}
>
IMPORT
</a>
SETTINGS
</Link>
</div>
{/* Mobile Menu Button */}
@@ -94,35 +79,25 @@ const Navbar = () => {
{/* Mobile Menu */}
{isMenuOpen && (
<div className="md:hidden mt-4 glass rounded-lg p-4">
{/* Search Bar - Mobile */}
<div className="mb-4">
<Input
type="text"
placeholder="SEARCH GAMES..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-glow bg-transparent border border-gray-600 text-white placeholder-gray-400 w-full"
style={{ borderColor: 'var(--mass-effect-cyan)' }}
/>
</div>
<div className="md:hidden mt-4 backdrop-blur-md bg-black/70 border border-white/10 rounded-lg p-4">
{/* Navigation Links - Mobile */}
<div className="flex flex-col space-y-3">
<a
<Link
href="/games"
className="text-white hover:text-glow-cyan transition-colors py-2"
className="font-mono text-sm text-white/80 hover:text-cyan-400 transition-all duration-300 py-2"
tabIndex={isMenuOpen ? 0 : -1}
style={{ textShadow: '0 0 10px rgba(34, 211, 238, 0.3)' }}
>
GAMES
</a>
<a
href="/import"
className="text-white hover:text-glow-cyan transition-colors py-2"
</Link>
<Link
href="/settings"
className="font-mono text-sm text-white/80 hover:text-purple-400 transition-all duration-300 py-2"
tabIndex={isMenuOpen ? 0 : -1}
style={{ textShadow: '0 0 10px rgba(192, 132, 252, 0.3)' }}
>
IMPORT
</a>
SETTINGS
</Link>
</div>
</div>
)}