Merge pull request 'UI Design System: shadcn/ui + Tailwind 4 + layout shell' (#2) from feature/UI-DESIGN-SYSTEM into main
This commit was merged in pull request #2.
This commit is contained in:
21
src/web/components.json
Normal file
21
src/web/components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
1643
src/web/package-lock.json
generated
1643
src/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,28 @@
|
|||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
"axios": "1.7",
|
"axios": "1.7",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hook-form": "^7.72.1",
|
||||||
"react-router-dom": "^7.14.1",
|
"react-router-dom": "^7.14.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
97
src/web/src/components/layout/AppHeader.tsx
Normal file
97
src/web/src/components/layout/AppHeader.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Menu, LogOut, User } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { ThemeToggle } from '@/components/layout/ThemeToggle'
|
||||||
|
import { SidebarNav } from '@/components/layout/AppSidebar'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
export function AppHeader() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
|
||||||
|
const initials = user?.nombre
|
||||||
|
? user.nombre
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
: 'U'
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout()
|
||||||
|
void navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center gap-4 border-b border-border bg-background px-4">
|
||||||
|
{/* Mobile sidebar trigger */}
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="lg:hidden"
|
||||||
|
aria-label="Abrir menú"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="p-0 w-64">
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Navegación</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<SidebarNav />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Right actions */}
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full"
|
||||||
|
aria-label="Menú de usuario"
|
||||||
|
>
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
Mi perfil
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
Cerrar sesión
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
src/web/src/components/layout/AppSidebar.tsx
Normal file
85
src/web/src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
ShoppingCart,
|
||||||
|
Calculator,
|
||||||
|
Zap,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
icon: React.ElementType
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||||
|
{ label: 'Ventas', href: '/ventas', icon: ShoppingCart, disabled: true },
|
||||||
|
{ label: 'Tasación', href: '/tasacion', icon: Calculator, disabled: true },
|
||||||
|
{
|
||||||
|
label: 'Integraciones',
|
||||||
|
href: '/integraciones',
|
||||||
|
icon: Zap,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Administración',
|
||||||
|
href: '/administracion',
|
||||||
|
icon: Settings,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SidebarNav() {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-full flex-col bg-background border-r border-border">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="flex h-14 items-center px-4 border-b border-border">
|
||||||
|
<span className="text-base font-semibold tracking-tight text-foreground">
|
||||||
|
SIG-CM 2.0
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 overflow-y-auto py-4 px-2 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const isActive = pathname === item.href && !item.disabled
|
||||||
|
|
||||||
|
return item.disabled ? (
|
||||||
|
<div
|
||||||
|
key={item.href}
|
||||||
|
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground cursor-not-allowed opacity-60"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Próximamente
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/web/src/components/layout/ThemeToggle.tsx
Normal file
30
src/web/src/components/layout/ThemeToggle.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Moon, Sun } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useTheme } from '@/hooks/useTheme'
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={
|
||||||
|
resolvedTheme === 'dark'
|
||||||
|
? 'Cambiar a modo claro'
|
||||||
|
: 'Cambiar a modo oscuro'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{resolvedTheme === 'dark' ? (
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/web/src/components/ui/alert.tsx
Normal file
59
src/web/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground',
|
||||||
|
destructive:
|
||||||
|
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = 'Alert'
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertTitle.displayName = 'AlertTitle'
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = 'AlertDescription'
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
48
src/web/src/components/ui/avatar.tsx
Normal file
48
src/web/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn('aspect-square h-full w-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
36
src/web/src/components/ui/badge.tsx
Normal file
36
src/web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
56
src/web/src/components/ui/button.tsx
Normal file
56
src/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
79
src/web/src/components/ui/card.tsx
Normal file
79
src/web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl font-semibold leading-none tracking-tight',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
198
src/web/src/components/ui/dropdown-menu.tsx
Normal file
198
src/web/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-sm font-semibold',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
176
src/web/src/components/ui/form.tsx
Normal file
176
src/web/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} from 'react-hook-form'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error('useFormField should be used within <FormField>')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = 'FormItem'
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && 'text-destructive', className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = 'FormLabel'
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = 'FormControl'
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = 'FormDescription'
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn('text-sm font-medium text-destructive', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = 'FormMessage'
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
24
src/web/src/components/ui/input.tsx
Normal file
24
src/web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
src/web/src/components/ui/label.tsx
Normal file
24
src/web/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
29
src/web/src/components/ui/separator.tsx
Normal file
29
src/web/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
138
src/web/src/components/ui/sheet.tsx
Normal file
138
src/web/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||||
|
bottom:
|
||||||
|
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||||
|
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||||
|
right:
|
||||||
|
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
))
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col space-y-2 text-center sm:text-left',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetHeader.displayName = 'SheetHeader'
|
||||||
|
|
||||||
|
const SheetFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetFooter.displayName = 'SheetFooter'
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
15
src/web/src/components/ui/skeleton.tsx
Normal file
15
src/web/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -1,66 +1,90 @@
|
|||||||
import type { FormEvent } from 'react'
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string().min(1, 'El usuario es requerido'),
|
||||||
|
password: z.string().min(1, 'La contraseña es requerida'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type LoginFormValues = z.infer<typeof loginSchema>
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
onSubmit: (username: string, password: string) => void
|
onSubmit: (username: string, password: string) => void
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginForm({ onSubmit, isLoading, error }: LoginFormProps) {
|
export function LoginForm({ onSubmit, isLoading }: LoginFormProps) {
|
||||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
const form = useForm<LoginFormValues>({
|
||||||
e.preventDefault()
|
resolver: zodResolver(loginSchema),
|
||||||
const form = e.currentTarget
|
defaultValues: { username: '', password: '' },
|
||||||
const data = new FormData(form)
|
})
|
||||||
const username = data.get('username') as string
|
|
||||||
const password = data.get('password') as string
|
function handleSubmit(values: LoginFormValues) {
|
||||||
onSubmit(username, password)
|
onSubmit(values.username, values.password)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full max-w-sm">
|
<Form {...form}>
|
||||||
<div className="flex flex-col gap-1">
|
<form
|
||||||
<label htmlFor="username" className="text-sm font-medium text-gray-700">
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
Usuario
|
className="space-y-4"
|
||||||
</label>
|
noValidate
|
||||||
<input
|
>
|
||||||
id="username"
|
<FormField
|
||||||
|
control={form.control}
|
||||||
name="username"
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Usuario</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
placeholder="Ingresá tu usuario"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<FormField
|
||||||
<label htmlFor="password" className="text-sm font-medium text-gray-700">
|
control={form.control}
|
||||||
Contraseña
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Contraseña</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
required
|
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
placeholder="Ingresá tu contraseña"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
{error && (
|
</FormItem>
|
||||||
<p role="alert" className="text-sm text-red-600">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={isLoading} className="w-full">
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Ingresando...' : 'Ingresar'}
|
{isLoading ? 'Ingresando...' : 'Ingresar'}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
import { useLogin } from '../hooks/useLogin'
|
import { useLogin } from '../hooks/useLogin'
|
||||||
import { LoginForm } from '../components/LoginForm'
|
import { LoginForm } from '../components/LoginForm'
|
||||||
import { isAxiosError } from 'axios'
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
|
||||||
|
function resolveErrorMessage(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string }
|
||||||
|
return data.error ?? 'Error al iniciar sesión'
|
||||||
|
}
|
||||||
|
return 'Error al iniciar sesión'
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -18,27 +36,25 @@ export function LoginPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveErrorMessage(err: unknown): string | null {
|
const errorMessage = resolveErrorMessage(error)
|
||||||
if (!err) return null
|
|
||||||
if (isAxiosError(err) && err.response?.data) {
|
|
||||||
const data = err.response.data as { error?: string }
|
|
||||||
return data.error ?? 'Error al iniciar sesión'
|
|
||||||
}
|
|
||||||
return 'Error al iniciar sesión'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
<Card className="w-full max-w-sm">
|
||||||
<div className="rounded-lg bg-white p-8 shadow-md w-full max-w-sm">
|
<CardHeader className="space-y-1">
|
||||||
<h1 className="mb-6 text-center text-2xl font-semibold text-gray-900">
|
<CardTitle className="text-2xl text-center">SIG-CM 2.0</CardTitle>
|
||||||
SIG-CM2
|
<CardDescription className="text-center">
|
||||||
</h1>
|
Iniciar sesión
|
||||||
<LoginForm
|
</CardDescription>
|
||||||
onSubmit={handleSubmit}
|
</CardHeader>
|
||||||
isLoading={isPending}
|
<CardContent className="space-y-4">
|
||||||
error={resolveErrorMessage(error)}
|
{errorMessage && (
|
||||||
/>
|
<Alert variant="destructive">
|
||||||
</div>
|
<AlertCircle className="h-4 w-4" />
|
||||||
</div>
|
<AlertDescription>{errorMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<LoginForm onSubmit={handleSubmit} isLoading={isPending} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/web/src/hooks/useTheme.ts
Normal file
42
src/web/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'theme'
|
||||||
|
|
||||||
|
function getSystemTheme(): 'light' | 'dark' {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
const resolved = theme === 'system' ? getSystemTheme() : theme
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.remove('light', 'dark')
|
||||||
|
root.classList.add(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
||||||
|
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
return 'system'
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolvedTheme: 'light' | 'dark' =
|
||||||
|
theme === 'system' ? getSystemTheme() : theme
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
function setTheme(next: Theme) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, next)
|
||||||
|
setThemeState(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { theme, setTheme, resolvedTheme }
|
||||||
|
}
|
||||||
@@ -1,81 +1,115 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@import "@fontsource/inter/400.css";
|
||||||
|
@import "@fontsource/inter/500.css";
|
||||||
|
@import "@fontsource/inter/600.css";
|
||||||
|
@import "@fontsource/jetbrains-mono/400.css";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--text: #9ca3af;
|
--background: 0 0% 100%;
|
||||||
--text-h: #f3f4f6;
|
--foreground: 240 10% 3.9%;
|
||||||
--bg: #16171d;
|
--card: 0 0% 100%;
|
||||||
--border: #2e303a;
|
--card-foreground: 240 10% 3.9%;
|
||||||
--code-bg: #1f2028;
|
--popover: 0 0% 100%;
|
||||||
--accent: #c084fc;
|
--popover-foreground: 240 10% 3.9%;
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
--primary: 240 5.9% 10%;
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
--primary-foreground: 0 0% 98%;
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
--secondary: 240 4.8% 95.9%;
|
||||||
--shadow:
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 240 5.9% 10%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--font-sans: "Inter", system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
#social .button-icon {
|
.dark {
|
||||||
filter: invert(1) brightness(2);
|
--background: 240 10% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 240 10% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
--ring: 240 4.9% 83.9%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
@layer base {
|
||||||
width: 1126px;
|
* {
|
||||||
max-width: 100%;
|
border-color: hsl(var(--border));
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100svh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2 {
|
h2,
|
||||||
font-family: var(--heading);
|
h3,
|
||||||
font-weight: 500;
|
h4,
|
||||||
color: var(--text-h);
|
h5,
|
||||||
}
|
h6 {
|
||||||
|
font-family: var(--font-sans);
|
||||||
h1 {
|
|
||||||
font-size: 56px;
|
|
||||||
letter-spacing: -1.68px;
|
|
||||||
margin: 32px 0;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
code,
|
||||||
.counter {
|
pre,
|
||||||
font-family: var(--mono);
|
kbd,
|
||||||
display: inline-flex;
|
samp {
|
||||||
border-radius: 4px;
|
font-family: var(--font-mono);
|
||||||
color: var(--text-h);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
@theme inline {
|
||||||
font-size: 15px;
|
--color-background: hsl(var(--background));
|
||||||
line-height: 135%;
|
--color-foreground: hsl(var(--foreground));
|
||||||
padding: 4px 8px;
|
--color-card: hsl(var(--card));
|
||||||
background: var(--code-bg);
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
--color-popover: hsl(var(--popover));
|
||||||
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
|
--color-primary: hsl(var(--primary));
|
||||||
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
|
--color-secondary: hsl(var(--secondary));
|
||||||
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
|
--color-muted: hsl(var(--muted));
|
||||||
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
|
--color-accent: hsl(var(--accent));
|
||||||
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { AppHeader } from '@/components/layout/AppHeader'
|
||||||
|
import { SidebarNav } from '@/components/layout/AppSidebar'
|
||||||
|
|
||||||
interface ProtectedLayoutProps {
|
interface ProtectedLayoutProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -6,8 +8,17 @@ interface ProtectedLayoutProps {
|
|||||||
|
|
||||||
export function ProtectedLayout({ children }: ProtectedLayoutProps) {
|
export function ProtectedLayout({ children }: ProtectedLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="flex h-svh overflow-hidden bg-background text-foreground">
|
||||||
{children}
|
{/* Desktop sidebar */}
|
||||||
|
<div className="hidden lg:flex lg:w-60 lg:flex-col lg:shrink-0">
|
||||||
|
<SidebarNav />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main column */}
|
||||||
|
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||||
|
<AppHeader />
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface PublicLayoutProps {
|
|||||||
|
|
||||||
export function PublicLayout({ children }: PublicLayoutProps) {
|
export function PublicLayout({ children }: PublicLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-svh bg-muted/40 flex items-center justify-center p-4">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
6
src/web/src/lib/utils.ts
Normal file
6
src/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -1,8 +1,109 @@
|
|||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
Calculator,
|
||||||
|
Zap,
|
||||||
|
Settings,
|
||||||
|
LayoutDashboard,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
interface ModuleCard {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: React.ElementType
|
||||||
|
available: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules: ModuleCard[] = [
|
||||||
|
{
|
||||||
|
title: 'Dashboard',
|
||||||
|
description: 'Vista general del sistema y métricas principales.',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ventas',
|
||||||
|
description: 'Gestión de ventas, presupuestos y seguimiento de clientes.',
|
||||||
|
icon: TrendingUp,
|
||||||
|
available: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tasación',
|
||||||
|
description: 'Herramientas de valuación y tasación de propiedades.',
|
||||||
|
icon: Calculator,
|
||||||
|
available: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Integraciones',
|
||||||
|
description: 'Conectores con portales inmobiliarios y servicios externos.',
|
||||||
|
icon: Zap,
|
||||||
|
available: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Administración',
|
||||||
|
description: 'Gestión de usuarios, roles y configuración del sistema.',
|
||||||
|
icon: Settings,
|
||||||
|
available: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Dashboard</h1>
|
{/* Welcome */}
|
||||||
<p className="mt-2 text-gray-600">Bienvenido al SIG-CM2.</p>
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||||
|
Panel principal
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{user?.nombre
|
||||||
|
? `Bienvenido, ${user.nombre}. Seleccioná un módulo para comenzar.`
|
||||||
|
: 'Bienvenido al SIG-CM 2.0. Seleccioná un módulo para comenzar.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Module grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{modules.map((mod) => {
|
||||||
|
const Icon = mod.icon
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={mod.title}
|
||||||
|
className={
|
||||||
|
mod.available ? 'hover:shadow-md transition-shadow' : 'opacity-75'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">{mod.title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>{mod.description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
{mod.available ? (
|
||||||
|
<Badge>Disponible</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Próximamente</Badge>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "es2023",
|
"target": "es2023",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"types": ["vite/client", "vitest/globals"],
|
"types": ["vite/client", "vitest/globals"],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -8,4 +9,9 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ['./src/tests/setup.ts'],
|
setupFiles: ['./src/tests/setup.ts'],
|
||||||
|
env: {
|
||||||
|
VITE_API_URL: 'http://localhost:5000',
|
||||||
|
},
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json', 'html'],
|
reporter: ['text', 'json', 'html'],
|
||||||
|
|||||||
Reference in New Issue
Block a user