Files
quasar/docs/02-tecnico/frontend.md
Benito Rodríguez 0c9c408564
Some checks failed
CI / lint (push) Failing after 1m4s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped
Refactor code structure for improved readability and maintainability
2026-02-22 18:18:46 +01:00

31 KiB

Frontend de Quasar 🎮

Visión general

El frontend de Quasar es una Single Page Application (SPA) moderna construida con React + Vite, utilizando shadcn/ui para componentes UI y Tailwind CSS para estilos. La aplicación está diseñada con una estética de modo oscuro sofisticada, priorizando la accesibilidad, la responsividad y un excelente rendimiento.

Stack tecnológico

Categoría Tecnología Versión Propósito
Framework React ^18.3.0 Framework base
Build tool Vite ^5.0.0 Build tool y dev server
Lenguaje TypeScript ^5.3.0 Type safety
Estado TanStack Query ^5.0.0 Gestión de estado y cache de datos
Routing TanStack Router ^1.0.0 Routing con carga de datos
Formularios TanStack Form ^0.0.0 Manejo de formularios
Validación Zod ^3.0.0 Validación de esquemas
Componentes UI shadcn/ui latest Componentes UI reutilizables
Estilos Tailwind CSS ^3.4.0 Framework de estilos
Iconos lucide-react ^0.300.0 Iconos
HTTP axios latest Cliente HTTP
Fechas date-fns latest Manipulación de fechas
Utils clsx ^2.0.0 Utilidad de clases condicionales
Utils tailwind-merge ^2.0.0 Fusión de clases Tailwind

Arquitectura del sistema

Diagrama de arquitectura

graph TB
    subgraph Browser
        UI[Componentes UI]
        Router[TanStack Router]
        Query[TanStack Query]
        Forms[TanStack Form + Zod]
    end

    subgraph Services
        API[API Client]
        Auth[Auth Service]
    end

    UI --> Router
    UI --> Query
    UI --> Forms
    Router --> Query
    Query --> API
    Forms --> Query

    API --> Backend[Backend API]

    style UI fill:#1e293b
    style Router fill:#334155
    style Query fill:#334155
    style Forms fill:#334155
    style API fill:#475569
    style Backend fill:#64748b

Estructura de carpetas

frontend/
├── src/
│   ├── components/
│   │   ├── ui/              # Componentes shadcn/ui
│   │   ├── layout/          # Componentes de layout
│   │   ├── games/           # Componentes específicos de juegos
│   │   ├── roms/            # Componentes específicos de ROMs
│   │   ├── shared/          # Componentes compartidos
│   │   └── ...              # Otros componentes específicos
│   ├── pages/               # Páginas de la aplicación
│   │   ├── DashboardPage.tsx
│   │   ├── GamesPage.tsx
│   │   ├── GameDetailPage.tsx
│   │   ├── GamesNewPage.tsx
│   │   ├── ImportPage.tsx
│   │   ├── PlatformsPage.tsx
│   │   ├── TagsPage.tsx
│   │   ├── SettingsPage.tsx
│   │   └── ExportPage.tsx
│   ├── api/                 # Servicios de API y tipos
│   │   ├── client.ts        # Cliente HTTP base
│   │   ├── games.ts         # Servicios de juegos
│   │   ├── platforms.ts     # Servicios de plataformas
│   │   ├── tags.ts          # Servicios de etiquetas
│   │   ├── import.ts        # Servicios de importación
│   │   ├── settings.ts      # Servicios de configuración
│   │   └── types.ts         # Tipos de API
│   ├── query/               # Configuración de TanStack Query
│   │   └── client.tsx       # Cliente de Query
│   ├── form/                # Configuración de TanStack Form
│   │   └── config.tsx        # Configuración de Form + Zod
│   ├── router/              # Configuración de TanStack Router
│   │   └── router.tsx       # Configuración del router
│   ├── types/               # Definiciones TypeScript
│   │   └── index.ts         # Tipos globales
│   ├── hooks/               # Custom hooks
│   ├── lib/                 # Utilidades y configuraciones
│   │   ├── utils.ts         # Utilidades generales
│   │   └── ...              # Otras utilidades
│   ├── styles/              # Estilos globales
│   │   └── globals.css      # CSS global con variables
│   └── layout/              # Componentes de layout
│       ├── Header.tsx       # Header principal
│       ├── Sidebar.tsx      # Sidebar de navegación
│       └── Layout.tsx       # Layout principal
├── public/                  # Assets estáticos
├── index.html              # HTML entry point
├── package.json            # Dependencias y scripts
├── tsconfig.json           # Configuración TypeScript
├── vite.config.ts          # Configuración Vite
├── tailwind.config.ts      # Configuración Tailwind
└── postcss.config.js       # Configuración PostCSS

Sistema de diseño

Principios de diseño

  1. Modo oscuro por defecto: La aplicación está diseñada para funcionar principalmente en modo oscuro
  2. Alto contraste: Asegurar legibilidad excelente en todos los contextos
  3. Consistencia: Componentes y patrones consistentes en toda la aplicación
  4. Accesibilidad: Cumplimiento con WCAG AA
  5. Performance: Componentes optimizados para rendimiento
  6. Responsividad: Mobile-first con breakpoints claros

Paleta de colores (Modo oscuro)

Colores semánticos

/* Primary - Violet (Acción principal) */
--primary-50: #f5f3ff;
--primary-100: #ede9fe;
--primary-200: #ddd6fe;
--primary-300: #c4b5fd;
--primary-400: #a78bfa;
--primary-500: #8b5cf6;
--primary-600: #7c3aed; /* Color principal */
--primary-700: #6d28d9;
--primary-800: #5b21b6;
--primary-900: #4c1d95;
--primary-950: #2e1065;

/* Secondary - Slate (Acciones secundarias) */
--secondary-50: #f8fafc;
--secondary-100: #f1f5f9;
--secondary-200: #e2e8f0;
--secondary-300: #cbd5e1;
--secondary-400: #94a3b8;
--secondary-500: #64748b;
--secondary-600: #475569;
--secondary-700: #334155;
--secondary-800: #1e293b;
--secondary-900: #0f172a;
--secondary-950: #020617;

/* Success - Emerald (Estados positivos) */
--success-50: #ecfdf5;
--success-100: #d1fae5;
--success-200: #a7f3d0;
--success-300: #6ee7b7;
--success-400: #34d399;
--success-500: #10b981;
--success-600: #059669;
--success-700: #047857;
--success-800: #065f46;
--success-900: #064e3b;
--success-950: #022c22;

/* Warning - Amber (Estados de advertencia) */
--warning-50: #fffbeb;
--warning-100: #fef3c7;
--warning-200: #fde68a;
--warning-300: #fcd34d;
--warning-400: #fbbf24;
--warning-500: #f59e0b;
--warning-600: #d97706;
--warning-700: #b45309;
--warning-800: #92400e;
--warning-900: #78350f;
--warning-950: #451a03;

/* Error - Red (Estados de error) */
--error-50: #fef2f2;
--error-100: #fee2e2;
--error-200: #fecaca;
--error-300: #fca5a5;
--error-400: #f87171;
--error-500: #ef4444;
--error-600: #dc2626;
--error-700: #b91c1c;
--error-800: #991b1b;
--error-900: #7f1d1d;
--error-950: #450a0a;

/* Info - Sky (Información) */
--info-50: #f0f9ff;
--info-100: #e0f2fe;
--info-200: #bae6fd;
--info-300: #7dd3fc;
--info-400: #38bdf8;
--info-500: #0ea5e9;
--info-600: #0284c7;
--info-700: #0369a1;
--info-800: #075985;
--info-900: #0c4a6e;
--info-950: #082f49;

Uso de colores

Propósito Color Hex
Background Slate-950 #020617
Card Slate-950 #020617
Card hover Slate-900 #0f172a
Border Slate-800 #1e293b
Primary action Violet-600 #7c3aed
Primary hover Violet-700 #6d28d9
Text primary Slate-50 #f8fafc
Text secondary Slate-400 #94a3b8
Text muted Slate-500 #64748b

Tipografía

Font family

font-family:
  'Inter',
  system-ui,
  -apple-system,
  sans-serif;

Escala de tamaños

Token Tamaño Line-height Uso
text-xs 12px 16px Labels pequeños, captions
text-sm 14px 20px Texto secundario
text-base 16px 24px Texto de cuerpo
text-lg 18px 28px Texto de cuerpo grande
text-xl 20px 28px Subtítulos
text-2xl 24px 32px Títulos pequeños
text-3xl 30px 40px Títulos medianos
text-4xl 36px 44px Títulos grandes
text-5xl 48px 56px Display

Font weights

Token Weight Uso
font-normal 400 Texto de cuerpo
font-medium 500 Texto de énfasis
font-semibold 600 Subtítulos
font-bold 700 Títulos

Espaciado

Escala de espaciado (base 4px)

Token Valor Uso
p-0, m-0 0px Sin espaciado
p-1, m-1 4px Micro espaciado
p-2, m-2 8px Espaciado pequeño
p-3, m-3 12px Espaciado medio-pequeño
p-4, m-4 16px Espaciado medio
p-5, m-5 20px Espaciado medio-grande
p-6, m-6 24px Espaciado grande
p-8, m-8 32px Espaciado extra grande
p-10, m-10 40px Espaciado extra extra grande
p-12, m-12 48px Espaciado masivo

Rutas de la aplicación

Diagrama de rutas

graph LR
    A[/] --> B[Dashboard]
    C[/games] --> D[Lista de juegos]
    E[/games/:id] --> F[Detalle de juego]
    G[/games/new] --> H[Crear juego]
    I[/import] --> J[Importar ROMs]
    K[/platforms] --> L[Gestión de plataformas]
    M[/tags] --> N[Gestión de etiquetas]
    O[/settings] --> P[Configuración]
    Q[/export] --> R[Exportar/Importar]

    style A fill:#7c3aed
    style C fill:#7c3aed
    style E fill:#7c3aed
    style G fill:#7c3aed
    style I fill:#7c3aed
    style K fill:#7c3aed
    style M fill:#7c3aed
    style O fill:#7c3aed
    style Q fill:#7c3aed

Descripción de rutas

Ruta Componente Descripción
/ Dashboard Vista general con estadísticas y juegos recientes
/games GamesPage Lista paginada de juegos con filtros y búsqueda
/games/:id GameDetailPage Vista detallada de un juego con metadata, ROMs, compras
/games/new GamesNewPage Formulario para crear un nuevo juego
/import ImportPage Interfaz para escanear e importar ROMs locales
/platforms PlatformsPage Gestión de plataformas
/tags TagsPage Gestión de etiquetas
/settings SettingsPage Configuración de la aplicación
/export ExportPage Exportar/importar colección

Componentes UI (shadcn/ui)

Componentes base

Componente Uso principal
Button Acciones principales y secundarias
Card Contenedores de contenido
Input Entradas de texto
Select Selección de opciones
Dialog Modales y diálogos
Table Tablas de datos
Badge Etiquetas y estados
Avatar Avatares de usuario
DropdownMenu Menús desplegables
Tabs Navegación por pestañas
Form Formularios
Separator Separadores visuales
ScrollArea Áreas con scroll
Tooltip Información contextual
Skeleton Estados de carga
AlertDialog Confirmaciones
Toast Notificaciones

Variantes y tamaños

Button

Variantes:

  • default: Acción principal (violet)
  • destructive: Acción destructiva (rojo)
  • outline: Acción secundaria con borde
  • secondary: Acción secundaria (slate)
  • ghost: Acción sutil sin borde
  • link: Estilo de enlace

Tamaños:

  • sm: 32px de altura
  • default: 40px de altura
  • lg: 48px de altura
  • icon: 32px x 32px (cuadrado)

Card

<Card>
  <CardHeader>
    <CardTitle>Título</CardTitle>
    <CardDescription>Descripción opcional</CardDescription>
  </CardHeader>
  <CardContent>Contenido principal</CardContent>
  <CardFooter>Acciones</CardFooter>
</Card>

Badge

Variantes:

  • default: Violet
  • secondary: Slate
  • destructive: Rojo
  • outline: Borde solo
  • success: Verde
  • warning: Amarillo

Componentes específicos

Componentes de Juegos

GameCard

Componente de tarjeta para mostrar un juego en una lista o grid.

interface GameCardProps {
  game: Game;
  onClick?: () => void;
  onEdit?: () => void;
  onDelete?: () => void;
}

Estructura:

<Card className="group hover:border-primary/50 transition-colors cursor-pointer">
  <CardHeader className="pb-3">
    <div className="flex items-start justify-between gap-3">
      <div className="flex items-center gap-3">
        <GameCover gameId={game.id} className="h-16 w-12 rounded" />
        <div className="min-w-0">
          <CardTitle className="line-clamp-1">{game.title}</CardTitle>
          <CardDescription className="line-clamp-1">
            {game.platforms.map((p) => p.name).join(', ')}
          </CardDescription>
        </div>
      </div>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" size="icon" className="opacity-0 group-hover:opacity-100">
            <MoreHorizontal className="h-4 w-4" />
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuItem onClick={onEdit}>
            <Edit className="mr-2 h-4 w-4" />
            Editar
          </DropdownMenuItem>
          <DropdownMenuSeparator />
          <DropdownMenuItem onClick={onDelete} className="text-destructive">
            <Trash2 className="mr-2 h-4 w-4" />
            Eliminar
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  </CardHeader>
  <CardContent className="pb-3">
    <div className="flex flex-wrap gap-2">
      {game.tags.slice(0, 3).map((tag) => (
        <Badge key={tag.id} variant="secondary" className="text-xs">
          {tag.name}
        </Badge>
      ))}
      {game.tags.length > 3 && (
        <Badge variant="secondary" className="text-xs">
          +{game.tags.length - 3}
        </Badge>
      )}
    </div>
  </CardContent>
  <CardFooter className="pt-0">
    <div className="flex items-center gap-4 text-xs text-muted-foreground">
      <div className="flex items-center gap-1">
        <HardDrive className="h-3 w-3" />
        {game.romFiles.length} ROMs
      </div>
      {game.releaseDate && (
        <div className="flex items-center gap-1">
          <Calendar className="h-3 w-3" />
          {formatDate(game.releaseDate)}
        </div>
      )}
    </div>
  </CardFooter>
</Card>

GameList

Componente de lista de juegos con filtros y paginación.

interface GameListProps {
  filters?: GameFilters;
  onFiltersChange?: (filters: GameFilters) => void;
}

Estructura:

<div className="space-y-6">
  <GameFilters filters={filters} onChange={onFiltersChange} />
  <GameGrid games={games} isLoading={isLoading} />
  <GamePagination pagination={pagination} onPageChange={onPageChange} />
</div>

GameDetail

Página de detalle de un juego.

<div className="space-y-6">
  <GameDetailHeader game={game} />
  <Tabs defaultValue="overview">
    <TabsList>
      <TabsTrigger value="overview">Resumen</TabsTrigger>
      <TabsTrigger value="roms">ROMs</TabsTrigger>
      <TabsTrigger value="purchases">Compras</TabsTrigger>
      <TabsTrigger value="metadata">Metadata</TabsTrigger>
    </TabsList>
    <TabsContent value="overview">
      <GameOverview game={game} />
    </TabsContent>
    <TabsContent value="roms">
      <GameRoms game={game} />
    </TabsContent>
    <TabsContent value="purchases">
      <GamePurchases game={game} />
    </TabsContent>
    <TabsContent value="metadata">
      <GameMetadata game={game} />
    </TabsContent>
  </Tabs>
</div>

Componentes de ROMs

RomFileCard

Componente de tarjeta para mostrar un archivo ROM.

interface RomFileCardProps {
  rom: RomFile;
  game?: Game;
  onLink?: () => void;
  onUnlink?: () => void;
}

ImportRomWizard

Wizard para importar ROMs desde el sistema de archivos.

Pasos del wizard:

  1. Configurar rutas: Seleccionar directorios para escanear
  2. Escanear: Ejecutar escaneo de archivos
  3. Revisar: Revisar resultados y detectar duplicados
  4. Importar: Importar ROMs seleccionadas

Componentes de Metadata

MetadataSearch

Componente para buscar metadata de APIs externas (IGDB, RAWG).

interface MetadataSearchProps {
  onSelect: (metadata: ExternalMetadata) => void;
  game?: Game;
}

Estructura:

<div className="space-y-4">
  <div className="flex gap-2">
    <Input
      placeholder="Buscar en IGDB, RAWG..."
      value={searchQuery}
      onChange={(e) => setSearchQuery(e.target.value)}
    />
    <Button onClick={handleSearch}>
      <Search className="mr-2 h-4 w-4" />
      Buscar
    </Button>
  </div>
  {isLoading && <MetadataSearchResultsSkeleton />}
  {results && (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
      {results.map((result) => (
        <MetadataResultCard key={result.id} result={result} onSelect={() => onSelect(result)} />
      ))}
    </div>
  )}
</div>

Componentes de Dashboard

DashboardStats

Tarjetas de estadísticas del dashboard.

interface DashboardStatsProps {
  stats: {
    totalGames: number;
    totalRoms: number;
    totalPlatforms: number;
    recentGames: number;
  };
}

Estructura:

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  <StatCard
    title="Total de juegos"
    value={stats.totalGames}
    icon={<Gamepad2 className="h-4 w-4" />}
  />
  <StatCard
    title="Total de ROMs"
    value={stats.totalRoms}
    icon={<HardDrive className="h-4 w-4" />}
  />
  <StatCard
    title="Plataformas"
    value={stats.totalPlatforms}
    icon={<Monitor className="h-4 w-4" />}
  />
  <StatCard
    title="Juegos recientes"
    value={stats.recentGames}
    icon={<Clock className="h-4 w-4" />}
  />
</div>

Layout Components

AppLayout

Layout principal de la aplicación.

interface AppLayoutProps {
  children: React.ReactNode;
}

Estructura:

<div className="min-h-screen bg-background">
  <Header />
  <div className="flex">
    <Sidebar />
    <main className="flex-1 p-6">{children}</main>
  </div>
</div>

Header

Header de la aplicación.

interface HeaderProps {
  onMenuToggle?: () => void;
}

Estructura:

<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur">
  <div className="container flex h-14 items-center gap-4">
    <Logo />
    <GlobalSearch />
    <div className="ml-auto flex items-center gap-2">
      <ThemeToggle />
      <UserMenu />
    </div>
  </div>
</header>

Sidebar

Sidebar de navegación.

interface SidebarProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

Estructura:

<aside className="fixed inset-y-0 left-0 z-40 w-64 border-r border-border bg-background">
  <div className="flex h-full flex-col">
    <SidebarHeader />
    <SidebarNav />
    <SidebarFooter />
  </div>
</aside>

Gestión de estado

TanStack Query

  • Cache de datos: Cache automático de respuestas de API
  • Invalidación: Invalidación inteligente de cache
  • Optimistic updates: Actualizaciones optimistas
  • Background refetch: Refetch en background
// Ejemplo de query
const gamesQuery = useQuery({
  queryKey: ['games', { page, limit, search }],
  queryFn: () => fetchGames({ page, limit, search }),
  staleTime: 5 * 60 * 1000, // 5 minutos
});

// Ejemplo de mutation
const createGameMutation = useMutation({
  mutationFn: createGame,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['games'] });
  },
});

TanStack Router

  • File-based routing: Rutas basadas en archivos
  • Route loaders: Carga de datos antes de renderizar
  • Type-safe routing: Tipado de parámetros y búsqueda
  • Code splitting: División de código automática
// Ejemplo de route loader
export const loader = ({ params }: LoaderArgs) => {
  return queryClient.ensureQueryData({
    queryKey: ['game', params.id],
    queryFn: () => fetchGame(params.id),
  });
};

TanStack Form + Zod

// Esquema de validación
const gameSchema = z.object({
  title: z.string().min(1, 'El título es requerido'),
  description: z.string().optional(),
  releaseDate: z.date().optional(),
  platforms: z.array(z.string()).min(1, 'Selecciona al menos una plataforma'),
  tags: z.array(z.string()).default([]),
})

type GameFormData = z.infer<typeof gameSchema>

// Componente de formulario
function GameForm() {
  const form = useForm({
    defaultValues: {
      title: '',
      description: '',
      platforms: [],
      tags: [],
    },
    validators: {
      onChange: gameSchema,
    },
  })

  const { mutate: createGame } = useCreateGame()

  const handleSubmit = (data: GameFormData) => {
    createGame(data)
  }

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        form.handleSubmit()
      }}
    >
      <form.Field
        name="title"
        validators={{
          onChange: ({ value }) =>
            !value ? 'El título es requerido' : undefined,
        }}
      >
        {(field) => (
          <div>
            <label htmlFor={field.name}>Título</label>
            <input
              id={field.name}
              name={field.name}
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {field.state.meta.errors && (
              <span>{field.state.meta.errors.join(', ')}</span>
            )}
          </div>
        )}
      </form.Field>
      <button type="submit">Guardar</button>
    </form>
  )
}

API Client

// API client base
const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
  timeout: 10000,
});

// Interceptor para manejo de errores
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      // Redirigir a login
    }
    return Promise.reject(error);
  }
);

// Servicios de API
export const gamesApi = {
  list: (params: GamesListParams) => apiClient.get('/games', { params }),
  get: (id: string) => apiClient.get(`/games/${id}`),
  create: (data: CreateGameDto) => apiClient.post('/games', data),
  update: (id: string, data: UpdateGameDto) => apiClient.patch(`/games/${id}`, data),
  delete: (id: string) => apiClient.delete(`/games/${id}`),
};

Accesibilidad

WCAG AA Compliance

  • Contraste: Mínimo 4.5:1 para texto normal, 3:1 para texto grande
  • Keyboard navigation: Navegación completa por teclado
  • Focus indicators: Indicadores visuales de foco
  • Screen reader support: ARIA labels y roles apropiados
  • Semantic HTML: Uso correcto de elementos semánticos

Implementación

// Ejemplo de componente accesible
<Button
  aria-label="Crear nuevo juego"
  type="button"
  onClick={handleCreate}
>
  <Plus className="mr-2 h-4 w-4" />
  Nuevo juego
</Button>

Responsividad

Breakpoints

/* Tailwind breakpoints */
sm: 640px   /* Móvil grande */
md: 768px   /* Tablet */
lg: 1024px  /* Desktop pequeño */
xl: 1280px  /* Desktop */
2xl: 1536px /* Desktop grande */

Estrategia mobile-first

// Ejemplo de componente responsivo
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  {games.map((game) => (
    <GameCard key={game.id} game={game} />
  ))}
</div>

Optimizaciones

Performance

  • Code splitting: División de código por rutas
  • Lazy loading: Carga diferida de componentes
  • Image optimization: Optimización de imágenes
  • Memoization: Uso de React.memo y useMemo
  • Virtual scrolling: Para listas largas

Bundle size

  • Tree shaking: Eliminación de código no usado
  • Compression: Gzip/Brotli compression
  • CDN: Uso de CDN para dependencias

Configuración

Scripts de desarrollo

{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "format": "prettier --write \"src/**/*.{ts,tsx}\"",
    "type-check": "tsc --noEmit",
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:e2e": "playwright test"
  }
}

Variables de entorno

# API
VITE_API_URL=http://localhost:3000

# Feature flags
VITE_ENABLE_ANALYTICS=false
VITE_ENABLE_TELEMETRY=false

Configuración de Tailwind

import type { Config } from 'tailwindcss';

const config: Config = {
  darkMode: ['class'],
  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        border: 'hsl(var(--border))',
        input: 'hsl(var(--input))',
        ring: 'hsl(var(--ring))',
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: {
          DEFAULT: 'hsl(var(--primary))',
          foreground: 'hsl(var(--primary-foreground))',
        },
        secondary: {
          DEFAULT: 'hsl(var(--secondary))',
          foreground: 'hsl(var(--secondary-foreground))',
        },
        destructive: {
          DEFAULT: 'hsl(var(--destructive))',
          foreground: 'hsl(var(--destructive-foreground))',
        },
        muted: {
          DEFAULT: 'hsl(var(--muted))',
          foreground: 'hsl(var(--muted-foreground))',
        },
        accent: {
          DEFAULT: 'hsl(var(--accent))',
          foreground: 'hsl(var(--accent-foreground))',
        },
        popover: {
          DEFAULT: 'hsl(var(--popover))',
          foreground: 'hsl(var(--popover-foreground))',
        },
        card: {
          DEFAULT: 'hsl(var(--card))',
          foreground: 'hsl(var(--card-foreground))',
        },
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
    },
  },
  plugins: [],
};

export default config;

Configuración de Vite

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
      },
    },
  },
});

Testing

Estrategia de testing

  • Unit tests: Componentes y hooks individuales
  • Integration tests: Flujos de usuario
  • E2E tests: Flujos completos con Playwright

Herramientas

  • Vitest: Unit tests
  • Testing Library: Testing de componentes React
  • Playwright: E2E tests

Patrones de diseño

Page Layout

<div className="min-h-screen bg-background">
  <Header />
  <div className="flex">
    <Sidebar />
    <main className="flex-1 p-6">
      <PageHeader />
      <PageContent />
    </main>
  </div>
</div>

Card Grid

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
  {items.map((item) => (
    <Card key={item.id}>{/* Card content */}</Card>
  ))}
</div>

Form Layout

<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
    <FormField
      control={form.control}
      name="title"
      render={({ field }) => (
        <FormItem>
          <FormLabel>Título</FormLabel>
          <FormControl>
            <Input placeholder="Escribe el título..." {...field} />
          </FormControl>
          <FormDescription>Descripción del campo</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
    <div className="flex justify-end gap-2">
      <Button type="button" variant="outline" onClick={onCancel}>
        Cancelar
      </Button>
      <Button type="submit">Guardar</Button>
    </div>
  </form>
</Form>

Estados de carga

Skeleton Loading

{isLoading ? (
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
    {Array.from({ length: 6 }).map((_, i) => (
      <Card key={i}>
        <CardHeader>
          <Skeleton className="h-4 w-3/4" />
        </CardHeader>
        <CardContent>
          <Skeleton className="h-20 w-full" />
        </CardContent>
      </Card>
    ))}
  </div>
) : (
  // Actual content
)}

Empty State

<div className="flex flex-col items-center justify-center py-12">
  <div className="rounded-full bg-muted p-4">
    <Inbox className="h-8 w-8 text-muted-foreground" />
  </div>
  <h3 className="mt-4 text-lg font-semibold">No hay elementos</h3>
  <p className="mt-2 text-sm text-muted-foreground">Comienza añadiendo tu primer elemento.</p>
  <Button className="mt-4">
    <Plus className="mr-2 h-4 w-4" />
    Añadir elemento
  </Button>
</div>

Iconos

Se utiliza lucide-react para todos los iconos. Iconos comunes:

Icono Uso
Home Dashboard
Gamepad2 Juegos
Upload Importar
Settings Configuración
Monitor Plataformas
Tag Etiquetas
Download Exportar
Search Búsqueda
Plus Crear nuevo
Edit Editar
Trash2 Eliminar
Filter Filtros
SortAsc Ordenar
ChevronRight Navegación
Menu Menú móvil
X Cerrar
Check Confirmar
AlertCircle Advertencia
Info Información
Loader2 Carga

Implementación

Prerrequisitos

  • Node.js 18+
  • Yarn 4.x (package manager)

Instalación

# Navegar al directorio frontend
cd frontend

# Instalar dependencias
yarn install

# Iniciar desarrollo
yarn dev

Build para producción

# Build
yarn build

# Preview
yarn preview

Metadatos

Autor: Quasar Frontend Team Última actualización: 2026-02-22 Versión: 1.0.0 Estado: Implementación completa en progreso