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, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import Navbar from '@/components/landing/Navbar';
export default function GameDetailPage() { export default function GameDetailPage() {
const params = useParams(); const params = useParams();
@@ -79,180 +80,278 @@ export default function GameDetailPage() {
if (loading) { if (loading) {
return ( return (
<div className="container mx-auto py-8"> <div className="min-h-screen bg-background">
<div className="flex items-center justify-center py-12"> <div className="starfield" />
<div className="text-muted-foreground">Cargando juego...</div> <Navbar />
</div> <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> </div>
); );
} }
if (error || !game) { if (error || !game) {
return ( return (
<div className="container mx-auto py-8"> <div className="min-h-screen bg-background">
<div className="flex items-center justify-center py-12"> <div className="starfield" />
<div className="text-destructive">{error || 'Juego no encontrado'}</div> <Navbar />
</div> <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> </div>
); );
} }
return ( return (
<div className="container mx-auto py-8"> <div className="min-h-screen bg-background">
<div className="flex flex-col gap-6"> <div className="starfield" />
{/* Header */} <Navbar />
<div className="flex items-center justify-between"> <main className="relative z-10 pt-20">
<div className="flex items-center gap-4"> <div className="container mx-auto px-4 py-8">
<Button variant="ghost" size="icon" onClick={() => router.back()}> <div className="flex flex-col gap-6">
<ArrowLeftIcon className="size-4" /> {/* Header */}
</Button> <div className="flex items-center justify-between">
<div> <div className="flex items-center gap-4">
<h1 className="text-3xl font-bold">{game.title}</h1> <Button variant="ghost" size="icon" onClick={() => router.back()}>
<p className="text-muted-foreground">{game.slug}</p> <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>
<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 */} {/* Game Info */}
<Card> <Card className="glassmorphic">
<CardHeader> <CardHeader>
<CardTitle>Información del Juego</CardTitle> <CardTitle className="mono text-sm tracking-wider">INFORMACIÓN DEL JUEGO</CardTitle>
<CardDescription>Detalles y metadatos</CardDescription> <CardDescription>Detalles y metadatos</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{formatDate(game.releaseDate) && ( {formatDate(game.releaseDate) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" /> <CalendarIcon className="size-4 text-muted-foreground" />
<span className="text-sm"> <span className="text-sm">
<span className="text-muted-foreground">Fecha de lanzamiento:</span>{' '} <span className="text-muted-foreground mono">FECHA DE LANZAMIENTO:</span>{' '}
{formatDate(game.releaseDate)} {formatDate(game.releaseDate)}
</span> </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>
)} {game.description && (
<div className="flex items-center gap-2"> <div className="mt-4">
<Badge variant="secondary">Fuente: {game.source}</Badge> <h3 className="font-semibold mb-2 mono text-sm tracking-wider">DESCRIPCIÓN</h3>
</div> <p className="text-sm text-muted-foreground leading-relaxed">
{game.genre && ( {game.description}
<div className="flex items-center gap-2"> </p>
<Badge variant="outline">Género: {game.genre}</Badge> </div>
</div> )}
)} </CardContent>
{game.platform && ( </Card>
<div className="flex items-center gap-2">
<Badge variant="outline">Plataforma: {game.platform}</Badge> {/* ROM Info (if source = rom) */}
</div> {game.source === 'rom' && (
)} <Card className="glassmorphic">
{game.year && ( <CardHeader>
<div className="flex items-center gap-2"> <CardTitle className="mono text-sm tracking-wider">
<Badge variant="outline">Año: {game.year}</Badge> INFORMACIÓN DEL ARCHIVO ROM
</div> </CardTitle>
)} <CardDescription>Detalles del archivo importado</CardDescription>
{game.sourceId && ( </CardHeader>
<div className="flex items-center gap-2"> <CardContent>
<Badge variant="outline">ID de fuente: {game.sourceId}</Badge> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
</div> {game.romFilename && (
)} <div className="flex items-center gap-2">
{game.igdbId && ( <FileIcon className="size-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <span className="text-sm">
<Badge variant="outline">IGDB ID: {game.igdbId}</Badge> <span className="text-muted-foreground mono">ARCHIVO:</span>{' '}
</div> {game.romFilename}
)} </span>
{game.rawgId && ( </div>
<div className="flex items-center gap-2"> )}
<Badge variant="outline">RAWG ID: {game.rawgId}</Badge> {game.romPath && (
</div> <div className="flex items-center gap-2">
)} <FileIcon className="size-4 text-muted-foreground" />
{game.thegamesdbId && ( <span className="text-sm">
<div className="flex items-center gap-2"> <span className="text-muted-foreground mono">RUTA:</span> {game.romPath}
<Badge variant="outline">TheGamesDB ID: {game.thegamesdbId}</Badge> </span>
</div> </div>
)} )}
</div> {game.romSize && (
{game.description && ( <div className="flex items-center gap-2">
<div className="mt-4"> <Badge
<h3 className="font-semibold mb-2">Descripción</h3> variant="outline"
<p className="text-sm text-muted-foreground">{game.description}</p> className="mono text-xs tracking-wider"
</div> 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> </div>
</Card>
{/* ROM Info (if source = rom) */} {/* Delete Game Confirmation */}
{game.source === 'rom' && ( <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<Card> <AlertDialogContent>
<CardHeader> <AlertDialogHeader>
<CardTitle>Información del Archivo ROM</CardTitle> <AlertDialogTitle className="mono text-sm tracking-wider">
<CardDescription>Detalles del archivo importado</CardDescription> ¿ELIMINAR JUEGO?
</CardHeader> </AlertDialogTitle>
<CardContent> <AlertDialogDescription>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> ¿Estás seguro de que deseas eliminar &ldquo;{game.title}&rdquo;? Esta acción no se
{game.romFilename && ( puede deshacer.
<div className="flex items-center gap-2"> </AlertDialogDescription>
<FileIcon className="size-4 text-muted-foreground" /> </AlertDialogHeader>
<span className="text-sm"> <AlertDialogFooter>
<span className="text-muted-foreground">Archivo:</span> {game.romFilename} <AlertDialogCancel className="mono text-xs tracking-wider">
</span> CANCELAR
</div> </AlertDialogCancel>
)} <AlertDialogAction
{game.romPath && ( onClick={handleDeleteGame}
<div className="flex items-center gap-2"> className="bg-destructive text-destructive-foreground hover:bg-destructive/90 mono text-xs tracking-wider transition-all duration-300 hover:translate-y-[-2px]"
<FileIcon className="size-4 text-muted-foreground" /> >
<span className="text-sm"> ELIMINAR
<span className="text-muted-foreground">Ruta:</span> {game.romPath} </AlertDialogAction>
</span> </AlertDialogFooter>
</div> </AlertDialogContent>
)} </AlertDialog>
{game.romSize && ( </div>
<div className="flex items-center gap-2"> </main>
<Badge variant="outline">Tamaño: {formatFileSize(game.romSize)}</Badge>
</div>
)}
{game.romFormat && (
<div className="flex items-center gap-2">
<Badge variant="outline">Formato: {game.romFormat}</Badge>
</div>
)}
{game.romChecksum && (
<div className="flex items-center gap-2">
<Badge variant="outline">Checksum: {game.romChecksum}</Badge>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
{/* Delete Game Confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar juego?</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas eliminar &ldquo;{game.title}&rdquo;? Esta acción no se
puede deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteGame}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }

View File

@@ -6,7 +6,6 @@ import { Game, gamesApi } from '@/lib/api';
import { GameTable } from '@/components/games/GameTable'; import { GameTable } from '@/components/games/GameTable';
import { GameDialog } from '@/components/games/GameDialog'; import { GameDialog } from '@/components/games/GameDialog';
import { GameFilters } from '@/components/games/GameFilters'; import { GameFilters } from '@/components/games/GameFilters';
import { ImportSheet } from '@/components/games/ImportSheet';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PlusIcon, LayoutGridIcon, TableIcon } from 'lucide-react'; import { PlusIcon, LayoutGridIcon, TableIcon } from 'lucide-react';
import { import {
@@ -19,6 +18,8 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import Navbar from '@/components/landing/Navbar';
import { GameCard } from '@/components/games/GameCard';
export default function GamesPage() { export default function GamesPage() {
const router = useRouter(); const router = useRouter();
@@ -96,124 +97,149 @@ export default function GamesPage() {
}; };
return ( return (
<div className="container mx-auto py-8"> <div className="min-h-screen bg-background">
<div className="flex flex-col gap-6"> {/* Starfield background */}
{/* Header */} <div className="starfield" />
<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 */} {/* Navbar */}
<GameFilters <Navbar />
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onClear={() => setSearchQuery('')}
/>
{/* View Mode Toggle */} {/* Main Content */}
<div className="flex gap-2 self-end"> <main className="relative z-10 pt-20">
<Button <div className="container mx-auto px-4 py-8">
variant={viewMode === 'table' ? 'default' : 'outline'} <div className="flex flex-col gap-6">
size="icon" {/* Header */}
onClick={() => setViewMode('table')} <div className="flex items-center justify-between">
> <div>
<TableIcon className="size-4" /> <h1 className="text-responsive-3xl font-bold mb-2">
</Button> <span className="gradient-text">GESTIÓN DE VIDEOJUEGOS</span>
<Button </h1>
variant={viewMode === 'grid' ? 'default' : 'outline'} <p className="text-muted-foreground mono text-sm tracking-wider">
size="icon" {filteredGames.length} {filteredGames.length === 1 ? 'JUEGO' : 'JUEGOS'} EN TU
onClick={() => setViewMode('grid')} BIBLIOTECA
> </p>
<LayoutGridIcon className="size-4" /> </div>
</Button> <div className="flex gap-2">
</div> <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 */} {/* Filters */}
{loading ? ( <GameFilters
<div className="flex items-center justify-center py-12"> searchQuery={searchQuery}
<div className="text-muted-foreground">Cargando juegos...</div> onSearchChange={setSearchQuery}
</div> onClear={() => setSearchQuery('')}
) : viewMode === 'table' ? ( />
<GameTable
games={filteredGames} {/* View Mode Toggle */}
onView={handleView} <div className="flex gap-2 self-end">
onEdit={handleEdit} <Button
onDelete={handleDelete} variant={viewMode === 'table' ? 'default' : 'outline'}
/> size="icon"
) : ( onClick={() => setViewMode('table')}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> className="transition-all duration-300 hover:translate-y-[-2px]"
{filteredGames.map((game) => ( >
<GameCard <TableIcon className="size-4" />
key={game.id} </Button>
game={game} <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} onView={handleView}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} 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 */} {/* Empty State */}
{!loading && filteredGames.length === 0 && ( {!loading && filteredGames.length === 0 && (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mono text-sm tracking-wider mb-4">
{searchQuery {searchQuery
? 'No se encontraron juegos que coincidan con tu búsqueda.' ? 'NO SE ENCONTRARON JUEGOS QUE COINCIDAN CON TU BÚSQUEDA.'
: 'No hay juegos en tu biblioteca.'} : 'NO HAY JUEGOS EN TU BIBLIOTECA.'}
</p> </p>
{!searchQuery && ( {!searchQuery && (
<Button onClick={handleCreate}> <Button
<PlusIcon data-icon="inline-start" /> onClick={handleCreate}
Agregar primer juego 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)]"
</Button> >
<PlusIcon data-icon="inline-start" />
<span className="mono text-xs tracking-wider ml-2">AGREGAR PRIMER JUEGO</span>
</Button>
)}
</div>
)} )}
</div> </div>
)}
</div>
{/* Dialog */} {/* Dialog */}
<GameDialog <GameDialog
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
game={editingGame} game={editingGame}
onSuccess={loadGames} onSuccess={loadGames}
/> />
{/* Delete Confirmation */} {/* Delete Confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>¿Eliminar juego?</AlertDialogTitle> <AlertDialogTitle>¿Eliminar juego?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
¿Estás seguro de que deseas eliminar "{gameToDelete?.title}"? Esta acción no se puede ¿Estás seguro de que deseas eliminar &ldquo;{gameToDelete?.title}&rdquo;? Esta
deshacer. acción no se puede deshacer.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={confirmDelete} onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
Eliminar Eliminar
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div>
</main>
</div> </div>
); );
} }

View File

@@ -157,6 +157,13 @@
border: 1px solid var(--glass-border); 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 effects */
.glow-cyan { .glow-cyan {
box-shadow: 0 0 20px var(--glow-cyan), 0 0 40px rgba(0, 240, 255, 0.2); 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, ArrowLeftIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Navbar from '@/components/landing/Navbar';
export default function ImportPage() { export default function ImportPage() {
const router = useRouter(); const router = useRouter();
@@ -56,167 +57,188 @@ export default function ImportPage() {
}; };
return ( return (
<div className="container mx-auto py-8"> <div className="min-h-screen bg-background">
<div className="max-w-2xl mx-auto"> <div className="starfield" />
{/* Header */} <Navbar />
<div className="flex items-center gap-4 mb-6"> <main className="relative z-10 pt-20">
<Button variant="ghost" size="icon" onClick={() => router.back()}> <div className="container mx-auto px-4 py-8">
<ArrowLeftIcon className="size-4" /> <div className="max-w-2xl mx-auto">
</Button> {/* Header */}
<div> <div className="flex items-center gap-4 mb-6">
<h1 className="text-3xl font-bold">Importar Juegos</h1> <Button
<p className="text-muted-foreground"> variant="ghost"
Importa juegos desde archivos ROM en un directorio local size="icon"
</p> onClick={() => router.back()}
</div> className="transition-all duration-300 hover:translate-y-[-2px]"
</div> >
<ArrowLeftIcon className="size-4" />
{/* Import Form */} </Button>
<Card> <div>
<CardHeader> <h1 className="text-responsive-3xl font-bold mb-2">
<CardTitle>Configuración de Importación</CardTitle> <span className="gradient-text">IMPORTAR JUEGOS</span>
<CardDescription> </h1>
Especifica el directorio que contiene los archivos ROM que deseas importar <p className="text-muted-foreground mono text-sm tracking-wider">
</CardDescription> Importa juegos desde archivos ROM en un directorio local
</CardHeader> </p>
<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> </div>
<p className="text-xs text-muted-foreground">
Ruta absoluta del directorio que contiene las ROMs
</p>
</div> </div>
<div className="flex items-center gap-2"> {/* Import Form */}
<Checkbox <Card className="glassmorphic">
id="recursive" <CardHeader>
checked={recursive} <CardTitle className="mono text-sm tracking-wider">
onCheckedChange={(checked) => setRecursive(checked === true)} CONFIGURACIÓN DE IMPORTACIÓN
disabled={isImporting} </CardTitle>
/> <CardDescription>
<Label htmlFor="recursive" className="cursor-pointer"> Especifica el directorio que contiene los archivos ROM que deseas importar
Incluir subdirectorios recursivamente </CardDescription>
</Label> </CardHeader>
</div> <CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex gap-2 mt-4"> <Label htmlFor="directory" className="mono text-xs tracking-wider">
<Button DIRECTORIO *
variant="outline" </Label>
className="flex-1" <div className="flex gap-2">
onClick={handleReset} <Input
disabled={isImporting || !directory} id="directory"
> value={directory}
Limpiar onChange={(e) => setDirectory(e.target.value)}
</Button> placeholder="/path/to/roms"
<Button disabled={isImporting}
className="flex-1" />
onClick={handleImport} <Button
disabled={isImporting || !directory.trim()} type="button"
> variant="outline"
{isImporting ? ( size="icon"
<> disabled={isImporting}
<LoaderIcon className="size-4 animate-spin mr-2" /> title="Seleccionar directorio"
Importando... >
</> <FolderOpenIcon className="size-4" />
) : ( </Button>
<> </div>
<UploadIcon data-icon="inline-start" /> <p className="text-xs text-muted-foreground">
Importar Ruta absoluta del directorio que contiene las ROMs
</> </p>
)}
</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> </div>
{result.errors.length > 0 && ( <div className="flex items-center gap-2">
<div> <Checkbox
<p className="text-sm font-medium mb-2">Errores encontrados</p> id="recursive"
<div className="bg-destructive/10 rounded-lg p-3 max-h-60 overflow-y-auto"> checked={recursive}
<ul className="text-sm text-destructive space-y-1"> onCheckedChange={(checked) => setRecursive(checked === true)}
{result.errors.map((error, index) => ( disabled={isImporting}
<li key={index} className="flex items-start gap-2"> />
<span className="text-destructive-foreground"></span> <Label htmlFor="recursive" className="cursor-pointer">
<span>{error}</span> Incluir subdirectorios recursivamente
</li> </Label>
))} </div>
</ul>
<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>
</div> </div>
)} </CardContent>
</Card>
)}
<div className="flex gap-2 pt-2"> {/* Info */}
<Button variant="outline" onClick={handleReset} className="flex-1"> <Card className="mt-6 bg-muted/50">
Nueva Importación <CardHeader>
</Button> <CardTitle className="text-base">Información</CardTitle>
{result.success && ( </CardHeader>
<Button onClick={() => router.push('/games')} className="flex-1"> <CardContent>
Ver Juegos <ul className="text-sm text-muted-foreground space-y-1">
</Button> <li>
)} El sistema escaneará el directorio especificado en busca de archivos ROM
</div> </li>
</div> <li> Se calcularán los checksums (CRC32, MD5, SHA1) para cada archivo</li>
</CardContent> <li>
</Card> Las ROMs se asociarán automáticamente con juegos existentes si coinciden
)} </li>
<li> Los archivos duplicados se detectarán y omitirán</li>
{/* Info */} <li> La importación puede tardar dependiendo de la cantidad de archivos</li>
<Card className="mt-6 bg-muted/50"> </ul>
<CardHeader> </CardContent>
<CardTitle className="text-base">Información</CardTitle> </Card>
</CardHeader> </div>
<CardContent> </div>
<ul className="text-sm text-muted-foreground space-y-1"> </main>
<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> </div>
); );
} }

View File

@@ -10,6 +10,7 @@ import Link from 'next/link';
import { GameCover } from '@/components/landing/GameCover'; import { GameCover } from '@/components/landing/GameCover';
import { EmptyState } from '@/components/landing/EmptyState'; import { EmptyState } from '@/components/landing/EmptyState';
import { ArrowRight, Grid3X3 } from 'lucide-react'; import { ArrowRight, Grid3X3 } from 'lucide-react';
import Navbar from '@/components/landing/Navbar';
export default function Home() { export default function Home() {
const [games, setGames] = useState<Game[]>([]); const [games, setGames] = useState<Game[]>([]);
@@ -43,43 +44,11 @@ export default function Home() {
{/* Starfield background */} {/* Starfield background */}
<div className="starfield" /> <div className="starfield" />
{/* Header */} {/* Navbar */}
<header className="glass sticky top-0 z-50 border-b border-border/50"> <Navbar />
<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>
{/* Main Content */} {/* Main Content */}
<main className="relative z-10"> <main className="relative z-10 pt-20">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center"> <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'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
const Navbar = () => { const Navbar = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const toggleMenu = () => { const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen); setIsMenuOpen(!isMenuOpen);
}; };
return ( 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="container mx-auto px-4 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Logo */} {/* Logo */}
<div className="flex items-center space-x-2"> <Link href="/" className="flex items-center space-x-2">
<h1 <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)' }} style={{ color: 'var(--mass-effect-cyan)' }}
> >
QUASAR QUASAR
</h1> </h1>
</div> </Link>
{/* 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>
{/* Navigation Links - Desktop */} {/* Navigation Links - Desktop */}
<div className="hidden md:flex items-center space-x-6"> <div className="hidden md:flex items-center space-x-4">
<a <Link
href="/games" href="/games"
className="text-white hover:text-glow-cyan transition-colors" className="font-mono text-sm text-white/80 hover:text-cyan-400 transition-all duration-300 hover:translate-y-[-2px]"
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }} style={{ textShadow: '0 0 10px rgba(34, 211, 238, 0.3)' }}
> >
GAMES GAMES
</a> </Link>
<a <Link
href="/import" href="/settings"
className="text-white hover:text-glow-cyan transition-colors" className="font-mono text-sm text-white/80 hover:text-purple-400 transition-all duration-300 hover:translate-y-[-2px]"
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }} style={{ textShadow: '0 0 10px rgba(192, 132, 252, 0.3)' }}
> >
IMPORT SETTINGS
</a> </Link>
</div> </div>
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
@@ -94,35 +79,25 @@ const Navbar = () => {
{/* Mobile Menu */} {/* Mobile Menu */}
{isMenuOpen && ( {isMenuOpen && (
<div className="md:hidden mt-4 glass rounded-lg p-4"> <div className="md:hidden mt-4 backdrop-blur-md bg-black/70 border border-white/10 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>
{/* Navigation Links - Mobile */} {/* Navigation Links - Mobile */}
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
<a <Link
href="/games" 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} tabIndex={isMenuOpen ? 0 : -1}
style={{ textShadow: '0 0 10px rgba(34, 211, 238, 0.3)' }}
> >
GAMES GAMES
</a> </Link>
<a <Link
href="/import" href="/settings"
className="text-white hover:text-glow-cyan transition-colors py-2" className="font-mono text-sm text-white/80 hover:text-purple-400 transition-all duration-300 py-2"
tabIndex={isMenuOpen ? 0 : -1} tabIndex={isMenuOpen ? 0 : -1}
style={{ textShadow: '0 0 10px rgba(192, 132, 252, 0.3)' }}
> >
IMPORT SETTINGS
</a> </Link>
</div> </div>
</div> </div>
)} )}