feat: add theme-based layouts

Each theme now has a unique layout:
- Light: Standard fixed sidebar
- Vibrant: Horizontal top navigation
- Corporate: Compact sidebar (expands on hover)
- Dark: Floating sidebar with glass effect

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-01-22 03:46:02 +00:00
parent 74b1bb8c02
commit 6e3e69005b
10 changed files with 499 additions and 64 deletions

View File

@@ -5,13 +5,37 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store';
import { themes, type ThemeName } from '@/themes';
import { Check, Palette, User, Building } from 'lucide-react';
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles } from 'lucide-react';
const themeOptions: { name: ThemeName; label: string; description: string }[] = [
{ name: 'light', label: 'Light', description: 'Tema claro profesional' },
{ name: 'vibrant', label: 'Vibrant', description: 'Colores vivos y modernos' },
{ name: 'corporate', label: 'Corporate', description: 'Diseño empresarial denso' },
{ name: 'dark', label: 'Dark', description: 'Modo oscuro con acentos neón' },
const themeOptions: { name: ThemeName; label: string; description: string; layoutDesc: string; layoutIcon: typeof Sidebar }[] = [
{
name: 'light',
label: 'Light',
description: 'Tema claro profesional',
layoutDesc: 'Sidebar estándar fijo',
layoutIcon: Sidebar,
},
{
name: 'vibrant',
label: 'Vibrant',
description: 'Colores vivos y modernos',
layoutDesc: 'Navegación horizontal superior',
layoutIcon: PanelTop,
},
{
name: 'corporate',
label: 'Corporate',
description: 'Diseño empresarial denso',
layoutDesc: 'Sidebar compacto (expande al hover)',
layoutIcon: Minimize2,
},
{
name: 'dark',
label: 'Dark',
description: 'Modo oscuro con acentos neón',
layoutDesc: 'Sidebar flotante con efecto glass',
layoutIcon: Sparkles,
},
];
export default function ConfiguracionPage() {
@@ -71,41 +95,72 @@ export default function ConfiguracionPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Palette className="h-4 w-4" />
Tema Visual
Tema Visual y Layout
</CardTitle>
<CardDescription>
Elige el tema que mejor se adapte a tu preferencia
Cada tema incluye colores y distribución de elementos únicos
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{themeOptions.map((option) => (
<div className="grid gap-4 md:grid-cols-2">
{themeOptions.map((option) => {
const LayoutIcon = option.layoutIcon;
return (
<button
key={option.name}
onClick={() => setTheme(option.name)}
className={`relative p-4 rounded-lg border-2 text-left transition-all ${
theme === option.name
? 'border-primary bg-primary/5'
? 'border-primary bg-primary/5 shadow-md'
: 'border-border hover:border-primary/50'
}`}
>
{theme === option.name && (
<div className="absolute top-2 right-2">
<Check className="h-4 w-4 text-primary" />
<div className="absolute top-3 right-3">
<div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
<Check className="h-4 w-4 text-primary-foreground" />
</div>
</div>
)}
{/* Color Preview */}
<div className="flex gap-2 mb-4">
<div
className="h-20 rounded-md mb-3"
className="h-12 w-12 rounded-lg shadow-inner"
style={{
background: `hsl(${themes[option.name].cssVars['--primary']})`,
}}
/>
<p className="font-medium">{option.label}</p>
<p className="text-xs text-muted-foreground">
<div
className="h-12 w-8 rounded-lg shadow-inner"
style={{
background: `hsl(${themes[option.name].cssVars['--secondary']})`,
}}
/>
<div
className="h-12 w-8 rounded-lg shadow-inner"
style={{
background: `hsl(${themes[option.name].cssVars['--accent']})`,
}}
/>
</div>
{/* Theme Info */}
<p className="font-semibold text-lg mb-1">{option.label}</p>
<p className="text-sm text-muted-foreground mb-3">
{option.description}
</p>
{/* Layout Info */}
<div className="flex items-center gap-2 pt-3 border-t">
<LayoutIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{option.layoutDesc}
</span>
</div>
</button>
))}
);
})}
</div>
</CardContent>
</Card>

View File

@@ -3,7 +3,13 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { useThemeStore } from '@/stores/theme-store';
import { themes } from '@/themes';
import { Sidebar } from '@/components/layouts/sidebar';
import { TopNav } from '@/components/layouts/topnav';
import { SidebarCompact } from '@/components/layouts/sidebar-compact';
import { SidebarFloating } from '@/components/layouts/sidebar-floating';
import { cn } from '@/lib/utils';
export default function DashboardLayout({
children,
@@ -12,6 +18,10 @@ export default function DashboardLayout({
}) {
const router = useRouter();
const { isAuthenticated } = useAuthStore();
const { theme } = useThemeStore();
const currentTheme = themes[theme];
const layout = currentTheme.layout;
useEffect(() => {
if (!isAuthenticated) {
@@ -23,10 +33,42 @@ export default function DashboardLayout({
return null;
}
// Render layout based on theme
const renderNavigation = () => {
switch (layout) {
case 'topnav':
return <TopNav />;
case 'sidebar-compact':
return <SidebarCompact />;
case 'sidebar-floating':
return <SidebarFloating />;
case 'sidebar-standard':
default:
return <Sidebar />;
}
};
const getContentClasses = () => {
switch (layout) {
case 'topnav':
return 'pt-16'; // Top padding for fixed top nav
case 'sidebar-compact':
return 'pl-16'; // Small left padding for compact sidebar
case 'sidebar-floating':
return 'pl-72 pr-4 py-4'; // Padding for floating sidebar
case 'sidebar-standard':
default:
return 'pl-64'; // Standard sidebar width
}
};
return (
<div className="min-h-screen bg-background">
<Sidebar />
<div className="pl-64">
<div className={cn(
'min-h-screen bg-background',
layout === 'sidebar-floating' && 'bg-gradient-to-br from-background via-background to-muted/20'
)}>
{renderNavigation()}
<div className={getContentClasses()}>
{children}
</div>
</div>

View File

@@ -0,0 +1,130 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
FileText,
Calculator,
Settings,
LogOut,
BarChart3,
Calendar,
Bell,
Users,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3 },
{ name: 'Calendario', href: '/calendario', icon: Calendar },
{ name: 'Alertas', href: '/alertas', icon: Bell },
{ name: 'Usuarios', href: '/usuarios', icon: Users },
{ name: 'Configuración', href: '/configuracion', icon: Settings },
];
export function SidebarCompact() {
const pathname = usePathname();
const router = useRouter();
const { user, logout: clearAuth } = useAuthStore();
const [expanded, setExpanded] = useState(false);
const handleLogout = async () => {
try {
await logout();
} catch {
// Ignore errors
} finally {
clearAuth();
router.push('/login');
}
};
return (
<aside
className={cn(
'fixed left-0 top-0 z-40 h-screen border-r bg-card transition-all duration-300',
expanded ? 'w-64' : 'w-16'
)}
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
>
<div className="flex h-full flex-col">
{/* Logo */}
<div className="flex h-14 items-center border-b px-4">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="h-8 w-8 rounded bg-primary flex items-center justify-center flex-shrink-0">
<span className="text-primary-foreground font-bold text-lg">H</span>
</div>
<span className={cn(
'font-bold text-lg whitespace-nowrap transition-opacity duration-300',
expanded ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
)}>
Horux360
</span>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 px-2 py-3">
{navigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded px-2 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
title={!expanded ? item.name : undefined}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
<span className={cn(
'whitespace-nowrap transition-opacity duration-300',
expanded ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
)}>
{item.name}
</span>
</Link>
);
})}
</nav>
{/* User & Logout */}
<div className="border-t p-2">
{expanded && (
<div className="mb-2 px-2 py-1">
<p className="text-xs font-medium truncate">{user?.nombre}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
</div>
)}
<button
onClick={handleLogout}
className={cn(
'flex w-full items-center gap-3 rounded px-2 py-2 text-sm font-medium text-muted-foreground hover:bg-destructive hover:text-destructive-foreground transition-colors'
)}
title={!expanded ? 'Cerrar sesión' : undefined}
>
<LogOut className="h-5 w-5 flex-shrink-0" />
<span className={cn(
'whitespace-nowrap transition-opacity duration-300',
expanded ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
)}>
Cerrar sesión
</span>
</button>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
FileText,
Calculator,
Settings,
LogOut,
BarChart3,
Calendar,
Bell,
Users,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3 },
{ name: 'Calendario', href: '/calendario', icon: Calendar },
{ name: 'Alertas', href: '/alertas', icon: Bell },
{ name: 'Usuarios', href: '/usuarios', icon: Users },
{ name: 'Config', href: '/configuracion', icon: Settings },
];
export function SidebarFloating() {
const pathname = usePathname();
const router = useRouter();
const { user, logout: clearAuth } = useAuthStore();
const handleLogout = async () => {
try {
await logout();
} catch {
// Ignore errors
} finally {
clearAuth();
router.push('/login');
}
};
return (
<aside className="fixed left-4 top-4 bottom-4 z-40 w-64 rounded-2xl border border-border/50 bg-card/80 backdrop-blur-xl shadow-2xl shadow-primary/5">
<div className="flex h-full flex-col p-4">
{/* Logo */}
<div className="flex items-center gap-3 mb-6 px-2">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/25">
<span className="text-primary-foreground font-bold text-xl">H</span>
</div>
<div>
<span className="font-bold text-lg block">Horux360</span>
<span className="text-xs text-muted-foreground">Análisis Fiscal</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200',
isActive
? 'bg-primary/20 text-primary shadow-sm shadow-primary/20 border border-primary/30'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
>
<item.icon className={cn(
'h-5 w-5 transition-transform',
isActive && 'scale-110'
)} />
{item.name}
</Link>
);
})}
</nav>
{/* User & Logout */}
<div className="mt-4 pt-4 border-t border-border/50">
<div className="flex items-center gap-3 px-2 mb-3">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-secondary to-muted flex items-center justify-center">
<span className="text-foreground font-medium">
{user?.nombre?.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user?.nombre}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-destructive/20 hover:text-destructive transition-colors"
>
<LogOut className="h-5 w-5" />
Cerrar sesión
</button>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
FileText,
Calculator,
Settings,
LogOut,
BarChart3,
Calendar,
Bell,
Users,
ChevronDown,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3 },
{ name: 'Calendario', href: '/calendario', icon: Calendar },
{ name: 'Alertas', href: '/alertas', icon: Bell },
{ name: 'Usuarios', href: '/usuarios', icon: Users },
{ name: 'Config', href: '/configuracion', icon: Settings },
];
export function TopNav() {
const pathname = usePathname();
const router = useRouter();
const { user, logout: clearAuth } = useAuthStore();
const [userMenuOpen, setUserMenuOpen] = useState(false);
const handleLogout = async () => {
try {
await logout();
} catch {
// Ignore errors
} finally {
clearAuth();
router.push('/login');
}
};
return (
<header className="fixed top-0 left-0 right-0 z-40 h-16 border-b bg-card">
<div className="flex h-full items-center px-6">
{/* Logo */}
<Link href="/dashboard" className="flex items-center gap-2 mr-8">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-lg">H</span>
</div>
<span className="font-bold text-xl">Horux360</span>
</Link>
{/* Navigation */}
<nav className="flex-1 flex items-center gap-1">
{navigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
<span className="hidden lg:inline">{item.name}</span>
</Link>
);
})}
</nav>
{/* User Menu */}
<div className="relative">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-primary font-medium text-sm">
{user?.nombre?.charAt(0).toUpperCase()}
</span>
</div>
<span className="hidden md:inline">{user?.nombre}</span>
<ChevronDown className="h-4 w-4" />
</button>
{userMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg border bg-card shadow-lg">
<div className="p-3 border-b">
<p className="text-sm font-medium">{user?.nombre}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<LogOut className="h-4 w-4" />
Cerrar sesión
</button>
</div>
)}
</div>
</div>
</header>
);
}

View File

@@ -1,7 +1,7 @@
export const corporateTheme = {
name: 'corporate' as const,
label: 'Corporate',
layout: 'multi-panel',
layout: 'sidebar-compact' as const,
cssVars: {
'--background': '210 20% 96%',
'--foreground': '210 50% 10%',
@@ -24,9 +24,4 @@ export const corporateTheme = {
'--ring': '210 100% 25%',
'--radius': '0.25rem',
},
sidebar: {
width: '200px',
collapsible: false,
},
density: 'compact',
};

View File

@@ -1,7 +1,7 @@
export const darkTheme = {
name: 'dark' as const,
label: 'Dark',
layout: 'minimal-floating',
layout: 'sidebar-floating' as const,
cssVars: {
'--background': '0 0% 3.9%',
'--foreground': '0 0% 98%',
@@ -24,13 +24,4 @@ export const darkTheme = {
'--ring': '187.2 85.7% 53.3%',
'--radius': '0.75rem',
},
sidebar: {
width: '64px',
collapsible: false,
iconsOnly: true,
},
effects: {
blur: '10px',
glow: '0 0 20px rgba(34,211,238,0.3)',
},
};

View File

@@ -12,5 +12,6 @@ export const themes = {
export type ThemeName = keyof typeof themes;
export type Theme = (typeof themes)[ThemeName];
export type LayoutType = 'sidebar-standard' | 'topnav' | 'sidebar-compact' | 'sidebar-floating';
export { lightTheme, vibrantTheme, corporateTheme, darkTheme };

View File

@@ -1,7 +1,7 @@
export const lightTheme = {
name: 'light' as const,
label: 'Light',
layout: 'sidebar-fixed',
layout: 'sidebar-standard' as const,
cssVars: {
'--background': '0 0% 100%',
'--foreground': '222.2 84% 4.9%',
@@ -24,8 +24,4 @@ export const lightTheme = {
'--ring': '221.2 83.2% 53.3%',
'--radius': '0.5rem',
},
sidebar: {
width: '240px',
collapsible: false,
},
};

View File

@@ -1,7 +1,7 @@
export const vibrantTheme = {
name: 'vibrant' as const,
label: 'Vibrant',
layout: 'sidebar-collapsible',
layout: 'topnav' as const,
cssVars: {
'--background': '270 50% 98%',
'--foreground': '263.4 84% 6.7%',
@@ -24,8 +24,4 @@ export const vibrantTheme = {
'--ring': '262.1 83.3% 57.8%',
'--radius': '1rem',
},
sidebar: {
width: '280px',
collapsible: true,
},
};