feat: update server port to 3003 and enhance logging; refactor frontend styles and components for improved UI/UX
Some checks failed
CI / lint (push) Failing after 15s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped

- Changed server port from 3000 to 3003 in backend.
- Updated logging message for server startup.
- Refactored global CSS styles for a neon theme with new color variables.
- Introduced responsive typography and layout adjustments in frontend.
- Added new components: EmptyState and GameCover for better game display.
- Implemented loading states and error handling in the Home page.
- Updated API base URL to match new server port.
This commit is contained in:
2026-03-19 19:54:08 +01:00
parent 3096a9b472
commit 9f5569a838
7 changed files with 726 additions and 274 deletions

View File

@@ -3,14 +3,15 @@ import { buildApp } from './app';
dotenv.config();
const port = Number(process.env.PORT ?? 3000);
const port = Number(process.env.PORT ?? 3003);
const app = buildApp();
const start = async () => {
const host = '0.0.0.0';
try {
await app.listen({ port, host: '0.0.0.0' });
// eslint-disable-next-line no-console
console.log(`Server listening on http://0.0.0.0:${port}`);
await app.listen({ port, host });
console.log(`🚀 Server ready and listening on http://${host}:${port}`);
} catch (err) {
app.log.error(err);
process.exit(1);

View File

@@ -7,8 +7,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-display: var(--font-display);
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -48,123 +48,242 @@
}
:root {
--radius: 0.625rem;
--background: #0a0a12;
--foreground: oklch(0.985 0 0);
--card: oklch(0.11 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.11 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: #00d0e0;
--primary-foreground: #0a0a12;
--secondary: oklch(0.18 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.18 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: #f0c040;
--accent-foreground: #0a0a12;
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
/* Rich dark backgrounds */
--background: #0a0a0a;
--foreground: #e5e5e5;
--card: #0f0f0f;
--card-foreground: #e5e5e5;
--popover: #0f0f0f;
--popover-foreground: #e5e5e5;
/* Neon accents */
--primary: #00f0ff;
--primary-foreground: #0a0a0a;
--secondary: #151515;
--secondary-foreground: #e5e5e5;
--muted: #151515;
--muted-foreground: #737373;
--accent: #b026ff;
--accent-foreground: #e5e5e5;
--destructive: #ff6b6b;
--border: #262626;
--input: #262626;
--ring: #00f0ff;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.11 0 0);
--sidebar-foreground: oklch(0.985 0 0);
/* Chart colors */
--chart-1: #00f0ff;
--chart-2: #b026ff;
--chart-3: #39ff14;
--chart-4: #ffd700;
--chart-5: #ff6b6b;
/* Sidebar */
--sidebar: #0f0f0f;
--sidebar-foreground: #e5e5e5;
--sidebar-primary: #00f0ff;
--sidebar-primary-foreground: #0a0a12;
--sidebar-accent: oklch(0.18 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-primary-foreground: #0a0a0a;
--sidebar-accent: #151515;
--sidebar-accent-foreground: #e5e5e5;
--sidebar-border: #262626;
--sidebar-ring: #00f0ff;
/* Radius */
--radius: 0.5rem;
}
.dark {
--background: #0a0a12;
--foreground: oklch(0.985 0 0);
--card: oklch(0.11 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.11 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: #00d0e0;
--primary-foreground: #0a0a12;
--secondary: oklch(0.18 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.18 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: #f0c040;
--accent-foreground: #0a0a12;
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--background: #0a0a0a;
--foreground: #e5e5e5;
--card: #0f0f0f;
--card-foreground: #e5e5e5;
--popover: #0f0f0f;
--popover-foreground: #e5e5e5;
--primary: #00f0ff;
--primary-foreground: #0a0a0a;
--secondary: #151515;
--secondary-foreground: #e5e5e5;
--muted: #151515;
--muted-foreground: #737373;
--accent: #b026ff;
--accent-foreground: #e5e5e5;
--destructive: #ff6b6b;
--border: #262626;
--input: #262626;
--ring: #00f0ff;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.11 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--chart-1: #00f0ff;
--chart-2: #b026ff;
--chart-3: #39ff14;
--chart-4: #ffd700;
--chart-5: #ff6b6b;
--sidebar: #0f0f0f;
--sidebar-foreground: #e5e5e5;
--sidebar-primary: #00f0ff;
--sidebar-primary-foreground: #0a0a12;
--sidebar-accent: oklch(0.18 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-primary-foreground: #0a0a0a;
--sidebar-accent: #151515;
--sidebar-accent-foreground: #e5e5e5;
--sidebar-border: #262626;
--sidebar-ring: #00f0ff;
}
/* Mass Effect-inspired theme customizations */
/* Custom neon accent colors */
:root {
/* Custom colors for Mass Effect theme */
--mass-effect-dark: #0a0a12;
--mass-effect-cyan: #00d0e0;
--mass-effect-gold: #f0c040;
--mass-effect-cyan-glow: rgba(0, 208, 224, 0.5);
--mass-effect-gold-glow: rgba(240, 192, 64, 0.5);
--glass-bg: rgba(10, 10, 18, 0.7);
--glass-border: rgba(0, 208, 224, 0.2);
--neon-cyan: #00f0ff;
--neon-purple: #b026ff;
--neon-lime: #39ff14;
--neon-gold: #ffd700;
--neon-coral: #ff6b6b;
/* Glow effects */
--glow-cyan: rgba(0, 240, 255, 0.4);
--glow-purple: rgba(176, 38, 255, 0.4);
--glow-lime: rgba(57, 255, 20, 0.4);
--glow-gold: rgba(255, 215, 0, 0.4);
--glow-coral: rgba(255, 107, 107, 0.4);
/* Transitions */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
/* Glassmorphism */
--glass-bg: rgba(15, 15, 15, 0.8);
--glass-border: rgba(255, 255, 255, 0.1);
}
/* Glassmorphism effect */
.glass {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
}
/* Glow effects */
.glow-cyan {
box-shadow: 0 0 10px var(--mass-effect-cyan-glow);
box-shadow: 0 0 20px var(--glow-cyan), 0 0 40px rgba(0, 240, 255, 0.2);
}
.glow-cyan-intense {
box-shadow: 0 0 20px var(--mass-effect-cyan-glow), 0 0 40px var(--mass-effect-cyan);
.glow-purple {
box-shadow: 0 0 20px var(--glow-purple), 0 0 40px rgba(176, 38, 255, 0.2);
}
.glow-lime {
box-shadow: 0 0 20px var(--glow-lime), 0 0 40px rgba(57, 255, 20, 0.2);
}
.glow-gold {
box-shadow: 0 0 10px var(--mass-effect-gold-glow);
box-shadow: 0 0 20px var(--glow-gold), 0 0 40px rgba(255, 215, 0, 0.2);
}
/* Text effects */
.glow-coral {
box-shadow: 0 0 20px var(--glow-coral), 0 0 40px rgba(255, 107, 107, 0.2);
}
/* Text glow effects */
.text-glow-cyan {
text-shadow: 0 0 10px var(--mass-effect-cyan-glow);
text-shadow: 0 0 10px var(--glow-cyan), 0 0 20px rgba(0, 240, 255, 0.3);
}
.text-glow-purple {
text-shadow: 0 0 10px var(--glow-purple), 0 0 20px rgba(176, 38, 255, 0.3);
}
.text-glow-lime {
text-shadow: 0 0 10px var(--glow-lime), 0 0 20px rgba(57, 255, 20, 0.3);
}
.text-glow-gold {
text-shadow: 0 0 10px var(--mass-effect-gold-glow);
text-shadow: 0 0 10px var(--glow-gold), 0 0 20px rgba(255, 215, 0, 0.3);
}
/* Holographic effect */
.holographic {
position: relative;
overflow: hidden;
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, var(--neon-cyan), var(--neon-purple));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.holographic::before {
.gradient-text-gold {
background: linear-gradient(135deg, var(--neon-gold), var(--neon-coral));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Card hover effects */
.card-hover {
transition: transform var(--transition-fast), box-shadow var(--transition-normal);
}
.card-hover:hover {
transform: translateY(-4px) scale(1.01);
}
/* Thick bottom border for cards */
.card-border-cyan {
border-bottom: 3px solid var(--neon-cyan);
}
.card-border-purple {
border-bottom: 3px solid var(--neon-purple);
}
.card-border-lime {
border-bottom: 3px solid var(--neon-lime);
}
.card-border-gold {
border-bottom: 3px solid var(--neon-gold);
}
.card-border-coral {
border-bottom: 3px solid var(--neon-coral);
}
/* Category badges */
.category-badge {
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
/* Staggered animation */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.5s ease forwards;
}
/* Pulse animation for system status */
@keyframes pulse-glow {
0%, 100% {
opacity: 1;
box-shadow: 0 0 10px var(--glow-cyan);
}
50% {
opacity: 0.7;
box-shadow: 0 0 20px var(--glow-cyan), 0 0 30px rgba(0, 240, 255, 0.4);
}
}
.pulse-glow {
animation: pulse-glow 2s infinite;
}
/* Scanline effect */
.scanline::before {
content: "";
position: absolute;
top: 0;
@@ -174,13 +293,13 @@
background: linear-gradient(
90deg,
transparent,
rgba(0, 240, 255, 0.2),
rgba(0, 240, 255, 0.1),
transparent
);
animation: holographic-scan 3s infinite;
animation: scanline 3s infinite;
}
@keyframes holographic-scan {
@keyframes scanline {
0% {
left: -100%;
}
@@ -189,27 +308,6 @@
}
}
/* Pulse animation for system status */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.pulse {
animation: pulse 2s infinite;
}
/* Hover glow effect */
.hover-glow:hover {
box-shadow: 0 0 15px var(--mass-effect-cyan-glow);
transform: translateY(-2px);
transition: all 0.3s ease;
}
/* Starfield background */
.starfield {
position: fixed;
@@ -219,14 +317,14 @@
height: 100%;
z-index: -1;
background-image:
radial-gradient(2px 2px at 20px 30px, #eee, transparent),
radial-gradient(2px 2px at 40px 70px, #eee, transparent),
radial-gradient(1px 1px at 50px 50px, #eee, transparent),
radial-gradient(1px 1px at 80px 10px, #eee, transparent),
radial-gradient(2px 2px at 130px 80px, #eee, transparent);
radial-gradient(1px 1px at 20px 30px, rgba(255,255,255,0.3), transparent),
radial-gradient(1px 1px at 40px 70px, rgba(255,255,255,0.2), transparent),
radial-gradient(1px 1px at 50px 50px, rgba(255,255,255,0.4), transparent),
radial-gradient(1px 1px at 80px 10px, rgba(255,255,255,0.2), transparent),
radial-gradient(1px 1px at 130px 80px, rgba(255,255,255,0.3), transparent);
background-repeat: repeat;
background-size: 200px 200px;
opacity: 0.3;
opacity: 0.5;
animation: starfield-move 120s linear infinite;
}
@@ -239,27 +337,21 @@
}
}
/* Custom button styles */
.btn-mission {
background: linear-gradient(45deg, var(--mass-effect-cyan), var(--mass-effect-gold));
border: none;
color: var(--mass-effect-dark);
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
padding: 12px 24px;
border-radius: 4px;
/* Hover glow effect */
.hover-glow:hover {
box-shadow: 0 0 20px var(--glow-cyan);
transform: translateY(-2px);
transition: all var(--transition-normal);
}
/* Button hover effects */
.btn-neon {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
transition: all var(--transition-normal);
}
.btn-mission:hover {
transform: scale(1.05);
box-shadow: 0 0 20px var(--mass-effect-cyan-glow), 0 0 40px var(--mass-effect-gold-glow);
}
.btn-mission::before {
.btn-neon::before {
content: "";
position: absolute;
top: 0;
@@ -275,23 +367,180 @@
transition: left 0.5s;
}
.btn-mission:hover::before {
.btn-neon:hover::before {
left: 100%;
}
/* Search bar glow effect */
.search-glow:focus {
box-shadow: 0 0 0 1px var(--mass-effect-cyan), 0 0 15px var(--mass-effect-cyan-glow);
border-color: var(--mass-effect-cyan);
.btn-neon:hover {
transform: scale(1.02);
box-shadow: 0 0 20px var(--glow-cyan);
}
/* Icon glow on hover */
.icon-glow:hover {
filter: drop-shadow(0 0 8px var(--glow-cyan));
transition: filter var(--transition-fast);
}
/* Dense grid layout */
.grid-dense {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
/* Bento grid layout */
.bento-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
.bento-item-large {
grid-column: span 2;
}
@media (max-width: 768px) {
.bento-item-large {
grid-column: span 1;
}
}
/* Responsive typography */
.text-responsive-xl {
font-size: clamp(1.5rem, 4vw, 2rem);
}
.text-responsive-2xl {
font-size: clamp(2rem, 5vw, 3rem);
}
.text-responsive-3xl {
font-size: clamp(2.5rem, 6vw, 4rem);
}
/* Number display for stats */
.stat-number {
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: -0.02em;
}
/* Gradient border */
.gradient-border {
position: relative;
}
.gradient-border::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, var(--neon-cyan), var(--neon-purple));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* Base styles */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: 'Inter', sans-serif;
font-family: var(--font-display), sans-serif;
overflow-x: hidden;
}
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display), sans-serif;
}
code, pre, .mono {
font-family: var(--font-mono), monospace;
}
/* Game Cover Styles */
.game-cover-wrapper {
animation: fadeInUp 0.5s ease forwards;
opacity: 0;
}
.game-cover-wrapper:hover {
transform: translateY(-4px) scale(1.02);
transition: transform 0.2s ease-out;
z-index: 10;
}
.game-cover-image img {
transition: transform 0.3s ease-out, filter 0.3s ease-out;
}
/* Discovery Wall Grid - Dense Masonry Layout */
.discovery-wall {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0;
margin: 0 -1px;
}
@media (min-width: 640px) {
.discovery-wall {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
}
@media (min-width: 1024px) {
.discovery-wall {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
}
/* Empty State Animation */
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
.empty-icon {
animation: float 3s ease-in-out infinite;
}
/* Pulse animation for empty state button */
@keyframes neon-pulse {
0%, 100% {
box-shadow: 0 0 20px var(--glow-cyan), 0 0 40px rgba(0, 240, 255, 0.2);
}
50% {
box-shadow: 0 0 30px var(--glow-cyan), 0 0 60px rgba(0, 240, 255, 0.4);
}
}
.btn-neon-pulse:hover {
animation: neon-pulse 1.5s infinite;
}
/* Section header with gradient line */
.section-header {
position: relative;
padding-bottom: 0.5rem;
}
.section-header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 60px;
height: 3px;
background: linear-gradient(90deg, var(--neon-cyan), var(--neon-purple));
border-radius: 2px;
}
}

View File

@@ -1,15 +1,17 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { Space_Grotesk, JetBrains_Mono } from 'next/font/google';
import './globals.css';
const geistSans = Geist({
variable: '--font-geist-sans',
const spaceGrotesk = Space_Grotesk({
variable: '--font-display',
subsets: ['latin'],
display: 'swap',
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
const jetBrainsMono = JetBrains_Mono({
variable: '--font-mono',
subsets: ['latin'],
display: 'swap',
});
export const metadata: Metadata = {
@@ -32,7 +34,9 @@ export default function RootLayout({
}>) {
return (
<html lang="es" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
<body className={`${spaceGrotesk.variable} ${jetBrainsMono.variable} antialiased`}>
{children}
</body>
</html>
);
}

View File

@@ -1,145 +1,156 @@
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
'use client';
import { useEffect, useState } from 'react';
import { Game } from '@/lib/api';
import { gamesApi } from '@/lib/api';
import { Gamepad2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Gamepad2, HardDrive, Import, Clock, Activity, Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { GameCover } from '@/components/landing/GameCover';
import { EmptyState } from '@/components/landing/EmptyState';
import { ArrowRight, Grid3X3 } from 'lucide-react';
export default function Home() {
const [games, setGames] = useState<Game[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchGames() {
try {
const allGames = await gamesApi.getAll();
// Ordenar por fecha de creación (más recientes primero)
const sortedGames = allGames.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
// Limitar a los últimos 16 juegos para la página principal
setGames(sortedGames.slice(0, 16));
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error desconocido';
setError(`Error al cargar juegos: ${errorMessage}`);
console.error('Error fetching games:', err);
} finally {
setIsLoading(false);
}
}
fetchGames();
}, []);
return (
<div className="min-h-screen bg-background">
{/* Starfield background */}
<div className="starfield" />
{/* Header */}
<header className="border-b border-border">
<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">
<div className="flex items-center gap-2">
<Gamepad2 className="w-6 h-6 text-primary" />
<h1 className="text-xl font-bold">Quasar</h1>
<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>
<Badge variant="outline">v1.0.0</Badge>
</div>
</header>
{/* Main Content */}
<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>
<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>
<main className="relative z-10">
{isLoading ? (
<div className="flex items-center justify-center min-h-[60vh]">
<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...</p>
</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>
) : error ? (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<p className="text-[var(--neon-coral)] mono text-sm tracking-wider mb-4">{error}</p>
<Button
onClick={() => window.location.reload()}
className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90"
>
REINTENTAR
</Button>
</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>
) : games.length === 0 ? (
<EmptyState />
) : (
<div className="container mx-auto px-4 py-8">
{/* Section Header */}
<div className="section-header mb-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-responsive-2xl font-bold mb-2">
<span className="gradient-text">ÚLTIMOS JUEGOS</span>
</h2>
<p className="text-muted-foreground mono text-sm tracking-wider">
{games.length} JUEGOS EN TU BIBLIOTECA
</p>
</div>
</CardContent>
</Card>
</section>
</div>
<Link href="/games">
<Button
variant="ghost"
size="sm"
className="text-[var(--neon-purple)] hover:bg-[var(--neon-purple)]/10 hover:text-[var(--neon-purple)] group"
>
<span className="mono text-xs tracking-wider mr-2">VER TODOS</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</div>
</div>
{/* Discovery Wall - Dense Grid */}
<div className="discovery-wall">
{games.map((game, index) => (
<GameCover key={game.id} game={game} index={index} />
))}
</div>
{/* Load More Button */}
{games.length >= 16 && (
<div className="flex justify-center mt-8">
<Link href="/games">
<Button
size="lg"
className="btn-neon bg-transparent border-2 border-[var(--neon-cyan)] text-[var(--neon-cyan)] hover:bg-[var(--neon-cyan)] hover:text-background font-bold"
>
<Grid3X3 className="w-5 h-5 mr-2" />
EXPLORAR MÁS JUEGOS
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</Link>
</div>
)}
</div>
)}
</main>
</div>
);

View File

@@ -0,0 +1,66 @@
'use client';
import { Button } from '@/components/ui/button';
import { Gamepad2, Sparkles, ArrowRight } from 'lucide-react';
import Link from 'next/link';
export function EmptyState() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] px-4">
{/* Icono animado */}
<div className="relative mb-8">
<div className="absolute inset-0 blur-3xl bg-[var(--neon-cyan)]/20" />
<div className="empty-icon relative">
<Gamepad2 className="w-32 h-32 text-[var(--neon-cyan)]" />
</div>
{/* Sparkles decorativos */}
<Sparkles className="absolute -top-4 -right-4 w-8 h-8 text-[var(--neon-purple)] animate-pulse" />
<Sparkles
className="absolute -bottom-4 -left-4 w-6 h-6 text-[var(--neon-lime)] animate-pulse"
style={{ animationDelay: '0.5s' }}
/>
</div>
{/* Título principal */}
<h1 className="text-responsive-3xl font-bold text-center mb-4">
<span className="gradient-text">TU BIBLIOTECA</span>
<br />
<span className="text-white">ESTÁ VACÍA</span>
</h1>
{/* Descripción motivadora */}
<p className="text-lg text-muted-foreground text-center max-w-md mb-8 leading-relaxed">
Es hora de comenzar tu colección de videojuegos.
<br />
<span className="text-[var(--neon-cyan)] font-mono text-sm tracking-wider">
Importa tus ROMs o añade juegos manualmente
</span>
</p>
{/* Botones de acción */}
<div className="flex flex-col sm:flex-row gap-4 w-full max-w-md">
<Link href="/import" className="flex-1">
<Button className="w-full btn-neon btn-neon-pulse bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 font-bold text-lg py-6">
<Gamepad2 className="w-5 h-5 mr-2" />
IMPORTAR JUEGOS
</Button>
</Link>
<Link href="/games" className="flex-1">
<Button className="w-full btn-neon bg-transparent border-2 border-[var(--neon-purple)] text-[var(--neon-purple)] hover:bg-[var(--neon-purple)] hover:text-background font-bold text-lg py-6">
<Sparkles className="w-5 h-5 mr-2" />
AÑADIR MANUAL
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</Link>
</div>
{/* Info adicional */}
<div className="mt-12 text-center">
<p className="text-sm text-muted-foreground mono tracking-wider">
Compatible con archivos ROM, IGDB, RAWG y TheGamesDB
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { Game } from '@/lib/api';
import { Badge } from '@/components/ui/badge';
import { ExternalLink } from 'lucide-react';
import Link from 'next/link';
import { useState } from 'react';
interface GameCoverProps {
game: Game;
index: number;
}
export function GameCover({ game, index }: GameCoverProps) {
const [isHovered, setIsHovered] = useState(false);
// Colores de acento por fuente
const sourceColors: Record<string, { bg: string; text: string; border: string }> = {
igdb: { bg: 'rgba(0, 240, 255, 0.15)', text: '#00f0ff', border: '#00f0ff' },
rawg: { bg: 'rgba(176, 38, 255, 0.15)', text: '#b026ff', border: '#b026ff' },
thegamesdb: { bg: 'rgba(57, 255, 20, 0.15)', text: '#39ff14', border: '#39ff14' },
rom: { bg: 'rgba(255, 215, 0, 0.15)', text: '#ffd700', border: '#ffd700' },
manual: { bg: 'rgba(255, 107, 107, 0.15)', text: '#ff6b6b', border: '#ff6b6b' },
};
const colors = sourceColors[game.source] || sourceColors.manual;
// URL de la portada o placeholder
const coverUrl = game.cover || '/placeholder-game-cover.png';
return (
<Link
href={`/games/${game.id}`}
className="game-cover-wrapper relative group block overflow-hidden"
style={{
animationDelay: `${index * 50}ms`,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Imagen de portada */}
<div className="game-cover-image aspect-[3/4] w-full overflow-hidden bg-[#0a0a0a]">
<img
src={coverUrl}
alt={game.title}
className="w-full h-full object-cover transition-all duration-300 ease-out group-hover:scale-110 group-hover:brightness-110"
loading="lazy"
/>
</div>
{/* Overlay en hover */}
<div className="game-cover-overlay absolute inset-0 bg-gradient-to-t from-black via-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease-out pointer-events-none">
{/* Badge de fuente */}
<div className="absolute top-3 left-3 right-3">
<Badge
className="category-badge font-mono text-xs tracking-wider uppercase"
style={{
backgroundColor: colors.bg,
color: colors.text,
borderColor: colors.border,
borderWidth: '1px',
}}
>
{game.source}
</Badge>
</div>
{/* Contenido del overlay */}
<div className="absolute bottom-0 left-0 right-0 p-4">
{/* Título del juego */}
<h3 className="font-display font-bold text-lg text-white mb-2 line-clamp-2 leading-tight">
{game.title}
</h3>
{/* Metadatos */}
<div className="flex items-center gap-3 text-xs mono text-gray-300">
{game.year && (
<span className="flex items-center gap-1">
<span className="text-[var(--neon-cyan)]">{game.year}</span>
</span>
)}
{game.platform && (
<span className="flex items-center gap-1">
<span className="text-[var(--neon-purple)]">{game.platform}</span>
</span>
)}
{game.genre && (
<span className="flex items-center gap-1">
<span className="text-[var(--neon-lime)]">{game.genre}</span>
</span>
)}
</div>
{/* Indicador de enlace */}
<div className="mt-3 flex items-center gap-1 text-xs text-[var(--neon-cyan)] font-mono tracking-wider">
<ExternalLink className="w-3 h-3" />
<span>VER DETALLES</span>
</div>
</div>
</div>
{/* Glow effect en hover */}
<div className="game-cover-glow absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div
className="absolute inset-0"
style={{
boxShadow: `inset 0 0 40px ${colors.text}20`,
}}
/>
</div>
{/* Borde brillante en hover */}
<div
className="game-cover-border absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300"
style={{
boxShadow: `inset 0 0 0 1px ${colors.text}40`,
}}
/>
</Link>
);
}

View File

@@ -1,4 +1,4 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3003/api';
// Tipos para relaciones del juego
export interface Artwork {