feat!: complete visual redesign → 1.0.0

BREAKING: version bump to 1.0.0

New design system:
- Emerald/teal color palette with semantic tokens (success, warning, destructive)
- Light/dark mode toggle with localStorage persistence and anti-FOUC
- CSS custom properties via Tailwind darkMode: 'class'
- Zero new dependencies — all primitives hand-rolled

Foundation:
- 70+ inline SVG icons (no external icon library)
- ThemeProvider + useTheme hook
- cn() utility for conditional classes

UI primitives (web/src/components/ui/):
- Button (6 variants, 4 sizes)
- Input, Label, Card with slots, Badge (6 variants + sizes)
- Switch (animated), Checkbox, Separator
- Skeleton, Spinner, EmptyState
- Toast system with auto-dismiss (success/error/warning/info)
- Dialog with focus trap + escape/click-outside
- ConfirmDialog replacing native confirm()
- DropdownMenu with keyboard support

Layout & navigation:
- Sidebar with icon+label nav, active indicator, responsive drawer on mobile
- Topbar for <lg viewports with hamburger
- PageHeader with title/description/actions pattern
- UserMenu with avatar + dropdown (change password, logout)
- DirtyBanner redesigned with semantic styling

Pages redesigned:
- Login: centered card with gradient background
- Dashboard: 4 KPI tiles, disk usage bars, services grid, recent activity
- Users: table with search, empty state, edit modal
- Files: breadcrumbs, drag-drop upload zone, SVG file icons, rename/delete
- Samba: card list with badges, edit modal with PathField
- NFS: card list with client chips, client editor
- Storage: tabs (Mover/SnapRAID/Jobs), real-time job log streaming
- Log: timeline grouped by date with filters
- Settings: watched mounts, import actions, system info
- NotFound: friendly 404 page

Polish:
- Toast notifications replace all native alert()/confirm()
- Tailwind animations (dialog-enter, toast-enter/leave, dropdown-enter)
- Custom scrollbar styling
- Focus-visible rings
- Skeleton loading states across all pages
This commit is contained in:
2026-07-08 01:24:51 -04:00
parent 56e5fabe8d
commit 10d0769071
40 changed files with 3742 additions and 2458 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
BINARY=nasctl BINARY=nasctl
VERSION?=0.8.3 VERSION?=1.0.0
GO?=go GO?=go
LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_FLAGS=CGO_ENABLED=0 BUILD_FLAGS=CGO_ENABLED=0
+20
View File
@@ -4,6 +4,26 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nasctl</title> <title>nasctl</title>
<meta name="theme-color" id="theme-color-meta" content="#0c1220" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" id="theme-color-meta-light" content="#ffffff" media="(prefers-color-scheme: light)" />
<script>
(function() {
var stored = localStorage.getItem("nasctl-theme");
var theme = stored === "light" ? "light" : "dark";
if (theme === "light") {
document.documentElement.classList.add("light");
}
var meta = document.getElementById("theme-color-meta");
var metaLight = document.getElementById("theme-color-meta-light");
if (theme === "dark") {
if (meta) meta.content = "#0c1220";
if (metaLight) metaLight.content = "transparent";
} else {
if (meta) meta.content = "transparent";
if (metaLight) meta.content = "#ffffff";
}
})();
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+12 -2
View File
@@ -12,6 +12,7 @@ import Log from "./pages/Log";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
import Files from "./pages/Files"; import Files from "./pages/Files";
import Storage from "./pages/Storage"; import Storage from "./pages/Storage";
import NotFound from "./pages/NotFound";
type AuthState = { loading: boolean; authenticated: boolean; username: string }; type AuthState = { loading: boolean; authenticated: boolean; username: string };
@@ -26,7 +27,16 @@ export default function App() {
}, []); }, []);
if (auth.loading) { if (auth.loading) {
return <div className="flex min-h-screen items-center justify-center text-slate-400">Cargando...</div>; return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<svg className="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span>Cargando</span>
</div>
</div>
);
} }
if (!auth.authenticated) { if (!auth.authenticated) {
@@ -60,7 +70,7 @@ export default function App() {
<Route path="/storage" element={<Storage />} /> <Route path="/storage" element={<Storage />} />
<Route path="/log" element={<Log />} /> <Route path="/log" element={<Log />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<NotFound />} />
</Route> </Route>
</Routes> </Routes>
</DirtyProvider> </DirtyProvider>
+21 -13
View File
@@ -1,27 +1,35 @@
import { AlertTriangle } from "../lib/icons";
import { useDirty } from "../DirtyContext"; import { useDirty } from "../DirtyContext";
import { Button } from "./ui/Button";
export default function DirtyBanner() { export function DirtyBanner() {
const { modules, apply, applying, error } = useDirty(); const { modules, apply, applying, error } = useDirty();
if (modules.length === 0 && !error) return null; if (modules.length === 0 && !error) return null;
return ( return (
<div className="sticky top-0 z-10"> <div className="sticky top-0 z-20">
{modules.length > 0 && ( {modules.length > 0 && (
<div className="flex items-center justify-between gap-4 border-b border-amber-700/50 bg-amber-500/15 px-6 py-3 text-amber-200"> <div className="flex items-center justify-between gap-4 border-b border-warning/30 bg-warning/10 px-6 py-3">
<span className="text-sm"> <div className="flex items-center gap-3">
Tienes cambios sin aplicar:{" "} <AlertTriangle size={16} className="text-warning shrink-0" />
<strong className="font-semibold"> <div className="flex items-center gap-2 text-sm">
{modules.map((m) => m.module).join(", ")} <span className="text-foreground">
</strong> Tienes cambios sin aplicar:
</span> </span>
<button className="btn-primary" onClick={apply} disabled={applying}> <span className="font-medium text-foreground">
{applying ? "Aplicando..." : "Aplicar cambios"} {modules.map((m) => m.module).join(", ")}
</button> </span>
</div>
</div>
<Button size="sm" onClick={apply} disabled={applying} loading={applying}>
{applying ? "Aplicando…" : "Aplicar cambios"}
</Button>
</div> </div>
)} )}
{error && ( {error && (
<div className="border-b border-red-700/50 bg-red-500/15 px-6 py-3 text-sm text-red-200"> <div className="flex items-center gap-3 border-b border-destructive/30 bg-destructive/10 px-6 py-3 text-sm">
Error al aplicar: {error} <AlertTriangle size={16} className="text-destructive shrink-0" />
<span className="text-destructive flex-1">Error al aplicar: {error}</span>
</div> </div>
)} )}
</div> </div>
+17 -61
View File
@@ -1,71 +1,27 @@
import { useEffect, useState } from "react"; import { Outlet } from "react-router-dom";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { Sidebar } from "./Sidebar";
import { api, VersionInfo } from "../api"; import { Topbar } from "./Topbar";
import DirtyBanner from "./DirtyBanner"; import { DirtyBanner } from "./DirtyBanner";
const navItems = [ interface LayoutProps {
{ to: "/", label: "Dashboard", end: true }, username: string;
{ to: "/users", label: "Usuarios" }, onLogout: () => void;
{ to: "/files", label: "Archivos" }, }
{ to: "/samba", label: "SMB / Samba" },
{ to: "/nfs", label: "NFS" },
{ to: "/storage", label: "Almacenamiento" },
{ to: "/log", label: "Historial" },
{ to: "/settings", label: "Ajustes" },
];
export default function Layout({ username, onLogout }: { username: string; onLogout: () => void }) {
const navigate = useNavigate();
const [version, setVersion] = useState<VersionInfo | null>(null);
useEffect(() => {
api.version().then(setVersion).catch(() => {});
}, []);
async function handleLogout() {
await api.logout();
onLogout();
navigate("/login");
}
export default function Layout({ username, onLogout }: LayoutProps) {
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen bg-background">
<aside className="flex w-60 flex-col border-r border-slate-800 bg-slate-900/80"> <div className="hidden lg:flex lg:w-64 lg:shrink-0">
<div className="px-6 py-5 text-xl font-bold tracking-tight text-white"> <Sidebar username={username} onLogout={onLogout} />
nas<span className="text-brand-500">ctl</span>
</div> </div>
<nav className="flex-1 space-y-1 px-3">
{navItems.map((item) => ( <div className="flex flex-1 flex-col min-w-0">
<NavLink <Topbar username={username} onLogout={onLogout} />
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`block rounded-md px-3 py-2 text-sm font-medium transition-colors ${
isActive ? "bg-brand-600 text-white" : "text-slate-300 hover:bg-slate-800"
}`
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="border-t border-slate-800 px-4 py-4 text-sm text-slate-400">
<div className="mb-2 truncate">Sesión: <span className="text-slate-200">{username}</span></div>
<button className="btn-ghost w-full" onClick={handleLogout}>
Cerrar sesión
</button>
<div className="mt-2 border-t border-slate-800 pt-2 text-center font-mono text-xs text-slate-600">
v{version?.version ?? "dev"}{version?.commit && ` · ${version.commit}`}
</div>
</div>
</aside>
<main className="flex-1">
<DirtyBanner /> <DirtyBanner />
<div className="p-8"> <main className="flex-1 p-6 lg:p-8">
<Outlet /> <Outlet />
</div>
</main> </main>
</div> </div>
</div>
); );
} }
+23
View File
@@ -0,0 +1,23 @@
import { ReactNode } from "react";
import { cn } from "../lib/cn";
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
className?: string;
}
export function PageHeader({ title, description, actions, className }: PageHeaderProps) {
return (
<div className={cn("flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between mb-6", className)}>
<div>
<h1 className="text-2xl font-bold text-foreground tracking-tight">{title}</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2 mt-3 sm:mt-0">{actions}</div>}
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { NavLink } from "react-router-dom";
import { api } from "../api";
import { ThemeToggle } from "./ThemeToggle";
import { UserMenu } from "./UserMenu";
import { VersionInfo } from "../api";
import {
Home,
Users,
FolderTree,
Share2,
Network,
HardDrive,
History,
Settings,
} from "../lib/icons";
import { cn } from "../lib/cn";
import { useEffect, useState } from "react";
const navItems = [
{ to: "/", label: "Dashboard", end: true, Icon: Home },
{ to: "/users", label: "Usuarios", Icon: Users },
{ to: "/files", label: "Archivos", Icon: FolderTree },
{ to: "/samba", label: "SMB / Samba", Icon: Share2 },
{ to: "/nfs", label: "NFS", Icon: Network },
{ to: "/storage", label: "Almacenamiento", Icon: HardDrive },
{ to: "/log", label: "Historial", Icon: History },
{ to: "/settings", label: "Ajustes", Icon: Settings },
];
interface SidebarProps {
username: string;
onLogout: () => void;
onClose?: () => void;
variant?: "drawer" | "fixed";
}
export function Sidebar({ username, onLogout, onClose, variant = "fixed" }: SidebarProps) {
const [version, setVersion] = useState<VersionInfo | null>(null);
useEffect(() => {
api.version().then(setVersion).catch(() => {});
}, []);
return (
<div
className={cn(
"flex h-full flex-col bg-card border-r border-border",
variant === "drawer" && "w-64"
)}
>
<div className="flex items-center justify-between px-5 py-5 border-b border-border">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary/20 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<line x1="6" y1="6" x2="6.01" y2="6" strokeWidth="3" />
<line x1="6" y1="18" x2="6.01" y2="18" strokeWidth="3" />
</svg>
</div>
<div>
<span className="font-bold text-lg text-foreground tracking-tight">nas</span>
<span className="font-bold text-lg text-primary tracking-tight">ctl</span>
</div>
</div>
{variant === "drawer" && onClose && (
<button
onClick={onClose}
className="lg:hidden p-1 rounded text-muted-foreground hover:text-foreground"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto scrollbar-thin">
{navItems.map(({ to, label, end, Icon }) => (
<NavLink
key={to}
to={to}
end={end}
onClick={variant === "drawer" ? onClose : undefined}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-all duration-150",
"hover:bg-accent hover:text-accent-foreground",
isActive
? "bg-primary/15 text-primary"
: "text-muted-foreground"
)
}
>
{({ isActive }) => (
<>
<Icon
size={18}
className={cn(
"shrink-0 transition-colors",
isActive ? "text-primary" : "text-muted-foreground"
)}
/>
<span className="flex-1">{label}</span>
{isActive && (
<div className="h-1.5 w-1.5 rounded-full bg-primary shrink-0" />
)}
</>
)}
</NavLink>
))}
</nav>
<div className="border-t border-border px-3 py-4 space-y-3">
<div className="flex items-center justify-between px-2">
<ThemeToggle size={16} />
</div>
<UserMenu username={username} onLogout={onLogout} />
<div className="px-2 text-center">
<div className="font-mono text-xs text-muted-foreground">
v{version?.version ?? "dev"}
{version?.commit && (
<span className="ml-1 text-muted-foreground/60">{version.commit}</span>
)}
</div>
</div>
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
type Theme = "dark" | "light";
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>("dark");
useEffect(() => {
const stored = localStorage.getItem("nasctl-theme") as Theme | null;
if (stored === "dark" || stored === "light") {
setThemeState(stored);
} else {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
setThemeState(prefersDark ? "dark" : "light");
}
}, []);
useEffect(() => {
const root = document.documentElement;
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.remove("light");
}
localStorage.setItem("nasctl-theme", theme);
}, [theme]);
const setTheme = (t: Theme) => setThemeState(t);
const toggleTheme = () => setThemeState((prev) => (prev === "dark" ? "light" : "dark"));
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
+16
View File
@@ -0,0 +1,16 @@
import { useTheme } from "./ThemeProvider";
import { Sun, Moon } from "../lib/icons";
export function ThemeToggle({ size = 16 }: { size?: number }) {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={theme === "dark" ? "Cambiar a modo claro" : "Cambiar a modo oscuro"}
>
{theme === "dark" ? <Sun size={size} /> : <Moon size={size} />}
</button>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from "react";
import { Menu } from "../lib/icons";
import { Sidebar } from "./Sidebar";
import { useLocation } from "react-router-dom";
const pageTitles: Record<string, string> = {
"/": "Dashboard",
"/users": "Usuarios",
"/files": "Archivos",
"/samba": "SMB / Samba",
"/nfs": "NFS",
"/storage": "Almacenamiento",
"/log": "Historial",
"/settings": "Ajustes",
};
interface TopbarProps {
username: string;
onLogout: () => void;
}
export function Topbar({ username, onLogout }: TopbarProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
const title = pageTitles[location.pathname] ?? "nasctl";
return (
<>
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b border-border bg-card/80 backdrop-blur px-4 lg:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<Menu size={20} />
<span className="sr-only">Abrir menú</span>
</button>
<div className="flex-1">
<span className="font-semibold text-foreground">{title}</span>
</div>
</header>
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-50 w-64 animate-in slide-in-from-left duration-200">
<Sidebar
username={username}
onLogout={onLogout}
onClose={() => setSidebarOpen(false)}
variant="drawer"
/>
</div>
</div>
)}
</>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { LogOut, Key } from "../lib/icons";
import { DropdownMenu } from "./ui/DropdownMenu";
import { useState } from "react";
import { Dialog, DialogHeader, DialogTitle, DialogContent, DialogFooter } from "./ui/Dialog";
import { Button } from "./ui/Button";
import { Input } from "./ui/Input";
import { Label } from "./ui/Label";
import { api } from "../api";
import { useToast } from "./ui/Toast";
interface UserMenuProps {
username: string;
onLogout: () => void;
}
export function UserMenu({ username, onLogout }: UserMenuProps) {
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
const initials = username.slice(0, 2).toUpperCase();
async function handleChangePassword() {
setLoading(true);
setError(null);
try {
await api.changePassword(oldPassword, newPassword);
toast({ type: "success", title: "Contraseña actualizada" });
setShowPasswordDialog(false);
setOldPassword("");
setNewPassword("");
} catch (e) {
setError(e instanceof Error ? e.message : "Error al cambiar contraseña");
} finally {
setLoading(false);
}
}
return (
<>
<DropdownMenu
trigger={
<button className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/20 text-primary text-xs font-semibold">
{initials}
</div>
<div className="flex-1 truncate text-left">
<div className="font-medium text-foreground text-xs truncate">{username}</div>
<div className="text-xs text-muted-foreground truncate">Sesión activa</div>
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="shrink-0">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
}
items={[
{
label: "Cambiar contraseña",
icon: <Key size={14} />,
onClick: () => setShowPasswordDialog(true),
},
{ separator: true },
{
label: "Cerrar sesión",
icon: <LogOut size={14} />,
onClick: onLogout,
destructive: true,
},
]}
align="left"
/>
<Dialog open={showPasswordDialog} onClose={() => setShowPasswordDialog(false)} maxWidth="sm">
<DialogHeader>
<DialogTitle>Cambiar contraseña</DialogTitle>
</DialogHeader>
<DialogContent>
<div className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>
)}
<div>
<Label htmlFor="old-password">Contraseña actual</Label>
<Input
id="old-password"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className="mt-1.5"
/>
</div>
<div>
<Label htmlFor="new-password">Nueva contraseña</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="mt-1.5"
/>
</div>
</div>
</DialogContent>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowPasswordDialog(false)}>
Cancelar
</Button>
<Button
variant="default"
onClick={handleChangePassword}
loading={loading}
disabled={!oldPassword || !newPassword}
>
Guardar
</Button>
</DialogFooter>
</Dialog>
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { HTMLAttributes } from "react";
import { cn } from "../../lib/cn";
export type BadgeVariant = "default" | "secondary" | "success" | "warning" | "destructive" | "outline";
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
size?: "sm" | "md";
}
const variantClasses: Record<BadgeVariant, string> = {
default: "bg-primary/15 text-primary border-primary/20",
secondary: "bg-secondary text-secondary-foreground border-secondary",
success: "bg-success/15 text-success border-success/20",
warning: "bg-warning/15 text-warning border-warning/20",
destructive: "bg-destructive/15 text-destructive border-destructive/20",
outline: "bg-transparent text-foreground border-border",
};
const sizeClasses: Record<NonNullable<BadgeProps["size"]>, string> = {
sm: "text-[10px] px-1.5 py-px",
md: "text-xs px-2.5 py-0.5",
};
export function Badge({ className, variant = "default", size = "md", ...props }: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center rounded-full border font-medium transition-colors",
variantClasses[variant],
sizeClasses[size],
className
)}
{...props}
/>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "../../lib/cn";
import { Loader2 } from "../../lib/icons";
export type ButtonVariant = "default" | "secondary" | "ghost" | "outline" | "destructive" | "link";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
}
const variantClasses: Record<ButtonVariant, string> = {
default: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 active:bg-secondary/70",
ghost: "hover:bg-accent hover:text-accent-foreground active:bg-accent/80",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground active:bg-accent/80",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/80",
link: "text-primary underline-offset-4 hover:underline active:text-primary/80",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
icon: "h-10 w-10",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "md", loading, disabled, children, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
variantClasses[variant],
sizeClasses[size],
className
)}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 size={14} className="animate-spin" />}
{children}
</button>
);
}
);
Button.displayName = "Button";
+52
View File
@@ -0,0 +1,52 @@
import { HTMLAttributes, forwardRef } from "react";
import { cn } from "../../lib/cn";
interface CardProps extends HTMLAttributes<HTMLDivElement> {}
export const Card = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border border-border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
export const CardContent = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
export const CardFooter = forwardRef<HTMLDivElement, CardProps>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
));
CardFooter.displayName = "CardFooter";
+45
View File
@@ -0,0 +1,45 @@
import { InputHTMLAttributes, forwardRef } from "react";
import { cn } from "../../lib/cn";
interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ className, label, id, ...props }, ref) => {
return (
<label className="inline-flex items-center gap-2 cursor-pointer">
<div className="relative">
<input
ref={ref}
type="checkbox"
id={id}
className="peer sr-only"
{...props}
/>
<div className={cn(
"h-4 w-4 shrink-0 rounded border border-input bg-background transition-colors",
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background",
"peer-checked:bg-primary peer-checked:border-primary",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
)}>
<svg
viewBox="0 0 14 14"
className="h-4 w-4 text-primary-foreground absolute inset-0 m-auto opacity-0 peer-checked:opacity-100 transition-opacity"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="2.5 7 5.5 10 11.5 4" />
</svg>
</div>
</div>
{label && (
<span className="text-sm text-foreground cursor-pointer select-none">{label}</span>
)}
</label>
);
}
);
Checkbox.displayName = "Checkbox";
+64
View File
@@ -0,0 +1,64 @@
import { AlertTriangle } from "../../lib/icons";
import { Button } from "./Button";
import { Dialog, DialogTitle, DialogDescription, DialogFooter } from "./Dialog";
interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
loading?: boolean;
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = "Confirmar",
cancelLabel = "Cancelar",
destructive = false,
loading = false,
}: ConfirmDialogProps) {
function handleConfirm() {
onConfirm();
}
function handleClose() {
onClose();
}
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm">
<div className="flex items-start gap-4 p-6">
<div className={`rounded-full p-2 ${destructive ? "bg-destructive/10" : "bg-primary/10"}`}>
<AlertTriangle
size={20}
className={destructive ? "text-destructive" : "text-primary"}
/>
</div>
<div className="flex-1">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-2">{description}</DialogDescription>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleClose} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={destructive ? "destructive" : "default"}
onClick={handleConfirm}
loading={loading}
>
{confirmLabel}
</Button>
</DialogFooter>
</Dialog>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { ReactNode, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { X } from "../../lib/icons";
import { cn } from "../../lib/cn";
interface DialogProps {
open: boolean;
onClose: () => void;
children: ReactNode;
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "full";
hideClose?: boolean;
}
const maxWidthMap: Record<NonNullable<DialogProps["maxWidth"]>, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-2xl",
"3xl": "max-w-3xl",
"4xl": "max-w-4xl",
full: "max-w-full",
};
export function Dialog({ open, onClose, children, maxWidth = "lg", hideClose }: DialogProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "Tab" && contentRef.current) {
const focusable = contentRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}
},
[onClose]
);
useEffect(() => {
if (open) {
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
const firstFocusable = contentRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
} else {
document.body.style.overflow = "";
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
};
}, [open, handleKeyDown]);
if (!open) return null;
return createPortal(
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200" />
<div
ref={contentRef}
className={cn(
"relative w-full rounded-lg border border-border bg-card shadow-2xl animate-dialog-enter",
maxWidthMap[maxWidth]
)}
role="dialog"
aria-modal="true"
>
{!hideClose && (
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-sm text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring z-10"
>
<X size={18} />
<span className="sr-only">Cerrar</span>
</button>
)}
{children}
</div>
</div>,
document.body
);
}
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5 p-6 pb-0", className)} {...props} />;
}
export function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h2 className={cn("text-lg font-semibold text-card-foreground", className)} {...props} />;
}
export function DialogDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
export function DialogContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-6 py-4", className)} {...props} />;
}
export function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex justify-end gap-2 p-6 pt-4", className)} {...props} />;
}
+128
View File
@@ -0,0 +1,128 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "../../lib/cn";
interface DropdownItem {
label: string;
onClick?: () => void;
href?: string;
disabled?: boolean;
destructive?: boolean;
icon?: ReactNode;
separator?: never;
}
interface DropdownSeparator {
separator: true;
label?: never;
onClick?: never;
href?: never;
disabled?: never;
destructive?: never;
icon?: never;
}
type DropdownItemType = DropdownItem | DropdownSeparator;
interface DropdownMenuProps {
trigger: ReactNode;
items: DropdownItemType[];
align?: "left" | "right";
}
export function DropdownMenu({ trigger, items, align = "right" }: DropdownMenuProps) {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
function handleTrigger() {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 4,
left: rect.left,
});
}
setOpen((prev) => !prev);
}
function handleItemClick(item: DropdownItem) {
if (item.disabled) return;
item.onClick?.();
setOpen(false);
}
return (
<>
<button ref={triggerRef} onClick={handleTrigger} type="button" className="inline-flex">
{trigger}
</button>
{open && position
? createPortal(
<div
ref={menuRef}
className={cn(
"fixed z-[60] min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 shadow-lg animate-dropdown-enter",
position.top < 100 ? "origin-top-left" : "origin-top-right"
)}
style={{ top: position.top, [align === "right" ? "right" : "left"]: align === "right" ? window.innerWidth - position.left : position.left }}
>
{items.map((item, i) => {
if ("separator" in item && item.separator) {
return <div key={i} className="my-1 h-px bg-border" />;
}
const typedItem = item as DropdownItem;
return (
<button
key={i}
disabled={typedItem.disabled}
onClick={() => handleItemClick(typedItem)}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
typedItem.destructive
? "text-destructive hover:bg-destructive/10"
: "text-popover-foreground hover:bg-accent",
typedItem.disabled && "opacity-50 cursor-not-allowed"
)}
>
{typedItem.icon && (
<span className="w-4 h-4 shrink-0">{typedItem.icon}</span>
)}
{typedItem.label}
</button>
);
})}
</div>,
document.body
)
: null}
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { ReactNode } from "react";
import { Button } from "./Button";
interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
className?: string;
}
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={`flex flex-col items-center justify-center gap-3 py-12 text-center ${className ?? ""}`}>
{icon && (
<div className="rounded-full bg-muted p-4 text-muted-foreground">
{icon}
</div>
)}
<div>
<p className="font-medium text-foreground">{title}</p>
{description && (
<p className="mt-1 text-sm text-muted-foreground max-w-sm">{description}</p>
)}
</div>
{action && (
<Button variant="outline" size="sm" onClick={action.onClick}>
{action.label}
</Button>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { forwardRef, InputHTMLAttributes, ReactNode } from "react";
import { cn } from "../../lib/cn";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, error, leftIcon, rightIcon, ...props }, ref) => {
return (
<div className="relative">
{leftIcon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
{leftIcon}
</div>
)}
<input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
error && "border-destructive focus:ring-destructive",
!!leftIcon && "pl-10",
!!rightIcon && "pr-10",
className
)}
{...props}
/>
{rightIcon && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
{rightIcon}
</div>
)}
</div>
);
}
);
Input.displayName = "Input";
+19
View File
@@ -0,0 +1,19 @@
import { forwardRef, LabelHTMLAttributes } from "react";
import { cn } from "../../lib/cn";
export const Label = forwardRef<HTMLLabelElement, LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => {
return (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
);
}
);
Label.displayName = "Label";
+20
View File
@@ -0,0 +1,20 @@
import { HTMLAttributes } from "react";
import { cn } from "../../lib/cn";
interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical";
}
export function Separator({ className, orientation = "horizontal", ...props }: SeparatorProps) {
return (
<div
role="separator"
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { HTMLAttributes } from "react";
import { cn } from "../../lib/cn";
interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
variant?: "text" | "circular" | "rectangular";
}
export function Skeleton({ className, variant = "rectangular", ...props }: SkeletonProps) {
return (
<div
className={cn(
"animate-skeleton-pulse bg-muted",
variant === "text" && "h-4 rounded",
variant === "circular" && "rounded-full",
variant === "rectangular" && "rounded-md",
className
)}
{...props}
/>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Loader2 } from "../../lib/icons";
interface SpinnerProps {
size?: number;
className?: string;
}
export function Spinner({ size = 20, className }: SpinnerProps) {
return <Loader2 size={size} className={`animate-spin text-muted-foreground ${className ?? ""}`} />;
}
+32
View File
@@ -0,0 +1,32 @@
import { cn } from "../../lib/cn";
interface SwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
id?: string;
}
export function Switch({ checked, onChange, disabled, id }: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
id={id}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-primary" : "bg-muted"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, ReactNode } from "react";
import { createPortal } from "react-dom";
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from "../../lib/icons";
import { cn } from "../../lib/cn";
export type ToastType = "success" | "error" | "warning" | "info";
interface Toast {
id: string;
type: ToastType;
title: string;
description?: string;
duration?: number;
}
interface ToastContextValue {
toasts: Toast[];
toast: (opts: Omit<Toast, "id">) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const toast = useCallback((opts: Omit<Toast, "id">) => {
const id = Math.random().toString(36).slice(2);
setToasts((prev) => [...prev, { ...opts, id }]);
}, []);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, toast, dismiss }}>
{children}
<Toaster />
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
const iconMap: Record<ToastType, typeof CheckCircle> = {
success: CheckCircle,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
};
const colorMap: Record<ToastType, string> = {
success: "text-success",
error: "text-destructive",
warning: "text-warning",
info: "text-primary",
};
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
const [leaving, setLeaving] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const handleDismiss = useCallback(() => {
setLeaving(true);
setTimeout(() => onDismiss(toast.id), 200);
}, [toast.id, onDismiss]);
useEffect(() => {
const duration = toast.duration ?? 4000;
timerRef.current = setTimeout(handleDismiss, duration);
return () => clearTimeout(timerRef.current);
}, [toast.duration, handleDismiss]);
const Icon = iconMap[toast.type];
return (
<div
className={cn(
"pointer-events-auto flex w-80 items-start gap-3 rounded-lg border border-border bg-card p-4 shadow-lg",
leaving ? "animate-toast-leave" : "animate-toast-enter"
)}
role="alert"
>
<Icon size={18} className={colorMap[toast.type] + " shrink-0 mt-0.5"} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-card-foreground">{toast.title}</p>
{toast.description && (
<p className="mt-0.5 text-xs text-muted-foreground">{toast.description}</p>
)}
</div>
<button
onClick={handleDismiss}
className="shrink-0 rounded-sm text-muted-foreground hover:text-foreground transition-colors"
>
<X size={14} />
</button>
</div>
);
}
function Toaster() {
const { toasts, dismiss } = useToast();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return createPortal(
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={dismiss} />
))}
</div>,
document.body
);
}
+154 -16
View File
@@ -3,31 +3,169 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root {
--radius: 0.5rem;
--background: 222 47% 6%;
--foreground: 210 40% 96%;
--card: 222 35% 9%;
--card-foreground: 210 40% 96%;
--popover: 222 35% 9%;
--popover-foreground: 210 40% 96%;
--primary: 168 76% 38%;
--primary-foreground: 168 30% 6%;
--secondary: 217 33% 14%;
--secondary-foreground: 210 40% 96%;
--muted: 217 33% 14%;
--muted-foreground: 215 20% 55%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 96%;
--destructive: 350 89% 60%;
--destructive-foreground: 210 40% 98%;
--success: 158 64% 52%;
--success-foreground: 168 30% 6%;
--warning: 38 92% 50%;
--warning-foreground: 38 92% 10%;
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 168 76% 38%;
--brand-50: 168 76% 95%;
--brand-100: 168 76% 85%;
--brand-200: 168 76% 75%;
--brand-300: 168 76% 65%;
--brand-400: 168 76% 55%;
--brand-500: 168 76% 45%;
--brand-600: 168 76% 38%;
--brand-700: 168 76% 32%;
--brand-800: 168 76% 24%;
--brand-900: 168 76% 16%;
}
.light {
--background: 0 0% 100%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 168 76% 32%;
--primary-foreground: 168 30% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 210 40% 96%;
--accent-foreground: 222 47% 11%;
--destructive: 350 89% 60%;
--destructive-foreground: 0 0% 100%;
--success: 158 64% 42%;
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%;
--warning-foreground: 38 92% 10%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 168 76% 38%;
--brand-50: 168 76% 95%;
--brand-100: 168 76% 88%;
--brand-200: 168 76% 78%;
--brand-300: 168 76% 68%;
--brand-400: 168 76% 58%;
--brand-500: 168 76% 48%;
--brand-600: 168 76% 38%;
--brand-700: 168 76% 32%;
--brand-800: 168 76% 24%;
--brand-900: 168 76% 16%;
}
* {
@apply border-border;
}
body { body {
@apply bg-slate-950 text-slate-100 antialiased; @apply bg-background text-foreground antialiased;
}
::selection {
@apply bg-primary/30 text-foreground;
}
:focus-visible {
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
} }
} }
@layer components { @layer utilities {
.btn { .scrollbar-thin {
@apply inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50; scrollbar-width: thin;
scrollbar-color: hsl(var(--muted)) transparent;
} }
.btn-primary { .scrollbar-thin::-webkit-scrollbar {
@apply btn bg-brand-600 text-white hover:bg-brand-700; width: 6px;
height: 6px;
} }
.btn-ghost { .scrollbar-thin::-webkit-scrollbar-track {
@apply btn bg-slate-800 text-slate-200 hover:bg-slate-700; background: transparent;
} }
.btn-danger { .scrollbar-thin::-webkit-scrollbar-thumb {
@apply btn bg-red-600 text-white hover:bg-red-700; background-color: hsl(var(--muted));
border-radius: 3px;
} }
.input {
@apply w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500; @keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
} }
.label { .animate-skeleton-pulse {
@apply mb-1 block text-sm font-medium text-slate-300; animation: skeleton-pulse 1.5s ease-in-out infinite;
} }
.card {
@apply rounded-lg border border-slate-800 bg-slate-900/60 p-5 shadow; @keyframes toast-enter {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
.animate-toast-enter {
animation: toast-enter 0.3s ease-out forwards;
}
@keyframes toast-leave {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(100%); }
}
.animate-toast-leave {
animation: toast-leave 0.2s ease-in forwards;
}
@keyframes dialog-enter {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.animate-dialog-enter {
animation: dialog-enter 0.2s ease-out forwards;
}
@keyframes dropdown-enter {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-dropdown-enter {
animation: dropdown-enter 0.15s ease-out forwards;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-in {
animation: fade-in 0.2s ease-out forwards;
}
@keyframes slide-in-from-left {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
.animate-in {
animation: fade-in 0.2s ease-out forwards, slide-in-from-left 0.2s ease-out forwards;
} }
} }
+6
View File
@@ -2,12 +2,18 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import { ThemeProvider } from "./components/ThemeProvider";
import { ToastProvider } from "./components/ui/Toast";
import "./index.css"; import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<ThemeProvider>
<ToastProvider>
<App /> <App />
</ToastProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );
+198 -58
View File
@@ -1,11 +1,22 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, formatBytes, SystemStatus } from "../api"; import { api, formatBytes, SystemStatus, ApplyLogEntry } from "../api";
import { useDirty } from "../DirtyContext"; import { useDirty } from "../DirtyContext";
import { PageHeader } from "../components/PageHeader";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
import { Badge } from "../components/ui/Badge";
import { Skeleton } from "../components/ui/Skeleton";
import {
HardDrive,
Server,
CheckCircle,
AlertCircle,
Activity,
} from "../lib/icons";
const SOURCE_COLORS: Record<string, string> = { const SOURCE_COLORS: Record<string, "success" | "warning" | "default"> = {
samba: "bg-emerald-500/20 text-emerald-300", samba: "success",
nfs: "bg-amber-500/20 text-amber-300", nfs: "warning",
manual: "bg-cyan-500/20 text-cyan-300", manual: "default",
}; };
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
@@ -16,51 +27,121 @@ const SOURCE_LABELS: Record<string, string> = {
export default function Dashboard() { export default function Dashboard() {
const [status, setStatus] = useState<SystemStatus | null>(null); const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [recentLog, setRecentLog] = useState<ApplyLogEntry[]>([]);
const { modules } = useDirty(); const { modules } = useDirty();
useEffect(() => { useEffect(() => {
api.systemStatus().then(setStatus).catch(() => setStatus(null)); Promise.all([api.systemStatus(), api.applyLog()])
.then(([s, l]) => {
setStatus(s);
setRecentLog(l.entries.slice(0, 5));
})
.catch(() => setStatus(null))
.finally(() => setLoading(false));
}, []); }, []);
const visibleDisks = status?.disks.filter( const visibleDisks = status?.disks.filter(
(d) => d.sources.includes("manual") || d.sources.includes("samba") || d.sources.includes("nfs") (d) => d.sources.includes("manual") || d.sources.includes("samba") || d.sources.includes("nfs")
) ?? []; ) ?? [];
const activeServices = status?.services.filter((s) => s.active).length ?? 0;
const totalServices = status?.services.length ?? 0;
const kpis = [
{
label: "Discos vigilados",
value: visibleDisks.length,
icon: HardDrive,
color: "text-primary",
bg: "bg-primary/10",
},
{
label: "Servicios activos",
value: `${activeServices}/${totalServices}`,
icon: Server,
color: activeServices === totalServices ? "text-success" : "text-warning",
bg: activeServices === totalServices ? "bg-success/10" : "bg-warning/10",
},
{
label: "Cambios pendientes",
value: modules.length,
icon: Activity,
color: modules.length > 0 ? "text-warning" : "text-success",
bg: modules.length > 0 ? "bg-warning/10" : "bg-success/10",
},
{
label: "Entradas de log",
value: recentLog.length,
icon: AlertCircle,
color: "text-muted-foreground",
bg: "bg-muted",
},
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold text-white">Dashboard</h1> <PageHeader
title="Dashboard"
description="Resumen del estado del sistema NAS"
/>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="card"> {kpis.map(({ label, value, icon: Icon, color, bg }) => (
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400"> <Card key={label} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2.5 ${bg}`}>
<Icon size={18} className={color} />
</div>
<div>
<p className="text-2xl font-bold text-foreground">{loading ? "—" : value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive size={16} className="text-primary" />
Uso de disco Uso de disco
</h2> </CardTitle>
{visibleDisks.length ? ( </CardHeader>
<CardContent>
{loading ? (
<div className="space-y-4">
{[1, 2].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-2 w-full rounded-full" />
</div>
))}
</div>
) : visibleDisks.length ? (
<div className="space-y-4"> <div className="space-y-4">
{visibleDisks.map((d) => ( {visibleDisks.map((d) => (
<div key={d.path}> <div key={d.path} className="space-y-2">
<div className="mb-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-sm"> <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-slate-300">{d.path}</span> <span className="text-sm font-medium text-foreground">{d.path}</span>
{d.sources.map((s) => ( {d.sources.map((s) => (
<span <Badge key={s} variant={SOURCE_COLORS[s]} size="sm">
key={s} {SOURCE_LABELS[s]}
className={`rounded-full px-2 py-0.5 text-xs ${SOURCE_COLORS[s] ?? "bg-slate-700 text-slate-300"}`} </Badge>
>
{SOURCE_LABELS[s] ?? s}
</span>
))} ))}
{d.used_by && d.used_by.length > 0 && d.sources.length > 1 && (
<span className="text-xs text-slate-500">{d.used_by.join(", ")}</span>
)}
</div> </div>
<span className="text-slate-400 text-xs"> <span className="text-xs text-muted-foreground font-mono">
{formatBytes(d.used_bytes)} / {formatBytes(d.total_bytes)} {formatBytes(d.used_bytes)} / {formatBytes(d.total_bytes)}
</span> </span>
</div> </div>
<div className="h-2 overflow-hidden rounded bg-slate-800"> <div className="h-2 overflow-hidden rounded-full bg-muted">
<div <div
className="h-full bg-brand-500" className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${Math.min(d.used_percent, 100)}%` }} style={{ width: `${Math.min(d.used_percent, 100)}%` }}
/> />
</div> </div>
@@ -68,49 +149,108 @@ export default function Dashboard() {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-slate-500"> <p className="text-sm text-muted-foreground py-4 text-center">
Sin puntos de montaje vigilados. Añádelos en Ajustes. Sin puntos de montaje vigilados. Añádelos en Ajustes.
</p> </p>
)} )}
</div> </CardContent>
</Card>
<div className="card"> <Card>
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400"> <CardHeader>
<CardTitle className="flex items-center gap-2">
<Server size={16} className="text-primary" />
Servicios Servicios
</h2> </CardTitle>
{status?.services?.length ? ( </CardHeader>
<ul className="space-y-2"> <CardContent>
{status.services.map((s) => ( {loading ? (
<li key={s.name} className="flex items-center justify-between text-sm"> <div className="space-y-3">
<span className="text-slate-300">{s.name}</span> {[1, 2, 3].map((i) => (
<span <div key={i} className="flex justify-between">
className={`rounded-full px-2 py-0.5 text-xs ${ <Skeleton className="h-4 w-24" />
s.active ? "bg-emerald-500/20 text-emerald-300" : "bg-slate-700 text-slate-300" <Skeleton className="h-4 w-16" />
}`}
>
{s.state}
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-slate-500">Sin datos de servicios.</p>
)}
</div> </div>
))}
</div>
) : status?.services?.length ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{status.services.map((s) => (
<div
key={s.name}
className="flex items-center justify-between rounded-md border border-border bg-card px-3 py-2"
>
<span className="text-sm font-medium text-foreground truncate pr-2">{s.name}</span>
<Badge variant={s.active ? "success" : "secondary"}>
{s.active ? (
<CheckCircle size={10} className="mr-1" />
) : null}
{s.state}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-4 text-center">
Sin datos de servicios.
</p>
)}
</CardContent>
</Card>
</div> </div>
<div className="card"> {modules.length > 0 && (
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-400"> <Card className="border-warning/30 bg-warning/5">
Cambios pendientes <CardContent className="flex items-center gap-3 p-4">
</h2> <div className="rounded-full bg-warning/10 p-2">
{modules.length > 0 ? ( <AlertCircle size={16} className="text-warning" />
<p className="text-sm text-amber-300"> </div>
Módulos con cambios sin aplicar: {modules.map((m) => m.module).join(", ")} <div className="flex-1">
<p className="text-sm font-medium text-foreground">
Cambios pendientes de aplicar
</p> </p>
<p className="text-xs text-muted-foreground">
Módulos: {modules.map((m) => m.module).join(", ")}
</p>
</div>
</CardContent>
</Card>
)}
{recentLog.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity size={16} className="text-primary" />
Actividad reciente
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{recentLog.map((entry) => (
<div key={entry.id} className="flex items-start gap-3 text-sm">
<div className={`mt-0.5 rounded-full p-1 ${entry.success ? "bg-success/10" : "bg-destructive/10"}`}>
{entry.success ? (
<CheckCircle size={12} className="text-success" />
) : ( ) : (
<p className="text-sm text-emerald-300">Todo aplicado. No hay cambios pendientes.</p> <AlertCircle size={12} className="text-destructive" />
)} )}
</div> </div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" size="sm">{entry.module}</Badge>
<span className="text-foreground truncate">{entry.message}</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{new Date(entry.created_at).toLocaleString()}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div> </div>
); );
} }
+328 -529
View File
File diff suppressed because it is too large Load Diff
+124 -35
View File
@@ -1,51 +1,140 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, ApplyLogEntry } from "../api"; import { api, ApplyLogEntry } from "../api";
import { PageHeader } from "../components/PageHeader";
import { Card } from "../components/ui/Card";
import { Badge } from "../components/ui/Badge";
import { Button } from "../components/ui/Button";
import { Spinner } from "../components/ui/Spinner";
import { EmptyState } from "../components/ui/EmptyState";
import { CheckCircle, AlertCircle, History } from "../lib/icons";
export default function Log() { export default function Log() {
const [entries, setEntries] = useState<ApplyLogEntry[]>([]); const [entries, setEntries] = useState<ApplyLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"all" | "success" | "error">("all");
const [moduleFilter, setModuleFilter] = useState("");
useEffect(() => { useEffect(() => {
api.applyLog().then((res) => setEntries(res.entries ?? [])).catch(() => setEntries([])); api.applyLog()
.then((res) => setEntries(res.entries))
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, []); }, []);
const modules = [...new Set(entries.map((e) => e.module))];
const filtered = entries.filter((e) => {
if (filter === "success" && !e.success) return false;
if (filter === "error" && e.success) return false;
if (moduleFilter && e.module !== moduleFilter) return false;
return true;
});
const grouped = filtered.reduce<Record<string, ApplyLogEntry[]>>((acc, entry) => {
const date = new Date(entry.created_at).toLocaleDateString("es-ES", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
if (!acc[date]) acc[date] = [];
acc[date].push(entry);
return acc;
}, {});
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold text-white">Historial de aplicaciones</h1> <PageHeader
<div className="card overflow-x-auto p-0"> title="Historial"
<table className="w-full text-left text-sm"> description="Registro de cambios aplicados al sistema"
<thead className="border-b border-slate-800 text-slate-400"> actions={
<tr> <Button variant="outline" size="sm" onClick={() => api.applyLog().then((r) => setEntries(r.entries)).catch(() => {})}>
<th className="px-4 py-3">Fecha</th> Actualizar
<th className="px-4 py-3">Módulo</th> </Button>
<th className="px-4 py-3">Resultado</th> }
<th className="px-4 py-3">Mensaje</th> />
</tr>
</thead> <div className="flex flex-wrap gap-3 items-center">
<tbody> <div className="flex gap-1 rounded-lg border border-border p-1">
{entries.map((e) => ( {(["all", "success", "error"] as const).map((f) => (
<tr key={e.id} className="border-b border-slate-800/60"> <button
<td className="px-4 py-3 text-slate-400">{new Date(e.created_at).toLocaleString()}</td> key={f}
<td className="px-4 py-3 font-medium text-slate-100">{e.module}</td> onClick={() => setFilter(f)}
<td className="px-4 py-3"> className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
{e.success ? ( filter === f
<span className="text-emerald-300">OK</span> ? "bg-primary text-primary-foreground"
) : ( : "text-muted-foreground hover:text-foreground"
<span className="text-red-300">Error</span> }`}
)} >
</td> {f === "all" ? "Todos" : f === "success" ? "Éxitos" : "Errores"}
<td className="px-4 py-3 text-slate-300">{e.message}</td> </button>
</tr>
))} ))}
{entries.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-slate-500">
Sin registros todavía.
</td>
</tr>
)}
</tbody>
</table>
</div> </div>
{modules.length > 1 && (
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
value={moduleFilter}
onChange={(e) => setModuleFilter(e.target.value)}
>
<option value="">Todos los módulos</option>
{modules.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
)}
</div>
<Card>
{loading ? (
<div className="py-12 text-center">
<Spinner />
</div>
) : filtered.length === 0 ? (
<EmptyState
icon={<History size={32} />}
title="Sin entradas"
description={
filter !== "all"
? `No hay entradas con filtro "${filter}".`
: "Aún no se ha aplicado ningún cambio."
}
/>
) : (
<div className="divide-y divide-border/60">
{Object.entries(grouped).map(([date, dateEntries]) => (
<div key={date}>
<div className="px-4 py-2 bg-muted/40">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{date}</p>
</div>
<div className="divide-y divide-border/40">
{dateEntries.map((entry) => (
<div key={entry.id} className="flex items-start gap-3 px-4 py-3 hover:bg-muted/20 transition-colors">
<div className={`mt-0.5 rounded-full p-1 shrink-0 ${
entry.success ? "bg-success/10" : "bg-destructive/10"
}`}>
{entry.success ? (
<CheckCircle size={14} className="text-success" />
) : (
<AlertCircle size={14} className="text-destructive" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" size="sm">{entry.module}</Badge>
<span className="text-sm text-foreground">{entry.message}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{new Date(entry.created_at).toLocaleTimeString("es-ES")}
</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</Card>
</div> </div>
); );
} }
+66 -16
View File
@@ -1,11 +1,16 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { Button } from "../components/ui/Button";
import { Input } from "../components/ui/Input";
import { Label } from "../components/ui/Label";
import { useTheme } from "../components/ThemeProvider";
export default function Login({ onLoggedIn }: { onLoggedIn: (username: string) => void }) { export default function Login({ onLoggedIn }: { onLoggedIn: (username: string) => void }) {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { theme } = useTheme();
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -22,31 +27,76 @@ export default function Login({ onLoggedIn }: { onLoggedIn: (username: string) =
} }
return ( return (
<div className="flex min-h-screen items-center justify-center p-4"> <div className="flex min-h-screen items-center justify-center p-4 relative overflow-hidden">
<form onSubmit={handleSubmit} className="card w-full max-w-sm"> <div
<div className="mb-6 text-center text-2xl font-bold text-white"> className="absolute inset-0 -z-10"
nas<span className="text-brand-500">ctl</span> style={{
background:
theme === "dark"
? "radial-gradient(ellipse at 50% 0%, hsl(168 76% 15%) 0%, hsl(222 47% 6%) 50%)"
: "radial-gradient(ellipse at 50% 0%, hsl(168 76% 90%) 0%, hsl(0 0% 100%) 50%)",
}}
/>
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[300px] opacity-20 blur-3xl -z-10"
style={{ background: "hsl(168 76% 40%)" }}
/>
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center h-14 w-14 rounded-2xl bg-primary/20 mb-4">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-primary">
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<line x1="6" y1="6" x2="6.01" y2="6" strokeWidth="3" />
<line x1="6" y1="18" x2="6.01" y2="18" strokeWidth="3" />
</svg>
</div> </div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">
nas<span className="text-primary">ctl</span>
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Panel de control NAS
</p>
</div>
<div className="rounded-xl border border-border bg-card p-6 shadow-xl">
<form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="mb-4 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div> <div className="rounded-md bg-destructive/10 px-3 py-2.5 text-sm text-destructive flex items-center gap-2">
)} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<div className="mb-4"> <circle cx="12" cy="12" r="10" />
<label className="label">Usuario</label> <line x1="12" x2="12" y1="8" y2="12" />
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus /> <line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
{error}
</div> </div>
<div className="mb-6"> )}
<label className="label">Contraseña</label> <div className="space-y-1.5">
<input <Label htmlFor="username">Usuario</Label>
className="input" <Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
placeholder="root"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Contraseña</Label>
<Input
id="password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/> />
</div> </div>
<button className="btn-primary w-full" disabled={loading}> <Button type="submit" className="w-full" loading={loading}>
{loading ? "Entrando..." : "Iniciar sesión"} {loading ? "Entrando" : "Iniciar sesión"}
</button> </Button>
</form> </form>
</div> </div>
</div>
</div>
); );
} }
+248 -295
View File
@@ -1,350 +1,303 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { api, NFSExport, NFSClient } from "../api"; import { api, NFSExport, NFSClient } from "../api";
import { useDirty } from "../DirtyContext"; import { useDirty } from "../DirtyContext";
import Modal from "../components/Modal"; import { PageHeader } from "../components/PageHeader";
import { Button } from "../components/ui/Button";
import { Input } from "../components/ui/Input";
import { Label } from "../components/ui/Label";
import { Badge } from "../components/ui/Badge";
import { Switch } from "../components/ui/Switch";
import { Separator } from "../components/ui/Separator";
import { Card } from "../components/ui/Card";
import { EmptyState } from "../components/ui/EmptyState";
import { Skeleton } from "../components/ui/Skeleton";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import {
Dialog,
DialogHeader,
DialogTitle,
DialogContent,
DialogFooter,
} from "../components/ui/Dialog";
import { DropdownMenu } from "../components/ui/DropdownMenu";
import PathField from "../components/PathField"; import PathField from "../components/PathField";
import { useToast } from "../components/ui/Toast";
const DEFAULT_EXPORT = { import { Network, Plus, Trash2, Pencil, MoreVertical, X } from "../lib/icons";
path: "",
clients: [],
read_only: false,
async: false,
root_squash: true,
subtree_check: false,
advanced: "{}",
};
const ADVANCED_KEYS = [
{ key: "all_squash", label: "All squash" },
{ key: "secure", label: "Secure" },
{ key: "wdelay", label: "WDelay" },
{ key: "hide", label: "Hide" },
{ key: "crossmnt", label: "Crossmnt" },
];
function parseAdvanced(raw: string): Record<string, boolean> {
try { return JSON.parse(raw || "{}"); } catch { return {}; }
}
function serializeAdvanced(m: Record<string, boolean>): string {
return JSON.stringify(m);
}
function emptyAdvanced() {
return { all_squash: false, secure: false, wdelay: false, hide: false, crossmnt: false };
}
function clientDefaults(exp: Partial<NFSExport>): NFSClient {
return {
host: "",
read_only: exp.read_only ?? false,
async: exp.async ?? false,
root_squash: exp.root_squash ?? true,
subtree_check: exp.subtree_check ?? false,
advanced: emptyAdvanced(),
};
}
function exportFlagsSummary(x: NFSExport): string {
const parts: string[] = [];
parts.push(x.read_only ? "ro" : "rw");
parts.push(x.async ? "async" : "sync");
parts.push(x.subtree_check ? "subtree_check" : "no_subtree_check");
parts.push(x.root_squash ? "root_squash" : "no_root_squash");
return parts.join(",");
}
export default function Nfs() { export default function Nfs() {
const [exports, setExports] = useState<NFSExport[]>([]); const [exports, setExports] = useState<NFSExport[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Partial<NFSExport> | null>(null); const [editing, setEditing] = useState<Partial<NFSExport> | null>(null);
const [hostDrafts, setHostDrafts] = useState<NFSClient[]>([]); const [deleting, setDeleting] = useState<number | null>(null);
const [advanced, setAdvanced] = useState<Record<string, boolean>>({}); const [deleteLoading, setDeleteLoading] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false); const [formError, setFormError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const { refresh } = useDirty(); const { refresh } = useDirty();
const { toast } = useToast();
async function load() { async function load() {
try {
const res = await api.listExports(); const res = await api.listExports();
setExports(res.exports ?? []); setExports(res.exports ?? []);
} catch {
setExports([]);
}
} }
useEffect(() => { useEffect(() => {
load(); load().finally(() => setLoading(false));
}, []); }, []);
function openEdit(x: NFSExport) {
setAdvanced(parseAdvanced(x.advanced ?? "{}"));
setShowAdvanced(false);
setEditing(x);
setHostDrafts(x.clients.map(c => ({
...c,
advanced: c.advanced ?? emptyAdvanced(),
})));
}
function openNew() {
setAdvanced({});
setShowAdvanced(false);
setEditing({ ...DEFAULT_EXPORT });
setHostDrafts([]);
}
async function save(e: FormEvent) { async function save(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (!editing) return; if (!editing) return;
setError(null); setFormError(null);
const payload = {
...editing,
clients: hostDrafts.map(h => ({ ...h })),
advanced: serializeAdvanced(advanced),
};
try { try {
if (editing.id) { if (editing.id) {
await api.updateExport(editing.id, payload); await api.updateExport(editing.id, editing);
toast({ type: "success", title: "Export NFS actualizado" });
} else { } else {
await api.createExport(payload); await api.createExport(editing);
toast({ type: "success", title: "Export NFS creado" });
} }
setEditing(null); setEditing(null);
setHostDrafts([]);
await load(); await load();
await refresh(); await refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Error al guardar"); setFormError(err instanceof Error ? err.message : "Error al guardar");
} }
} }
async function remove(id: number) { async function handleDelete() {
if (!confirm("¿Eliminar este export?")) return; if (deleting === null) return;
await api.deleteExport(id); setDeleteLoading(true);
try {
await api.deleteExport(deleting);
toast({ type: "success", title: "Export eliminado" });
setDeleting(null);
await load(); await load();
await refresh(); await refresh();
} catch (err) {
toast({ type: "error", title: "Error", description: err instanceof Error ? err.message : undefined });
} finally {
setDeleteLoading(false);
}
} }
function toggleAdvanced(key: string) { async function handleImport() {
setAdvanced(prev => ({ ...prev, [key]: !prev[key] }));
}
function addHost() {
const defaults = clientDefaults(editing ?? {});
setHostDrafts(prev => [...prev, defaults as NFSClient]);
}
function removeHost(idx: number) {
setHostDrafts(prev => prev.filter((_, i) => i !== idx));
}
function updateHost(idx: number, field: keyof NFSClient, value: string | boolean) {
setHostDrafts(prev => prev.map((h, i) => i === idx ? { ...h, [field]: value } : h));
}
function toggleHostAdvanced(idx: number, key: string) {
setHostDrafts(prev => prev.map((h, i) => {
if (i !== idx) return h;
return { ...h, advanced: { ...h.advanced, [key]: !h.advanced[key] } };
}));
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Exports NFS</h1>
<div className="flex gap-2">
<button className="btn-ghost" onClick={async () => {
if (!confirm("Esto reemplazará todos los exports configurados con el contenido actual de /etc/exports. ¿Continuar?")) return;
try { try {
const res = await api.importNfs(); const res = await api.importNfs();
await load(); await load();
await refresh(); await refresh();
alert(`Importados ${res.imported} exports desde /etc/exports. Revisa y aplica los cambios.`); toast({ type: "success", title: `Importados ${res.imported} exports` });
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : "Error al importar"); toast({ type: "error", title: "Error", description: err instanceof Error ? err.message : undefined });
} }
}}> }
Re-importar del sistema
</button> function emptyExport(): Partial<NFSExport> {
<button className="btn-primary" onClick={openNew}> return {
path: "",
clients: [],
read_only: false,
async: true,
root_squash: true,
subtree_check: false,
fsid: 0,
advanced: "",
};
}
function addClient() {
setEditing((prev) => {
if (!prev) return prev;
return {
...prev,
clients: [
...(prev.clients ?? []),
{ host: "", read_only: false, async: true, root_squash: true, subtree_check: false, advanced: { all_squash: false, secure: false, wdelay: false, hide: false, crossmnt: false } },
],
};
});
}
function removeClient(index: number) {
setEditing((prev) => {
if (!prev) return prev;
const clients = [...(prev.clients ?? [])];
clients.splice(index, 1);
return { ...prev, clients };
});
}
function updateClient(index: number, field: keyof NFSClient, value: unknown) {
setEditing((prev) => {
if (!prev) return prev;
const clients = [...(prev.clients ?? [])];
clients[index] = { ...clients[index], [field]: value };
return { ...prev, clients };
});
}
return (
<div className="space-y-6">
<PageHeader
title="NFS"
description={`${exports.length} export${exports.length !== 1 ? "s" : ""} configurado${exports.length !== 1 ? "s" : ""}`}
actions={
<>
<Button variant="outline" size="sm" onClick={handleImport}>
Importar de /etc/exports
</Button>
<Button size="sm" onClick={() => setEditing(emptyExport())}>
<Plus size={14} />
Nuevo export Nuevo export
</button> </Button>
</div> </>
</div> }
<div className="card overflow-x-auto p-0">
<table className="w-full text-left text-sm">
<thead className="border-b border-slate-800 text-slate-400">
<tr>
<th className="px-4 py-3">Path</th>
<th className="px-4 py-3">Clientes</th>
<th className="px-4 py-3">Flags (export)</th>
<th className="px-4 py-3">FSID</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{exports.map((x) => (
<tr key={x.id} className="border-b border-slate-800/60">
<td className="px-4 py-3 font-medium text-slate-100">{x.path}</td>
<td className="px-4 py-3 text-slate-300">
{x.clients.length === 0
? "*"
: x.clients.map(c => c.host).join(", ")}
</td>
<td className="px-4 py-3 text-slate-400 text-xs font-mono">{exportFlagsSummary(x)}</td>
<td className="px-4 py-3">
<span className="inline-flex items-center rounded bg-slate-700 px-2 py-0.5 text-xs font-mono text-slate-300">
{x.fsid}
</span>
</td>
<td className="px-4 py-3 text-right">
<button className="btn-ghost mr-2" onClick={() => openEdit({ ...x })}>
Editar
</button>
<button className="btn-danger" onClick={() => remove(x.id)}>
Eliminar
</button>
</td>
</tr>
))}
{exports.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-6 text-center text-slate-500">
No hay exports configurados.
</td>
</tr>
)}
</tbody>
</table>
</div>
{editing && (
<Modal title={editing.id ? "Editar export" : "Nuevo export"} onClose={() => { setEditing(null); setHostDrafts([]); }}>
<form onSubmit={save} className="space-y-4">
{error && (
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
)}
<div>
<label className="label">Path (absoluto)</label>
<PathField
value={editing.path ?? ""}
onChange={p => setEditing({ ...editing, path: p })}
/> />
</div>
<div> <Card className="overflow-hidden">
<div className="flex items-center justify-between mb-2"> {loading ? (
<span className="label">Hosts</span> <div className="p-6 space-y-4">
<button type="button" className="text-xs text-indigo-400 hover:text-indigo-300" onClick={addHost}> {[1, 2].map((i) => <Skeleton key={i} className="h-20 w-full" />)}
+ Añadir host
</button>
</div> </div>
<div className="space-y-2"> ) : exports.length === 0 ? (
{hostDrafts.length === 0 && ( <EmptyState
<p className="text-sm text-slate-500 py-2">Sin hosts usa "Añadir host" para agregar.</p> icon={<Network size={32} />}
)} title="Sin exports NFS"
{hostDrafts.map((c, idx) => ( description="Crea un export o impórtalo de /etc/exports."
<div key={idx} className="rounded border border-slate-700 p-3 space-y-2"> action={{ label: "Crear export", onClick: () => setEditing(emptyExport()) }}
<div className="flex items-center gap-2">
<input
className="input flex-1"
placeholder="192.168.1.20 o 192.168.1.0/24"
value={c.host}
onChange={e => updateHost(idx, "host", e.target.value)}
/> />
<button type="button" className="btn-ghost text-red-400 text-xs px-2" onClick={() => removeHost(idx)}> ) : (
<div className="divide-y divide-border/60">
</button> {exports.map((exp) => (
</div> <div
<div className="grid grid-cols-2 gap-x-4 gap-y-1"> key={exp.id}
<label className="flex items-center gap-1.5 text-xs text-slate-300"> className="flex items-start gap-4 p-4 hover:bg-muted/30 transition-colors group"
<input type="checkbox" className="checkbox" checked={c.read_only} onChange={e => updateHost(idx, "read_only", e.target.checked)} />
Read-only
</label>
<label className="flex items-center gap-1.5 text-xs text-slate-300">
<input type="checkbox" className="checkbox" checked={c.async} onChange={e => updateHost(idx, "async", e.target.checked)} />
Async
</label>
<label className="flex items-center gap-1.5 text-xs text-slate-300">
<input type="checkbox" className="checkbox" checked={c.root_squash} onChange={e => updateHost(idx, "root_squash", e.target.checked)} />
Root squash
</label>
<label className="flex items-center gap-1.5 text-xs text-slate-300">
<input type="checkbox" className="checkbox" checked={c.subtree_check} onChange={e => updateHost(idx, "subtree_check", e.target.checked)} />
Subtree check
</label>
</div>
<details className="group">
<summary className="cursor-pointer text-xs text-slate-500 hover:text-slate-300">
{showAdvanced ? "▾" : "▸"} Avanzado
</summary>
<div className="mt-1 grid grid-cols-2 gap-y-1">
{ADVANCED_KEYS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-1.5 text-xs text-slate-300">
<input
type="checkbox"
className="checkbox"
checked={!!c.advanced?.[key]}
onChange={() => toggleHostAdvanced(idx, key)}
/>
{label}
</label>
))}
</div>
</details>
</div>
))}
</div>
</div>
<div className="space-y-2">
<span className="label">Opciones por defecto (plantilla para hosts nuevos)</span>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 text-sm text-slate-300">
<input type="checkbox" className="checkbox" checked={!!editing.read_only} onChange={e => setEditing({ ...editing, read_only: e.target.checked })} />
Read-only
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input type="checkbox" className="checkbox" checked={!!editing.async} onChange={e => setEditing({ ...editing, async: e.target.checked })} />
Async
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input type="checkbox" className="checkbox" checked={!!editing.root_squash} onChange={e => setEditing({ ...editing, root_squash: e.target.checked })} />
Root squash
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input type="checkbox" className="checkbox" checked={!!editing.subtree_check} onChange={e => setEditing({ ...editing, subtree_check: e.target.checked })} />
Subtree check
</label>
</div>
</div>
<details className="group" open={showAdvanced}>
<summary
className="cursor-pointer text-sm text-slate-400 hover:text-slate-200"
onClick={e => { e.preventDefault(); setShowAdvanced(v => !v); }}
> >
{showAdvanced ? "▾" : "▸"} Opciones avanzadas (plantilla) <div className="rounded-lg bg-primary/10 p-2.5 mt-0.5">
</summary> <Network size={16} className="text-primary" />
<div className="mt-2 grid grid-cols-2 gap-2"> </div>
{ADVANCED_KEYS.map(({ key, label }) => ( <div className="flex-1 min-w-0">
<label key={key} className="flex items-center gap-2 text-sm text-slate-300"> <div className="flex items-center gap-2 flex-wrap">
<input type="checkbox" className="checkbox" checked={!!advanced[key]} onChange={() => toggleAdvanced(key)} /> <span className="font-medium text-foreground font-mono">{exp.path}</span>
{label} {exp.read_only && <Badge variant="warning" size="sm">Ro</Badge>}
</label> {exp.root_squash && <Badge variant="secondary" size="sm">Root squash</Badge>}
</div>
<div className="flex flex-wrap gap-1 mt-2">
{exp.clients.map((c, i) => (
<Badge key={i} variant="outline" size="sm">{c.host}</Badge>
))} ))}
</div> </div>
</details>
<div className="flex justify-end gap-2 pt-2">
<button type="button" className="btn-ghost" onClick={() => { setEditing(null); setHostDrafts([]); }}>
Cancelar
</button>
<button className="btn-primary">Guardar</button>
</div> </div>
</form> <DropdownMenu
</Modal> trigger={
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0 opacity-0 group-hover:opacity-100">
<MoreVertical size={14} />
</Button>
}
items={[
{ label: "Editar", icon: <Pencil size={14} />, onClick: () => setEditing({ ...exp }) },
{ label: "Eliminar", icon: <Trash2 size={14} />, onClick: () => setDeleting(exp.id), destructive: true },
]}
/>
</div>
))}
</div>
)} )}
</Card>
<Dialog
open={editing !== null}
onClose={() => setEditing(null)}
maxWidth="2xl"
>
<DialogHeader>
<DialogTitle>{editing?.id ? "Editar export NFS" : "Nuevo export NFS"}</DialogTitle>
</DialogHeader>
<form onSubmit={save}>
<DialogContent>
<div className="space-y-4">
{formError && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{formError}
</div>
)}
<div className="space-y-1.5">
<Label>Ruta</Label>
<PathField
value={editing?.path ?? ""}
onChange={(v) => setEditing((p) => p && { ...p, path: v })}
/>
</div>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm">Clientes NFS</Label>
<Button type="button" variant="outline" size="sm" onClick={addClient}>
<Plus size={12} />
Añadir cliente
</Button>
</div>
{(editing?.clients ?? []).map((client, i) => (
<div key={i} className="flex gap-2 items-start">
<Input
placeholder="192.168.1.0/24"
value={client.host}
onChange={(e) => updateClient(i, "host", e.target.value)}
className="flex-1"
/>
<div className="flex items-center gap-2 shrink-0">
<Switch checked={client.read_only} onChange={(v: boolean) => updateClient(i, "read_only", v)} />
<button
type="button"
onClick={() => removeClient(i)}
className="text-muted-foreground hover:text-destructive transition-colors"
>
<X size={14} />
</button>
</div>
</div>
))}
</div>
<Separator />
<div className="flex flex-wrap gap-6">
<div className="flex items-center gap-3">
<Switch checked={editing?.read_only ?? false} onChange={(v: boolean) => setEditing((p) => p && { ...p, read_only: v })} />
<Label className="cursor-pointer">Solo lectura</Label>
</div>
<div className="flex items-center gap-3">
<Switch checked={editing?.async ?? false} onChange={(v: boolean) => setEditing((p) => p && { ...p, async: v })} />
<Label className="cursor-pointer">Async</Label>
</div>
<div className="flex items-center gap-3">
<Switch checked={editing?.root_squash ?? true} onChange={(v: boolean) => setEditing((p) => p && { ...p, root_squash: v })} />
<Label className="cursor-pointer">Root squash</Label>
</div>
<div className="flex items-center gap-3">
<Switch checked={editing?.subtree_check ?? false} onChange={(v: boolean) => setEditing((p) => p && { ...p, subtree_check: v })} />
<Label className="cursor-pointer">Subtree check</Label>
</div>
</div>
</div>
</DialogContent>
<DialogFooter>
<Button variant="ghost" type="button" onClick={() => setEditing(null)}>Cancelar</Button>
<Button type="submit">{editing?.id ? "Guardar" : "Crear"}</Button>
</DialogFooter>
</form>
</Dialog>
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
onConfirm={handleDelete}
title="Eliminar export NFS"
description="¿Eliminar este export? Se eliminará de /etc/exports al aplicar."
confirmLabel="Eliminar"
destructive
loading={deleteLoading}
/>
</div> </div>
); );
} }
+36
View File
@@ -0,0 +1,36 @@
import { Link } from "react-router-dom";
import { Home, ArrowLeft } from "../lib/icons";
export default function NotFound() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-6 text-center">
<div className="relative">
<div className="text-[8rem] font-bold leading-none text-muted/20 select-none">404</div>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground mb-2">
<circle cx="12" cy="12" r="10" />
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
<line x1="9" x2="9.01" y1="9" y2="9" strokeWidth="3" />
<line x1="15" x2="15.01" y1="9" y2="9" strokeWidth="3" />
</svg>
</div>
</div>
<div>
<h1 className="text-2xl font-semibold text-foreground mb-2">Página no encontrada</h1>
<p className="text-muted-foreground max-w-md">
La página que buscas no existe o fue movida.
</p>
</div>
<div className="flex gap-3">
<Link to="/" className="inline-flex items-center gap-2 btn btn-primary">
<Home size={16} />
Ir al Dashboard
</Link>
<button onClick={() => history.back()} className="inline-flex items-center gap-2 btn btn-ghost">
<ArrowLeft size={16} />
Volver atrás
</button>
</div>
</div>
);
}
+275 -252
View File
@@ -1,10 +1,100 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { api, SambaShare, User } from "../api"; import { api, SambaShare } from "../api";
import { useDirty } from "../DirtyContext"; import { useDirty } from "../DirtyContext";
import Modal from "../components/Modal"; import { PageHeader } from "../components/PageHeader";
import { Button } from "../components/ui/Button";
import { Input } from "../components/ui/Input";
import { Label } from "../components/ui/Label";
import { Badge } from "../components/ui/Badge";
import { Switch } from "../components/ui/Switch";
import { Separator } from "../components/ui/Separator";
import { Card } from "../components/ui/Card";
import { EmptyState } from "../components/ui/EmptyState";
import { Skeleton } from "../components/ui/Skeleton";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import {
Dialog,
DialogHeader,
DialogTitle,
DialogContent,
DialogFooter,
} from "../components/ui/Dialog";
import { DropdownMenu } from "../components/ui/DropdownMenu";
import PathField from "../components/PathField"; import PathField from "../components/PathField";
import { useToast } from "../components/ui/Toast";
import { Share2, Plus, Trash2, Pencil, MoreVertical } from "../lib/icons";
const empty: Partial<SambaShare> = { export default function Samba() {
const [shares, setShares] = useState<SambaShare[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Partial<SambaShare> | null>(null);
const [deleting, setDeleting] = useState<number | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const { refresh } = useDirty();
const { toast } = useToast();
async function load() {
try {
const res = await api.listShares();
setShares(res.shares ?? []);
} catch {
setShares([]);
}
}
useEffect(() => {
load().finally(() => setLoading(false));
}, []);
async function save(e: FormEvent) {
e.preventDefault();
if (!editing) return;
setFormError(null);
try {
if (editing.id) {
await api.updateShare(editing.id, editing);
toast({ type: "success", title: "Recurso SMB actualizado" });
} else {
await api.createShare(editing);
toast({ type: "success", title: "Recurso SMB creado" });
}
setEditing(null);
await load();
await refresh();
} catch (err) {
setFormError(err instanceof Error ? err.message : "Error al guardar");
}
}
async function handleDelete() {
if (deleting === null) return;
setDeleteLoading(true);
try {
await api.deleteShare(deleting);
toast({ type: "success", title: "Recurso eliminado" });
setDeleting(null);
await load();
await refresh();
} catch (err) {
toast({ type: "error", title: "Error", description: err instanceof Error ? err.message : undefined });
} finally {
setDeleteLoading(false);
}
}
async function handleImport() {
try {
const res = await api.importSamba();
await load();
await refresh();
toast({ type: "success", title: `Importados ${res.imported} recursos` });
} catch (err) {
toast({ type: "error", title: "Error", description: err instanceof Error ? err.message : undefined });
}
}
const emptyShare = (): Partial<SambaShare> => ({
name: "", name: "",
path: "", path: "",
comment: "", comment: "",
@@ -13,278 +103,211 @@ const empty: Partial<SambaShare> = {
valid_users: [], valid_users: [],
valid_groups: [], valid_groups: [],
invalid_users: [], invalid_users: [],
}; });
type ChipPickerProps = {
label: string;
options: User[];
selected: string[];
onChange: (selected: string[]) => void;
allowRemove?: boolean;
};
function ChipPicker({ label, options, selected, onChange }: ChipPickerProps) {
const available = options.filter((u) => !selected.includes(u.username));
return (
<div className="space-y-2">
<label className="label">{label}</label>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selected.map((username) => {
const user = options.find((u) => u.username === username);
const isSMB = user?.smb_enabled;
return (
<span
key={username}
className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2.5 py-0.5 text-xs text-emerald-300"
>
{username}
{isSMB && <span title="SMB habilitado">*</span>}
<button
type="button"
className="ml-0.5 rounded-full hover:text-white"
onClick={() => onChange(selected.filter((u) => u !== username))}
>
×
</button>
</span>
);
})}
</div>
)}
{available.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{available.map((user) => (
<button
key={user.username}
type="button"
className="rounded-full bg-slate-700 px-2.5 py-0.5 text-xs text-slate-300 hover:bg-slate-600 hover:text-white"
onClick={() => onChange([...selected, user.username])}
>
+ {user.username}
</button>
))}
</div>
)}
{options.length === 0 && (
<p className="text-xs text-slate-500">No hay usuarios del sistema. Créalos primero.</p>
)}
</div>
);
}
export default function Samba() {
const [shares, setShares] = useState<SambaShare[]>([]);
const [editing, setEditing] = useState<Partial<SambaShare> | null>(null);
const [error, setError] = useState<string | null>(null);
const [systemUsers, setSystemUsers] = useState<User[]>([]);
const { refresh } = useDirty();
async function load() {
const res = await api.listShares();
setShares(res.shares ?? []);
}
async function loadSystemUsers() {
try {
const res = await api.listUsers();
setSystemUsers(res.users ?? []);
} catch {
setSystemUsers([]);
}
}
useEffect(() => {
load();
}, []);
function openEditor(share: Partial<SambaShare> | null) {
setEditing(share ? { ...share } : { ...empty });
loadSystemUsers();
}
async function save(e: FormEvent) {
e.preventDefault();
if (!editing) return;
setError(null);
try {
if (editing.id) {
await api.updateShare(editing.id, editing);
} else {
await api.createShare(editing);
}
setEditing(null);
await load();
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Error al guardar");
}
}
async function remove(id: number) {
if (!confirm("¿Eliminar este share?")) return;
await api.deleteShare(id);
await load();
await refresh();
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <PageHeader
<h1 className="text-2xl font-bold text-white">Shares SMB / Samba</h1> title="SMB / Samba"
<div className="flex gap-2"> description={`${shares.length} recurso${shares.length !== 1 ? "s" : ""} configurado${shares.length !== 1 ? "s" : ""}`}
<button className="btn-ghost" onClick={async () => { actions={
if (!confirm("Esto reemplazará todos los shares configurados con el contenido actual de smb.conf. ¿Continuar?")) return; <>
try { <Button variant="outline" size="sm" onClick={handleImport}>
const res = await api.importSamba(); Importar de smb.conf
await load(); </Button>
await refresh(); <Button size="sm" onClick={() => setEditing(emptyShare())}>
alert(`Importados ${res.imported} shares desde smb.conf. Revisa y aplica los cambios.`); <Plus size={14} />
} catch (err) { Nuevo recurso
alert(err instanceof Error ? err.message : "Error al importar"); </Button>
</>
} }
}}> />
Re-importar del sistema
</button>
<button className="btn-primary" onClick={() => openEditor(null)}>
Nuevo share
</button>
</div>
</div>
<div className="card overflow-x-auto p-0"> <Card className="overflow-hidden">
<table className="w-full text-left text-sm"> {loading ? (
<thead className="border-b border-slate-800 text-slate-400"> <div className="p-6 space-y-4">
<tr> {[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
<th className="px-4 py-3">Nombre</th> </div>
<th className="px-4 py-3">Path</th> ) : shares.length === 0 ? (
<th className="px-4 py-3">Modo</th> <EmptyState
<th className="px-4 py-3">Invitado</th> icon={<Share2 size={32} />}
<th className="px-4 py-3">Usuarios</th> title="Sin recursos SMB"
<th className="px-4 py-3"></th> description="Crea un recurso o impórtalo de smb.conf."
</tr> action={{ label: "Crear recurso", onClick: () => setEditing(emptyShare()) }}
</thead> />
<tbody> ) : (
{shares.map((s) => ( <div className="divide-y divide-border/60">
<tr key={s.id} className="border-b border-slate-800/60"> {shares.map((share) => (
<td className="px-4 py-3 font-medium text-slate-100">{s.name}</td> <div
<td className="px-4 py-3 text-slate-300">{s.path}</td> key={share.id}
<td className="px-4 py-3">{s.read_only ? "Solo lectura" : "Lectura/Escritura"}</td> className="flex items-start gap-4 p-4 hover:bg-muted/30 transition-colors group"
<td className="px-4 py-3">{s.guest_ok ? "Sí" : "No"}</td> >
<td className="px-4 py-3"> <div className="rounded-lg bg-primary/10 p-2.5 mt-0.5">
<div className="flex flex-wrap gap-1"> <Share2 size={16} className="text-primary" />
{s.valid_users.map((u) => ( </div>
<span key={u} className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-300"> <div className="flex-1 min-w-0">
{u} <div className="flex items-center gap-2 flex-wrap">
</span> <span className="font-medium text-foreground">{share.name}</span>
{share.read_only && <Badge variant="warning" size="sm">Solo lectura</Badge>}
{share.guest_ok && <Badge variant="secondary" size="sm">Guest</Badge>}
</div>
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{share.path}</p>
{share.comment && (
<p className="text-xs text-muted-foreground mt-1">{share.comment}</p>
)}
{(share.valid_users.length > 0 || share.valid_groups.length > 0) && (
<div className="flex flex-wrap gap-1 mt-2">
{share.valid_users.map((u) => (
<Badge key={u} variant="outline" size="sm">{u}</Badge>
))} ))}
{s.invalid_users.map((u) => ( {share.valid_groups.map((g) => (
<span key={u} className="rounded-full bg-red-500/20 px-2 py-0.5 text-xs text-red-300"> <Badge key={g} variant="outline" size="sm">@{g}</Badge>
!{u}
</span>
))} ))}
{s.valid_users.length === 0 && s.invalid_users.length === 0 && ( </div>
<span className="text-slate-500"></span>
)} )}
</div> </div>
</td> <DropdownMenu
<td className="px-4 py-3 text-right"> trigger={
<button className="btn-ghost mr-2" onClick={() => openEditor(s)}> <Button variant="ghost" size="icon" className="h-8 w-8 shrink-0 opacity-0 group-hover:opacity-100">
Editar <MoreVertical size={14} />
</button> </Button>
<button className="btn-danger" onClick={() => remove(s.id)}> }
Eliminar items={[
</button> {
</td> label: "Editar",
</tr> icon: <Pencil size={14} />,
))} onClick: () => setEditing({ ...share }),
{shares.length === 0 && ( },
<tr> {
<td colSpan={6} className="px-4 py-6 text-center text-slate-500"> label: "Eliminar",
No hay shares configurados. icon: <Trash2 size={14} />,
</td> onClick: () => setDeleting(share.id),
</tr> destructive: true,
)} },
</tbody> ]}
</table>
</div>
{editing && (
<Modal title={editing.id ? "Editar share" : "Nuevo share"} onClose={() => setEditing(null)}>
<form onSubmit={save} className="space-y-4">
{error && (
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
)}
<div>
<label className="label">Nombre</label>
<input
className="input"
value={editing.name ?? ""}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
/> />
</div> </div>
<div> ))}
<label className="label">Path (absoluto)</label> </div>
)}
</Card>
<Dialog
open={editing !== null}
onClose={() => setEditing(null)}
maxWidth="2xl"
>
<DialogHeader>
<DialogTitle>{editing?.id ? "Editar recurso SMB" : "Nuevo recurso SMB"}</DialogTitle>
</DialogHeader>
<form onSubmit={save}>
<DialogContent>
<div className="space-y-4">
{formError && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{formError}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="smb-name">Nombre del recurso</Label>
<Input
id="smb-name"
value={editing?.name ?? ""}
disabled={!!editing?.id}
onChange={(e) => setEditing((p) => p && { ...p, name: e.target.value })}
placeholder="MiCarpeta"
/>
</div>
<div className="space-y-1.5">
<Label> Ruta </Label>
<PathField <PathField
value={editing.path ?? ""} value={editing?.path ?? ""}
onChange={(p) => setEditing({ ...editing, path: p })} onChange={(v) => setEditing((p) => p && { ...p, path: v })}
/> />
</div> </div>
<div> </div>
<label className="label">Comentario</label> <div className="space-y-1.5">
<input <Label htmlFor="smb-comment">Comentario</Label>
className="input" <Input
value={editing.comment ?? ""} id="smb-comment"
onChange={(e) => setEditing({ ...editing, comment: e.target.value })} value={editing?.comment ?? ""}
onChange={(e) => setEditing((p) => p && { ...p, comment: e.target.value })}
placeholder="Descripción opcional"
/> />
</div> </div>
<Separator />
<ChipPicker <div className="flex flex-wrap gap-6">
label="Usuarios válidos (acceso permitido)" <div className="flex items-center gap-3">
options={systemUsers} <Switch
selected={editing.valid_users ?? []} checked={editing?.read_only ?? false}
onChange={(selected) => onChange={(v: boolean) => setEditing((p) => p && { ...p, read_only: v })}
setEditing({ ...editing, valid_users: selected }) />
<Label className="cursor-pointer">Solo lectura</Label>
</div>
<div className="flex items-center gap-3">
<Switch
checked={editing?.guest_ok ?? false}
onChange={(v: boolean) => setEditing((p) => p && { ...p, guest_ok: v })}
/>
<Label className="cursor-pointer">Acceso guest</Label>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="smb-users">Usuarios válidos (separados por coma)</Label>
<Input
id="smb-users"
value={(editing?.valid_users ?? []).join(", ")}
onChange={(e) =>
setEditing((p) =>
p && {
...p,
valid_users: e.target.value.split(",").map((v) => v.trim()).filter(Boolean),
}
)
} }
/> />
</div>
<ChipPicker <div className="space-y-1.5">
label="Usuarios denegados (acceso explícitamente bloqueado)" <Label htmlFor="smb-groups">Grupos válidos (separados por coma)</Label>
options={systemUsers} <Input
selected={editing.invalid_users ?? []} id="smb-groups"
onChange={(selected) => value={(editing?.valid_groups ?? []).join(", ")}
setEditing({ ...editing, invalid_users: selected }) onChange={(e) =>
setEditing((p) =>
p && {
...p,
valid_groups: e.target.value.split(",").map((v) => v.trim()).filter(Boolean),
}
)
} }
/> />
<div className="flex gap-6">
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={editing.read_only ?? false}
onChange={(e) => setEditing({ ...editing, read_only: e.target.checked })}
/>
Solo lectura
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={editing.guest_ok ?? false}
onChange={(e) => setEditing({ ...editing, guest_ok: e.target.checked })}
/>
Permitir invitado
</label>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> </div>
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}> </div>
</DialogContent>
<DialogFooter>
<Button variant="ghost" type="button" onClick={() => setEditing(null)}>
Cancelar Cancelar
</button> </Button>
<button className="btn-primary">Guardar</button> <Button type="submit">
</div> {editing?.id ? "Guardar" : "Crear"}
</Button>
</DialogFooter>
</form> </form>
</Modal> </Dialog>
)}
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
onConfirm={handleDelete}
title="Eliminar recurso SMB"
description="¿Eliminar este recurso? Se eliminará de smb.conf al aplicar."
confirmLabel="Eliminar"
destructive
loading={deleteLoading}
/>
</div> </div>
); );
} }
+205 -214
View File
@@ -1,250 +1,241 @@
import { FormEvent, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, formatBytes, SystemStatus, WatchedMount } from "../api"; import { api, WatchedMount, VersionInfo } from "../api";
import { PageHeader } from "../components/PageHeader";
const SOURCE_COLORS: Record<string, string> = { import { Button } from "../components/ui/Button";
system: "bg-blue-500/20 text-blue-300", import { Label } from "../components/ui/Label";
mount: "bg-purple-500/20 text-purple-300", import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../components/ui/Card";
samba: "bg-emerald-500/20 text-emerald-300", import { EmptyState } from "../components/ui/EmptyState";
nfs: "bg-amber-500/20 text-amber-300", import { Skeleton } from "../components/ui/Skeleton";
manual: "bg-cyan-500/20 text-cyan-300", import { ConfirmDialog } from "../components/ui/ConfirmDialog";
}; import PathField from "../components/PathField";
import { useToast } from "../components/ui/Toast";
const SOURCE_LABELS: Record<string, string> = { import {
system: "Sistema", HardDrive,
mount: "Mount", Plus,
samba: "SMB", Trash2,
nfs: "NFS", RefreshCw,
manual: "Vigilado", Globe,
}; AlertCircle,
Server,
function DiskUsageItem({ disk }: { disk: SystemStatus["disks"][number] }) { Cpu,
return ( CheckCircle,
<div className="mb-4 last:mb-0"> } from "../lib/icons";
<div className="mb-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="text-slate-300">{disk.path}</span>
{disk.sources.map((s) => (
<span
key={s}
className={`rounded-full px-2 py-0.5 text-xs ${SOURCE_COLORS[s] ?? "bg-slate-700 text-slate-300"}`}
>
{SOURCE_LABELS[s] ?? s}
</span>
))}
{disk.used_by && disk.used_by.length > 0 && disk.sources.length > 1 && (
<div className="flex flex-wrap gap-1">
{disk.used_by.map((name) => (
<span key={name} className="rounded bg-slate-700 px-1.5 py-0.5 text-xs text-slate-300">
{name}
</span>
))}
</div>
)}
</div>
{disk.available ? (
<span className="text-slate-400">
{formatBytes(disk.used_bytes)} / {formatBytes(disk.total_bytes)}
</span>
) : (
<span className="text-amber-400 text-xs"> {disk.error}</span>
)}
</div>
{disk.available && (
<div className="h-2 overflow-hidden rounded bg-slate-800">
<div
className="h-full bg-brand-500"
style={{ width: `${Math.min(disk.used_percent, 100)}%` }}
/>
</div>
)}
</div>
);
}
export default function Settings() { export default function Settings() {
const [oldPassword, setOldPassword] = useState(""); const [mounts, setMounts] = useState<WatchedMount[]>([]);
const [newPassword, setNewPassword] = useState(""); const [loading, setLoading] = useState(true);
const [confirmPassword, setConfirmPassword] = useState(""); const [addingMount, setAddingMount] = useState(false);
const [error, setError] = useState<string | null>(null); const [newMountPath, setNewMountPath] = useState("");
const [success, setSuccess] = useState(false); const [mountLoading, setMountLoading] = useState(false);
const [loading, setLoading] = useState(false); const [deletingMount, setDeletingMount] = useState<number | null>(null);
const [status, setStatus] = useState<SystemStatus | null>(null); const [deleteMountLoading, setDeleteMountLoading] = useState(false);
const [watched, setWatched] = useState<WatchedMount[]>([]); const [version, setVersion] = useState<VersionInfo | null>(null);
const [newPath, setNewPath] = useState(""); const [systemStatus, setSystemStatus] = useState<{ disks: { path: string; available: boolean; error?: string }[] } | null>(null);
const [watchError, setWatchError] = useState<string | null>(null); const { toast } = useToast();
const [watchLoading, setWatchLoading] = useState(false);
useEffect(() => { useEffect(() => {
api.systemStatus().then(setStatus).catch(() => setStatus(null)); api.listWatchedMounts().then(r => setMounts(r.mounts)).catch(() => {}).finally(() => setLoading(false));
api.listWatchedMounts().then(r => setWatched(r.mounts ?? [])).catch(() => setWatched([])); api.version().then(setVersion).catch(() => {});
api.systemStatus().then(setSystemStatus).catch(() => {});
}, []); }, []);
async function handleSubmit(e: FormEvent) { async function addMount() {
e.preventDefault(); if (!newMountPath.trim()) return;
setError(null); setMountLoading(true);
setSuccess(false);
if (newPassword.length < 8) {
setError("La nueva contraseña debe tener al menos 8 caracteres.");
return;
}
if (newPassword !== confirmPassword) {
setError("La nueva contraseña y la confirmación no coinciden.");
return;
}
setLoading(true);
try { try {
await api.changePassword(oldPassword, newPassword); const m = await api.createWatchedMount(newMountPath.trim());
setSuccess(true); setMounts((prev) => [...prev, m]);
setOldPassword(""); setNewMountPath("");
setNewPassword(""); setAddingMount(false);
setConfirmPassword(""); toast({ type: "success", title: "Punto de montaje añadido" });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Error al cambiar la contraseña."); toast({ type: "error", title: "Error", description: err instanceof Error ? err.message : undefined });
} finally { } finally {
setLoading(false); setMountLoading(false);
} }
} }
async function addWatched(e: FormEvent) { async function deleteMount(id: number) {
e.preventDefault(); setDeleteMountLoading(true);
setWatchError(null);
const path = newPath.trim();
if (!path) return;
setWatchLoading(true);
try { try {
const m = await api.createWatchedMount(path);
setWatched(prev => [...prev, m].sort((a, b) => a.path.localeCompare(b.path)));
setNewPath("");
} catch (err) {
setWatchError(err instanceof Error ? err.message : "Error al añadir");
} finally {
setWatchLoading(false);
}
}
async function removeWatched(id: number) {
if (!confirm("¿Eliminar este punto de montaje vigilado?")) return;
await api.deleteWatchedMount(id); await api.deleteWatchedMount(id);
setWatched(prev => prev.filter(m => m.id !== id)); setMounts((prev) => prev.filter((m) => m.id !== id));
setDeletingMount(null);
toast({ type: "success", title: "Punto de montaje eliminado" });
} catch (err) {
toast({ type: "error", title: "Error", description: err instanceof Error ? err.message : undefined });
} finally {
setDeleteMountLoading(false);
}
} }
const manualDisks = status?.disks.filter(d => d.sources.includes("manual")) ?? [];
const shareDisks = status?.disks.filter(d => d.sources.includes("samba") || d.sources.includes("nfs")) ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold text-white">Ajustes</h1> <PageHeader title="Ajustes" description="Configuración del sistema" />
<div className="card max-w-lg"> <Card>
<h2 className="mb-4 text-lg font-semibold text-white">Cambiar contraseña</h2> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
{error && ( <HardDrive size={16} className="text-primary" />
<div className="mb-4 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
)}
{success && (
<div className="mb-4 rounded-md bg-emerald-500/15 px-3 py-2 text-sm text-emerald-200">
Contraseña cambiada correctamente.
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Contraseña actual</label>
<input
className="input"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<div>
<label className="label">Nueva contraseña</label>
<input
className="input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
/>
<p className="mt-1 text-xs text-slate-500">Mínimo 8 caracteres.</p>
</div>
<div>
<label className="label">Confirmar nueva contraseña</label>
<input
className="input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="pt-2">
<button className="btn-primary" type="submit" disabled={loading}>
{loading ? "Guardando..." : "Guardar contraseña"}
</button>
</div>
</form>
</div>
<div className="card">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
Puntos de montaje vigilados Puntos de montaje vigilados
</h2> </CardTitle>
<CardDescription>
<form onSubmit={addWatched} className="mb-4 flex gap-2"> Rutas que nasctl monitoriza para mostrar uso de disco en el dashboard.
<input </CardDescription>
className="input flex-1" </CardHeader>
placeholder="/mnt/data" <CardContent className="space-y-4">
value={newPath} {loading ? (
onChange={(e) => setNewPath(e.target.value)} <div className="space-y-2">
{[1, 2].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : mounts.length === 0 ? (
<EmptyState
icon={<HardDrive size={28} />}
title="Sin puntos de montaje"
description="Añade rutas para monitorizar su uso de disco."
/> />
<button className="btn-primary" type="submit" disabled={watchLoading}> ) : (
{watchLoading ? "..." : "Añadir"} <div className="space-y-2">
</button> {mounts.map((m) => (
</form> <div key={m.id} className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2">
<HardDrive size={14} className="text-muted-foreground shrink-0" />
{watchError && ( <span className="flex-1 font-mono text-sm text-foreground truncate">{m.path}</span>
<div className="mb-3 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{watchError}</div> <Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setDeletingMount(m.id)}
>
<Trash2 size={12} />
</Button>
</div>
))}
</div>
)} )}
{watched.length > 0 ? ( {addingMount ? (
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<Label>Ruta</Label>
<PathField
value={newMountPath}
onChange={(v) => setNewMountPath(v)}
/>
</div>
<Button size="sm" onClick={addMount} loading={mountLoading}>
Añadir
</Button>
<Button variant="ghost" size="sm" onClick={() => { setAddingMount(false); setNewMountPath(""); }}>
Cancelar
</Button>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => setAddingMount(true)}>
<Plus size={14} />
Añadir punto de montaje
</Button>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Globe size={16} className="text-primary" />
Importar configuración
</CardTitle>
<CardDescription>
Importa recursos y exports existentes desde los archivos de configuración del sistema.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
{[
{ label: "Importar Samba", onClick: async () => {
try {
const r = await api.importSamba();
toast({ type: "success", title: `${r.imported} recursos importados de smb.conf` });
} catch (e) { toast({ type: "error", title: "Error", description: e instanceof Error ? e.message : undefined }); }
}},
{ label: "Importar NFS", onClick: async () => {
try {
const r = await api.importNfs();
toast({ type: "success", title: `${r.imported} exports importados de /etc/exports` });
} catch (e) { toast({ type: "error", title: "Error", description: e instanceof Error ? e.message : undefined }); }
}},
{ label: "Importar Usuarios", onClick: async () => {
try {
const r = await api.importUsers();
toast({ type: "success", title: `${r.imported} usuarios importados del sistema` });
} catch (e) { toast({ type: "error", title: "Error", description: e instanceof Error ? e.message : undefined }); }
}},
].map(({ label, onClick }) => (
<Button key={label} variant="outline" size="sm" onClick={onClick}>
<RefreshCw size={12} />
{label}
</Button>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Server size={16} className="text-primary" />
Estado del sistema
</CardTitle>
</CardHeader>
<CardContent>
{systemStatus ? (
<div className="space-y-2"> <div className="space-y-2">
{watched.map((m) => ( {systemStatus.disks.map((d) => (
<div key={m.id} className="flex items-center justify-between text-sm"> <div key={d.path} className="flex items-center gap-3 text-sm">
<span className="text-slate-300">{m.path}</span> {d.available ? (
<button <CheckCircle size={14} className="text-success shrink-0" />
className="btn-ghost text-xs text-slate-400 hover:text-red-300" ) : (
onClick={() => removeWatched(m.id)} <AlertCircle size={14} className="text-destructive shrink-0" />
> )}
Quitar <span className="font-mono text-foreground">{d.path}</span>
</button> {d.error && <span className="text-xs text-muted-foreground"> {d.error}</span>}
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-slate-500">Sin puntos de montaje vigilados.</p> <Skeleton className="h-20 w-full" />
)} )}
</CardContent>
</Card>
{manualDisks.length > 0 && ( <Card>
<div className="mt-4 border-t border-slate-800 pt-4"> <CardHeader>
{manualDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)} <CardTitle className="flex items-center gap-2 text-base">
<Cpu size={16} className="text-primary" />
Acerca de nasctl
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Versión</span>
<span className="font-mono text-foreground">{version?.version ?? "dev"}</span>
</div>
{version?.commit && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Commit</span>
<span className="font-mono text-foreground">{version.commit}</span>
</div> </div>
)} )}
</div> </CardContent>
</Card>
<div className="card"> <ConfirmDialog
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400"> open={deletingMount !== null}
Recursos compartidos onClose={() => setDeletingMount(null)}
</h2> onConfirm={() => deletingMount !== null && deleteMount(deletingMount)}
{shareDisks.length > 0 ? ( title="Eliminar punto de montaje"
shareDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />) description="Este punto de montaje dejará de vigilado. Los datos no se eliminan del disco."
) : ( confirmLabel="Eliminar"
<p className="text-sm text-slate-500">Sin shares ni exports configurados.</p> destructive
)} loading={deleteMountLoading}
</div> />
</div> </div>
); );
} }
+311 -702
View File
File diff suppressed because it is too large Load Diff
+266 -115
View File
@@ -1,7 +1,27 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { api, User } from "../api"; import { api, User } from "../api";
import { useDirty } from "../DirtyContext"; import { useDirty } from "../DirtyContext";
import Modal from "../components/Modal"; import { PageHeader } from "../components/PageHeader";
import { Button } from "../components/ui/Button";
import { Input } from "../components/ui/Input";
import { Label } from "../components/ui/Label";
import { Badge } from "../components/ui/Badge";
import { Switch } from "../components/ui/Switch";
import { Separator } from "../components/ui/Separator";
import { Card } from "../components/ui/Card";
import { EmptyState } from "../components/ui/EmptyState";
import { Skeleton } from "../components/ui/Skeleton";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import {
Dialog,
DialogHeader,
DialogTitle,
DialogContent,
DialogFooter,
} from "../components/ui/Dialog";
import { DropdownMenu } from "../components/ui/DropdownMenu";
import { useToast } from "../components/ui/Toast";
import { Users, Plus, UserCog, Trash2, MoreVertical } from "../lib/icons";
type EditUser = Partial<User> & { password?: string }; type EditUser = Partial<User> & { password?: string };
@@ -13,187 +33,318 @@ const empty: EditUser = {
password: "", password: "",
}; };
export default function Users() { export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<EditUser | null>(null); const [editing, setEditing] = useState<EditUser | null>(null);
const [error, setError] = useState<string | null>(null); const [deleting, setDeleting] = useState<number | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const { refresh } = useDirty(); const { refresh } = useDirty();
const { toast } = useToast();
async function load() { async function load() {
try {
const res = await api.listUsers(); const res = await api.listUsers();
setUsers(res.users ?? []); setUsers(res.users ?? []);
} catch {
setUsers([]);
}
} }
useEffect(() => { useEffect(() => {
load(); load().finally(() => setLoading(false));
}, []); }, []);
async function save(e: FormEvent) { async function save(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (!editing) return; if (!editing) return;
setError(null); setFormError(null);
try { try {
if (editing.id) { if (editing.id) {
await api.updateUser(editing.id, editing); await api.updateUser(editing.id, editing);
toast({ type: "success", title: "Usuario actualizado" });
} else { } else {
await api.createUser(editing); await api.createUser(editing);
toast({ type: "success", title: "Usuario creado" });
} }
setEditing(null); setEditing(null);
await load(); await load();
await refresh(); await refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Error al guardar"); setFormError(err instanceof Error ? err.message : "Error al guardar");
} }
} }
async function remove(id: number) { async function handleDelete() {
if (!confirm("¿Eliminar este usuario? Se ejecutará userdel al aplicar.")) return; if (deleting === null) return;
await api.deleteUser(id); setDeleteLoading(true);
try {
await api.deleteUser(deleting);
toast({ type: "success", title: "Usuario eliminado" });
setDeleting(null);
await load(); await load();
await refresh(); await refresh();
} catch (err) {
toast({
type: "error",
title: "Error al eliminar",
description: err instanceof Error ? err.message : undefined,
});
} finally {
setDeleteLoading(false);
}
} }
return ( async function handleImport() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Usuarios del sistema</h1>
<div className="flex gap-2">
<button className="btn-ghost" onClick={async () => {
try { try {
const res = await api.importUsers(); const res = await api.importUsers();
await load(); await load();
await refresh(); await refresh();
alert(`Importados ${res.imported} usuarios del sistema. Revisa y aplica los cambios.`); toast({
type: "success",
title: `Importados ${res.imported} usuarios`,
description: "Revisa y aplica los cambios.",
});
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : "Error al importar"); toast({
type: "error",
title: "Error al importar",
description: err instanceof Error ? err.message : undefined,
});
}
} }
}}>
Re-importar del sistema
</button>
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
Nuevo usuario
</button>
</div>
</div>
<div className="card overflow-x-auto p-0"> const filtered = users.filter((u) =>
u.username.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-6">
<PageHeader
title="Usuarios del sistema"
description={`${users.length} usuario${users.length !== 1 ? "s" : ""} configurado${users.length !== 1 ? "s" : ""}`}
actions={
<>
<Button variant="outline" size="sm" onClick={handleImport}>
Re-importar del sistema
</Button>
<Button size="sm" onClick={() => setEditing({ ...empty })}>
<Plus size={14} />
Nuevo usuario
</Button>
</>
}
/>
{users.length > 0 && (
<div className="max-w-sm">
<Input
placeholder="Buscar usuarios…"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/></svg>}
/>
</div>
)}
<Card className="overflow-hidden">
{loading ? (
<div className="p-6 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-4">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 flex-1" />
<Skeleton className="h-10 w-24" />
</div>
))}
</div>
) : filtered.length === 0 ? (
<EmptyState
icon={<Users size={32} />}
title={search ? "Sin resultados" : "No hay usuarios"}
description={
search
? `No hay usuarios que coincidan con "${search}"`
: "Crea un usuario o importa del sistema."
}
action={
!search
? { label: "Crear usuario", onClick: () => setEditing({ ...empty }) }
: undefined
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="border-b border-slate-800 text-slate-400"> <thead>
<tr> <tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3">Usuario</th> <th className="px-4 py-3 font-medium text-muted-foreground">Usuario</th>
<th className="px-4 py-3">Grupos</th> <th className="px-4 py-3 font-medium text-muted-foreground">Grupos</th>
<th className="px-4 py-3">SMB</th> <th className="px-4 py-3 font-medium text-muted-foreground">SMB</th>
<th className="px-4 py-3">Estado</th> <th className="px-4 py-3 font-medium text-muted-foreground">Estado</th>
<th className="px-4 py-3"></th> <th className="px-4 py-3 w-12"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((u) => ( {filtered.map((u) => (
<tr key={u.id} className="border-b border-slate-800/60"> <tr key={u.id} className="border-b border-border/60 hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-slate-100">{u.username}</td>
<td className="px-4 py-3 text-slate-300">{u.groups.join(", ") || "—"}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{u.smb_enabled ? ( <div className="flex items-center gap-2.5">
<span className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-300"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-semibold">
Habilitado {u.username.slice(0, 2).toUpperCase()}
</span> </div>
<span className="font-medium text-foreground">{u.username}</span>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">
{u.groups.length > 0 ? (
<div className="flex flex-wrap gap-1">
{u.groups.map((g) => (
<Badge key={g} variant="outline" size="sm">{g}</Badge>
))}
</div>
) : ( ) : (
<span className="rounded-full bg-slate-700 px-2 py-0.5 text-xs text-slate-300"> <span className="text-muted-foreground/60"></span>
Deshabilitado
</span>
)} )}
</td> </td>
<td className="px-4 py-3 text-slate-400">{u.disabled ? "Bloqueado" : "Activo"}</td> <td className="px-4 py-3">
<td className="px-4 py-3 text-right"> <Badge variant={u.smb_enabled ? "success" : "secondary"}>
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...u, password: "" })}> {u.smb_enabled ? "Habilitado" : "Off"}
Editar </Badge>
</button> </td>
<button className="btn-danger" onClick={() => remove(u.id)}> <td className="px-4 py-3">
Eliminar {u.disabled ? (
</button> <Badge variant="destructive">Bloqueado</Badge>
) : (
<Badge variant="success">Activo</Badge>
)}
</td>
<td className="px-4 py-3">
<DropdownMenu
trigger={
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical size={14} />
</Button>
}
items={[
{
label: "Editar",
icon: <UserCog size={14} />,
onClick: () => setEditing({ ...u, password: "" }),
},
{
label: "Eliminar",
icon: <Trash2 size={14} />,
onClick: () => setDeleting(u.id),
destructive: true,
},
]}
/>
</td> </td>
</tr> </tr>
))} ))}
{users.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-6 text-center text-slate-500">
No hay usuarios configurados.
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
{editing && (
<Modal title={editing.id ? "Editar usuario" : "Nuevo usuario"} onClose={() => setEditing(null)}>
<form onSubmit={save} className="space-y-4">
{error && (
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
)} )}
<div> </Card>
<label className="label">Nombre de usuario</label>
<input <Dialog
className="input" open={editing !== null}
value={editing.username ?? ""} onClose={() => setEditing(null)}
disabled={!!editing.id} maxWidth="md"
onChange={(e) => setEditing({ ...editing, username: e.target.value })} >
<DialogHeader>
<DialogTitle>{editing?.id ? "Editar usuario" : "Nuevo usuario"}</DialogTitle>
</DialogHeader>
<form onSubmit={save}>
<DialogContent>
<div className="space-y-4">
{formError && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{formError}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="edit-username">Nombre de usuario</Label>
<Input
id="edit-username"
value={editing?.username ?? ""}
disabled={!!editing?.id}
onChange={(e) => setEditing((prev) => prev && { ...prev, username: e.target.value })}
/> />
</div> </div>
<div> <div className="space-y-1.5">
<label className="label">Grupos (separados por coma)</label> <Label htmlFor="edit-groups">Grupos (separados por coma)</Label>
<input <Input
className="input" id="edit-groups"
value={(editing.groups ?? []).join(", ")} value={(editing?.groups ?? []).join(", ")}
onChange={(e) => onChange={(e) =>
setEditing({ setEditing((prev) =>
...editing, prev && {
groups: e.target.value ...prev,
.split(",") groups: e.target.value.split(",").map((v) => v.trim()).filter(Boolean),
.map((v) => v.trim()) }
.filter(Boolean), )
})
} }
/> />
</div> </div>
<div> </div>
<label className="label"> <div className="space-y-1.5">
Contraseña {editing.id && <span className="text-slate-500">(dejar vacío para no cambiar)</span>} <Label htmlFor="edit-password">
</label> Contraseña {editing?.id && <span className="text-muted-foreground text-xs">(dejar vacío para no cambiar)</span>}
<input </Label>
className="input" <Input
id="edit-password"
type="password" type="password"
value={editing.password ?? ""} value={editing?.password ?? ""}
onChange={(e) => setEditing({ ...editing, password: e.target.value })} onChange={(e) => setEditing((prev) => prev && { ...prev, password: e.target.value })}
placeholder={editing?.id ? "••••••••" : ""}
/> />
</div> </div>
<div className="flex gap-6"> <Separator />
<label className="flex items-center gap-2 text-sm text-slate-300"> <div className="flex flex-wrap gap-6">
<input <div className="flex items-center gap-3">
type="checkbox" <Switch
checked={editing.smb_enabled ?? false} checked={editing?.smb_enabled ?? false}
onChange={(e) => setEditing({ ...editing, smb_enabled: e.target.checked })} onChange={(v) => setEditing((prev) => prev && { ...prev, smb_enabled: v })}
/> />
Acceso SMB <Label htmlFor="edit-smb" className="cursor-pointer">Acceso SMB</Label>
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={editing.disabled ?? false}
onChange={(e) => setEditing({ ...editing, disabled: e.target.checked })}
/>
Cuenta bloqueada
</label>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex items-center gap-3">
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}> <Switch
checked={editing?.disabled ?? false}
onChange={(v) => setEditing((prev) => prev && { ...prev, disabled: v })}
/>
<Label htmlFor="edit-disabled" className="cursor-pointer">Cuenta bloqueada</Label>
</div>
</div>
</div>
</DialogContent>
<DialogFooter>
<Button variant="ghost" type="button" onClick={() => setEditing(null)}>
Cancelar Cancelar
</button> </Button>
<button className="btn-primary">Guardar</button> <Button type="submit">
</div> {editing?.id ? "Guardar cambios" : "Crear usuario"}
</Button>
</DialogFooter>
</form> </form>
</Modal> </Dialog>
)}
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
onConfirm={handleDelete}
title="Eliminar usuario"
description="¿Eliminar este usuario? Se ejecutará userdel al aplicar los cambios."
confirmLabel="Eliminar"
destructive
loading={deleteLoading}
/>
</div> </div>
); );
} }
+102 -5
View File
@@ -1,15 +1,112 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"], content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { theme: {
extend: { extend: {
colors: { colors: {
brand: { border: "hsl(var(--border))",
50: "#eff6ff", input: "hsl(var(--input))",
500: "#3b82f6", ring: "hsl(var(--ring))",
600: "#2563eb", background: "hsl(var(--background))",
700: "#1d4ed8", 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))",
},
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-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))",
},
brand: {
50: "hsl(var(--brand-50))",
100: "hsl(var(--brand-100))",
200: "hsl(var(--brand-200))",
300: "hsl(var(--brand-300))",
400: "hsl(var(--brand-400))",
500: "hsl(var(--brand-500))",
600: "hsl(var(--brand-600))",
700: "hsl(var(--brand-700))",
800: "hsl(var(--brand-800))",
900: "hsl(var(--brand-900))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"toast-enter": {
from: { opacity: "0", transform: "translateX(100%)" },
to: { opacity: "1", transform: "translateX(0)" },
},
"toast-leave": {
from: { opacity: "1", transform: "translateX(0)" },
to: { opacity: "0", transform: "translateX(100%)" },
},
"dialog-enter": {
from: { opacity: "0", transform: "scale(0.95)" },
to: { opacity: "1", transform: "scale(1)" },
},
"dropdown-enter": {
from: { opacity: "0", transform: "translateY(-4px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
"skeleton-pulse": {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.4" },
},
spin: {
from: { transform: "rotate(0deg)" },
to: { transform: "rotate(360deg)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"toast-enter": "toast-enter 0.3s ease-out",
"toast-leave": "toast-leave 0.2s ease-in forwards",
"dialog-enter": "dialog-enter 0.2s ease-out",
"dropdown-enter": "dropdown-enter 0.15s ease-out",
"skeleton-pulse": "skeleton-pulse 1.5s ease-in-out infinite",
spin: "spin 1s linear infinite",
}, },
}, },
}, },
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/dirtycontext.tsx","./src/api.ts","./src/main.tsx","./src/components/dirtybanner.tsx","./src/components/filebrowsermodal.tsx","./src/components/layout.tsx","./src/components/modal.tsx","./src/components/pathfield.tsx","./src/pages/dashboard.tsx","./src/pages/files.tsx","./src/pages/log.tsx","./src/pages/login.tsx","./src/pages/nfs.tsx","./src/pages/samba.tsx","./src/pages/settings.tsx","./src/pages/storage.tsx","./src/pages/users.tsx"],"version":"5.9.3"} {"root":["./src/app.tsx","./src/dirtycontext.tsx","./src/api.ts","./src/main.tsx","./src/components/dirtybanner.tsx","./src/components/filebrowsermodal.tsx","./src/components/layout.tsx","./src/components/modal.tsx","./src/components/pageheader.tsx","./src/components/pathfield.tsx","./src/components/sidebar.tsx","./src/components/themeprovider.tsx","./src/components/themetoggle.tsx","./src/components/topbar.tsx","./src/components/usermenu.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdownmenu.tsx","./src/components/ui/emptystate.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/spinner.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/cn.ts","./src/lib/icons.tsx","./src/pages/dashboard.tsx","./src/pages/files.tsx","./src/pages/log.tsx","./src/pages/login.tsx","./src/pages/nfs.tsx","./src/pages/notfound.tsx","./src/pages/samba.tsx","./src/pages/settings.tsx","./src/pages/storage.tsx","./src/pages/users.tsx"],"version":"5.9.3"}