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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
<div className="flex flex-1 flex-col min-w-0">
|
||||||
{navItems.map((item) => (
|
<Topbar username={username} onLogout={onLogout} />
|
||||||
<NavLink
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ?? ""}`} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-3
@@ -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>
|
||||||
<App />
|
<ThemeProvider>
|
||||||
</BrowserRouter>
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
+225
-85
@@ -1,116 +1,256 @@
|
|||||||
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> = {
|
||||||
samba: "SMB",
|
samba: "SMB",
|
||||||
nfs: "NFS",
|
nfs: "NFS",
|
||||||
manual: "Vigilado",
|
manual: "Vigilado",
|
||||||
};
|
};
|
||||||
|
|
||||||
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">
|
||||||
Uso de disco
|
<CardContent className="p-4">
|
||||||
</h2>
|
<div className="flex items-center gap-3">
|
||||||
{visibleDisks.length ? (
|
<div className={`rounded-lg p-2.5 ${bg}`}>
|
||||||
<div className="space-y-4">
|
<Icon size={18} className={color} />
|
||||||
{visibleDisks.map((d) => (
|
</div>
|
||||||
<div key={d.path}>
|
<div>
|
||||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-sm">
|
<p className="text-2xl font-bold text-foreground">{loading ? "—" : value}</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
<span className="text-slate-300">{d.path}</span>
|
</div>
|
||||||
{d.sources.map((s) => (
|
</div>
|
||||||
<span
|
</CardContent>
|
||||||
key={s}
|
</Card>
|
||||||
className={`rounded-full px-2 py-0.5 text-xs ${SOURCE_COLORS[s] ?? "bg-slate-700 text-slate-300"}`}
|
))}
|
||||||
>
|
</div>
|
||||||
{SOURCE_LABELS[s] ?? s}
|
|
||||||
</span>
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
))}
|
<Card>
|
||||||
{d.used_by && d.used_by.length > 0 && d.sources.length > 1 && (
|
<CardHeader>
|
||||||
<span className="text-xs text-slate-500">{d.used_by.join(", ")}</span>
|
<CardTitle className="flex items-center gap-2">
|
||||||
)}
|
<HardDrive size={16} className="text-primary" />
|
||||||
</div>
|
Uso de disco
|
||||||
<span className="text-slate-400 text-xs">
|
</CardTitle>
|
||||||
{formatBytes(d.used_bytes)} / {formatBytes(d.total_bytes)}
|
</CardHeader>
|
||||||
</span>
|
<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>
|
||||||
<div className="h-2 overflow-hidden rounded bg-slate-800">
|
))}
|
||||||
<div
|
</div>
|
||||||
className="h-full bg-brand-500"
|
) : visibleDisks.length ? (
|
||||||
style={{ width: `${Math.min(d.used_percent, 100)}%` }}
|
<div className="space-y-4">
|
||||||
/>
|
{visibleDisks.map((d) => (
|
||||||
|
<div key={d.path} className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{d.path}</span>
|
||||||
|
{d.sources.map((s) => (
|
||||||
|
<Badge key={s} variant={SOURCE_COLORS[s]} size="sm">
|
||||||
|
{SOURCE_LABELS[s]}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{formatBytes(d.used_bytes)} / {formatBytes(d.total_bytes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||||
|
style={{ width: `${Math.min(d.used_percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
Sin puntos de montaje vigilados. Añádelos en Ajustes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server size={16} className="text-primary" />
|
||||||
|
Servicios
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex justify-between">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{modules.length > 0 && (
|
||||||
|
<Card className="border-warning/30 bg-warning/5">
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<div className="rounded-full bg-warning/10 p-2">
|
||||||
|
<AlertCircle size={16} className="text-warning" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Cambios pendientes de aplicar
|
||||||
|
</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" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle size={12} 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-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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</CardContent>
|
||||||
<p className="text-sm text-slate-500">
|
</Card>
|
||||||
Sin puntos de montaje vigilados. Añádelos en Ajustes.
|
)}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
|
||||||
Servicios
|
|
||||||
</h2>
|
|
||||||
{status?.services?.length ? (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{status.services.map((s) => (
|
|
||||||
<li key={s.name} className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-slate-300">{s.name}</span>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
|
||||||
s.active ? "bg-emerald-500/20 text-emerald-300" : "bg-slate-700 text-slate-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s.state}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-500">Sin datos de servicios.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
|
||||||
Cambios pendientes
|
|
||||||
</h2>
|
|
||||||
{modules.length > 0 ? (
|
|
||||||
<p className="text-sm text-amber-300">
|
|
||||||
Módulos con cambios sin aplicar: {modules.map((m) => m.module).join(", ")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-emerald-300">Todo aplicado. No hay cambios pendientes.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+385
-586
File diff suppressed because it is too large
Load Diff
+124
-35
@@ -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>
|
))}
|
||||||
|
</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>
|
||||||
))}
|
))}
|
||||||
{entries.length === 0 && (
|
</select>
|
||||||
<tr>
|
)}
|
||||||
<td colSpan={4} className="px-4 py-6 text-center text-slate-500">
|
|
||||||
Sin registros todavía.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-23
@@ -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>
|
||||||
|
<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>
|
||||||
{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-xl border border-border bg-card p-6 shadow-xl">
|
||||||
)}
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="mb-4">
|
{error && (
|
||||||
<label className="label">Usuario</label>
|
<div className="rounded-md bg-destructive/10 px-3 py-2.5 text-sm text-destructive flex items-center gap-2">
|
||||||
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" x2="12" y1="8" y2="12" />
|
||||||
|
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||||
|
</svg>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="username">Usuario</Label>
|
||||||
|
<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"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
|
{loading ? "Entrando…" : "Iniciar sesión"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-6">
|
</div>
|
||||||
<label className="label">Contraseña</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button className="btn-primary w-full" disabled={loading}>
|
|
||||||
{loading ? "Entrando..." : "Iniciar sesión"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-293
@@ -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() {
|
||||||
const res = await api.listExports();
|
try {
|
||||||
setExports(res.exports ?? []);
|
const res = await api.listExports();
|
||||||
|
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);
|
||||||
await load();
|
try {
|
||||||
await refresh();
|
await api.deleteExport(deleting);
|
||||||
|
toast({ type: "success", title: "Export eliminado" });
|
||||||
|
setDeleting(null);
|
||||||
|
await load();
|
||||||
|
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] }));
|
try {
|
||||||
|
const res = await api.importNfs();
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
toast({ type: "success", title: `Importados ${res.imported} exports` });
|
||||||
|
} catch (err) {
|
||||||
|
toast({ type: "error", title: "Error", description: err instanceof Error ? err.message : undefined });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addHost() {
|
function emptyExport(): Partial<NFSExport> {
|
||||||
const defaults = clientDefaults(editing ?? {});
|
return {
|
||||||
setHostDrafts(prev => [...prev, defaults as NFSClient]);
|
path: "",
|
||||||
|
clients: [],
|
||||||
|
read_only: false,
|
||||||
|
async: true,
|
||||||
|
root_squash: true,
|
||||||
|
subtree_check: false,
|
||||||
|
fsid: 0,
|
||||||
|
advanced: "",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeHost(idx: number) {
|
function addClient() {
|
||||||
setHostDrafts(prev => prev.filter((_, i) => i !== idx));
|
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 updateHost(idx: number, field: keyof NFSClient, value: string | boolean) {
|
function removeClient(index: number) {
|
||||||
setHostDrafts(prev => prev.map((h, i) => i === idx ? { ...h, [field]: value } : h));
|
setEditing((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const clients = [...(prev.clients ?? [])];
|
||||||
|
clients.splice(index, 1);
|
||||||
|
return { ...prev, clients };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleHostAdvanced(idx: number, key: string) {
|
function updateClient(index: number, field: keyof NFSClient, value: unknown) {
|
||||||
setHostDrafts(prev => prev.map((h, i) => {
|
setEditing((prev) => {
|
||||||
if (i !== idx) return h;
|
if (!prev) return prev;
|
||||||
return { ...h, advanced: { ...h.advanced, [key]: !h.advanced[key] } };
|
const clients = [...(prev.clients ?? [])];
|
||||||
}));
|
clients[index] = { ...clients[index], [field]: value };
|
||||||
|
return { ...prev, clients };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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">Exports NFS</h1>
|
title="NFS"
|
||||||
<div className="flex gap-2">
|
description={`${exports.length} export${exports.length !== 1 ? "s" : ""} configurado${exports.length !== 1 ? "s" : ""}`}
|
||||||
<button className="btn-ghost" onClick={async () => {
|
actions={
|
||||||
if (!confirm("Esto reemplazará todos los exports configurados con el contenido actual de /etc/exports. ¿Continuar?")) return;
|
<>
|
||||||
try {
|
<Button variant="outline" size="sm" onClick={handleImport}>
|
||||||
const res = await api.importNfs();
|
Importar de /etc/exports
|
||||||
await load();
|
</Button>
|
||||||
await refresh();
|
<Button size="sm" onClick={() => setEditing(emptyExport())}>
|
||||||
alert(`Importados ${res.imported} exports desde /etc/exports. Revisa y aplica los cambios.`);
|
<Plus size={14} />
|
||||||
} catch (err) {
|
Nuevo export
|
||||||
alert(err instanceof Error ? err.message : "Error al importar");
|
</Button>
|
||||||
}
|
</>
|
||||||
}}>
|
}
|
||||||
Re-importar del sistema
|
/>
|
||||||
</button>
|
|
||||||
<button className="btn-primary" onClick={openNew}>
|
|
||||||
Nuevo export
|
|
||||||
</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].map((i) => <Skeleton key={i} className="h-20 w-full" />)}
|
||||||
<th className="px-4 py-3">Path</th>
|
</div>
|
||||||
<th className="px-4 py-3">Clientes</th>
|
) : exports.length === 0 ? (
|
||||||
<th className="px-4 py-3">Flags (export)</th>
|
<EmptyState
|
||||||
<th className="px-4 py-3">FSID</th>
|
icon={<Network size={32} />}
|
||||||
<th className="px-4 py-3"></th>
|
title="Sin exports NFS"
|
||||||
</tr>
|
description="Crea un export o impórtalo de /etc/exports."
|
||||||
</thead>
|
action={{ label: "Crear export", onClick: () => setEditing(emptyExport()) }}
|
||||||
<tbody>
|
/>
|
||||||
{exports.map((x) => (
|
) : (
|
||||||
<tr key={x.id} className="border-b border-slate-800/60">
|
<div className="divide-y divide-border/60">
|
||||||
<td className="px-4 py-3 font-medium text-slate-100">{x.path}</td>
|
{exports.map((exp) => (
|
||||||
<td className="px-4 py-3 text-slate-300">
|
<div
|
||||||
{x.clients.length === 0
|
key={exp.id}
|
||||||
? "*"
|
className="flex items-start gap-4 p-4 hover:bg-muted/30 transition-colors group"
|
||||||
: x.clients.map(c => c.host).join(", ")}
|
>
|
||||||
</td>
|
<div className="rounded-lg bg-primary/10 p-2.5 mt-0.5">
|
||||||
<td className="px-4 py-3 text-slate-400 text-xs font-mono">{exportFlagsSummary(x)}</td>
|
<Network size={16} className="text-primary" />
|
||||||
<td className="px-4 py-3">
|
</div>
|
||||||
<span className="inline-flex items-center rounded bg-slate-700 px-2 py-0.5 text-xs font-mono text-slate-300">
|
<div className="flex-1 min-w-0">
|
||||||
{x.fsid}
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
</span>
|
<span className="font-medium text-foreground font-mono">{exp.path}</span>
|
||||||
</td>
|
{exp.read_only && <Badge variant="warning" size="sm">Ro</Badge>}
|
||||||
<td className="px-4 py-3 text-right">
|
{exp.root_squash && <Badge variant="secondary" size="sm">Root squash</Badge>}
|
||||||
<button className="btn-ghost mr-2" onClick={() => openEdit({ ...x })}>
|
</div>
|
||||||
Editar
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
</button>
|
{exp.clients.map((c, i) => (
|
||||||
<button className="btn-danger" onClick={() => remove(x.id)}>
|
<Badge key={i} variant="outline" size="sm">{c.host}</Badge>
|
||||||
Eliminar
|
))}
|
||||||
</button>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
<DropdownMenu
|
||||||
))}
|
trigger={
|
||||||
{exports.length === 0 && (
|
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0 opacity-0 group-hover:opacity-100">
|
||||||
<tr>
|
<MoreVertical size={14} />
|
||||||
<td colSpan={5} className="px-4 py-6 text-center text-slate-500">
|
</Button>
|
||||||
No hay exports configurados.
|
}
|
||||||
</td>
|
items={[
|
||||||
</tr>
|
{ label: "Editar", icon: <Pencil size={14} />, onClick: () => setEditing({ ...exp }) },
|
||||||
)}
|
{ label: "Eliminar", icon: <Trash2 size={14} />, onClick: () => setDeleting(exp.id), destructive: true },
|
||||||
</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>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="label">Hosts</span>
|
|
||||||
<button type="button" className="text-xs text-indigo-400 hover:text-indigo-300" onClick={addHost}>
|
|
||||||
+ Añadir host
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
))}
|
||||||
{hostDrafts.length === 0 && (
|
</div>
|
||||||
<p className="text-sm text-slate-500 py-2">Sin hosts — usa "Añadir host" para agregar.</p>
|
)}
|
||||||
)}
|
</Card>
|
||||||
{hostDrafts.map((c, idx) => (
|
|
||||||
<div key={idx} className="rounded border border-slate-700 p-3 space-y-2">
|
<Dialog
|
||||||
<div className="flex items-center gap-2">
|
open={editing !== null}
|
||||||
<input
|
onClose={() => setEditing(null)}
|
||||||
className="input flex-1"
|
maxWidth="2xl"
|
||||||
placeholder="192.168.1.20 o 192.168.1.0/24"
|
>
|
||||||
value={c.host}
|
<DialogHeader>
|
||||||
onChange={e => updateHost(idx, "host", e.target.value)}
|
<DialogTitle>{editing?.id ? "Editar export NFS" : "Nuevo export NFS"}</DialogTitle>
|
||||||
/>
|
</DialogHeader>
|
||||||
<button type="button" className="btn-ghost text-red-400 text-xs px-2" onClick={() => removeHost(idx)}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-slate-300">
|
|
||||||
<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>
|
||||||
</div>
|
<Separator />
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center gap-3">
|
||||||
<span className="label">Opciones por defecto (plantilla para hosts nuevos)</span>
|
<Switch checked={editing?.read_only ?? false} onChange={(v: boolean) => setEditing((p) => p && { ...p, read_only: v })} />
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<Label className="cursor-pointer">Solo lectura</Label>
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
</div>
|
||||||
<input type="checkbox" className="checkbox" checked={!!editing.read_only} onChange={e => setEditing({ ...editing, read_only: e.target.checked })} />
|
<div className="flex items-center gap-3">
|
||||||
Read-only
|
<Switch checked={editing?.async ?? false} onChange={(v: boolean) => setEditing((p) => p && { ...p, async: v })} />
|
||||||
</label>
|
<Label className="cursor-pointer">Async</Label>
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
</div>
|
||||||
<input type="checkbox" className="checkbox" checked={!!editing.async} onChange={e => setEditing({ ...editing, async: e.target.checked })} />
|
<div className="flex items-center gap-3">
|
||||||
Async
|
<Switch checked={editing?.root_squash ?? true} onChange={(v: boolean) => setEditing((p) => p && { ...p, root_squash: v })} />
|
||||||
</label>
|
<Label className="cursor-pointer">Root squash</Label>
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
</div>
|
||||||
<input type="checkbox" className="checkbox" checked={!!editing.root_squash} onChange={e => setEditing({ ...editing, root_squash: e.target.checked })} />
|
<div className="flex items-center gap-3">
|
||||||
Root squash
|
<Switch checked={editing?.subtree_check ?? false} onChange={(v: boolean) => setEditing((p) => p && { ...p, subtree_check: v })} />
|
||||||
</label>
|
<Label className="cursor-pointer">Subtree check</Label>
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
</div>
|
||||||
<input type="checkbox" className="checkbox" checked={!!editing.subtree_check} onChange={e => setEditing({ ...editing, subtree_check: e.target.checked })} />
|
|
||||||
Subtree check
|
|
||||||
</label>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<details className="group" open={showAdvanced}>
|
<ConfirmDialog
|
||||||
<summary
|
open={deleting !== null}
|
||||||
className="cursor-pointer text-sm text-slate-400 hover:text-slate-200"
|
onClose={() => setDeleting(null)}
|
||||||
onClick={e => { e.preventDefault(); setShowAdvanced(v => !v); }}
|
onConfirm={handleDelete}
|
||||||
>
|
title="Eliminar export NFS"
|
||||||
{showAdvanced ? "▾" : "▸"} Opciones avanzadas (plantilla)
|
description="¿Eliminar este export? Se eliminará de /etc/exports al aplicar."
|
||||||
</summary>
|
confirmLabel="Eliminar"
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
destructive
|
||||||
{ADVANCED_KEYS.map(({ key, label }) => (
|
loading={deleteLoading}
|
||||||
<label key={key} className="flex items-center gap-2 text-sm text-slate-300">
|
/>
|
||||||
<input type="checkbox" className="checkbox" checked={!!advanced[key]} onChange={() => toggleAdvanced(key)} />
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+266
-243
@@ -1,290 +1,313 @@
|
|||||||
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";
|
||||||
const empty: Partial<SambaShare> = {
|
import { Share2, Plus, Trash2, Pencil, MoreVertical } from "../lib/icons";
|
||||||
name: "",
|
|
||||||
path: "",
|
|
||||||
comment: "",
|
|
||||||
read_only: false,
|
|
||||||
guest_ok: false,
|
|
||||||
valid_users: [],
|
|
||||||
valid_groups: [],
|
|
||||||
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() {
|
export default function Samba() {
|
||||||
const [shares, setShares] = useState<SambaShare[]>([]);
|
const [shares, setShares] = useState<SambaShare[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [editing, setEditing] = useState<Partial<SambaShare> | null>(null);
|
const [editing, setEditing] = useState<Partial<SambaShare> | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<number | null>(null);
|
||||||
const [systemUsers, setSystemUsers] = useState<User[]>([]);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const { refresh } = useDirty();
|
const { refresh } = useDirty();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const res = await api.listShares();
|
|
||||||
setShares(res.shares ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSystemUsers() {
|
|
||||||
try {
|
try {
|
||||||
const res = await api.listUsers();
|
const res = await api.listShares();
|
||||||
setSystemUsers(res.users ?? []);
|
setShares(res.shares ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
setSystemUsers([]);
|
setShares([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load().finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function openEditor(share: Partial<SambaShare> | null) {
|
|
||||||
setEditing(share ? { ...share } : { ...empty });
|
|
||||||
loadSystemUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
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.updateShare(editing.id, editing);
|
await api.updateShare(editing.id, editing);
|
||||||
|
toast({ type: "success", title: "Recurso SMB actualizado" });
|
||||||
} else {
|
} else {
|
||||||
await api.createShare(editing);
|
await api.createShare(editing);
|
||||||
|
toast({ type: "success", title: "Recurso SMB 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 share?")) return;
|
if (deleting === null) return;
|
||||||
await api.deleteShare(id);
|
setDeleteLoading(true);
|
||||||
await load();
|
try {
|
||||||
await refresh();
|
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: "",
|
||||||
|
path: "",
|
||||||
|
comment: "",
|
||||||
|
read_only: false,
|
||||||
|
guest_ok: false,
|
||||||
|
valid_users: [],
|
||||||
|
valid_groups: [],
|
||||||
|
invalid_users: [],
|
||||||
|
});
|
||||||
|
|
||||||
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>}
|
||||||
{s.invalid_users.map((u) => (
|
{share.guest_ok && <Badge variant="secondary" size="sm">Guest</Badge>}
|
||||||
<span key={u} className="rounded-full bg-red-500/20 px-2 py-0.5 text-xs text-red-300">
|
|
||||||
!{u}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{s.valid_users.length === 0 && s.invalid_users.length === 0 && (
|
|
||||||
<span className="text-slate-500">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{share.path}</p>
|
||||||
<td className="px-4 py-3 text-right">
|
{share.comment && (
|
||||||
<button className="btn-ghost mr-2" onClick={() => openEditor(s)}>
|
<p className="text-xs text-muted-foreground mt-1">{share.comment}</p>
|
||||||
Editar
|
)}
|
||||||
</button>
|
{(share.valid_users.length > 0 || share.valid_groups.length > 0) && (
|
||||||
<button className="btn-danger" onClick={() => remove(s.id)}>
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
Eliminar
|
{share.valid_users.map((u) => (
|
||||||
</button>
|
<Badge key={u} variant="outline" size="sm">{u}</Badge>
|
||||||
</td>
|
))}
|
||||||
</tr>
|
{share.valid_groups.map((g) => (
|
||||||
|
<Badge key={g} variant="outline" size="sm">@{g}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu
|
||||||
|
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({ ...share }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Eliminar",
|
||||||
|
icon: <Trash2 size={14} />,
|
||||||
|
onClick: () => setDeleting(share.id),
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{shares.length === 0 && (
|
</div>
|
||||||
<tr>
|
)}
|
||||||
<td colSpan={6} className="px-4 py-6 text-center text-slate-500">
|
</Card>
|
||||||
No hay shares configurados.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editing && (
|
<Dialog
|
||||||
<Modal title={editing.id ? "Editar share" : "Nuevo share"} onClose={() => setEditing(null)}>
|
open={editing !== null}
|
||||||
<form onSubmit={save} className="space-y-4">
|
onClose={() => setEditing(null)}
|
||||||
{error && (
|
maxWidth="2xl"
|
||||||
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
>
|
||||||
)}
|
<DialogHeader>
|
||||||
<div>
|
<DialogTitle>{editing?.id ? "Editar recurso SMB" : "Nuevo recurso SMB"}</DialogTitle>
|
||||||
<label className="label">Nombre</label>
|
</DialogHeader>
|
||||||
<input
|
<form onSubmit={save}>
|
||||||
className="input"
|
<DialogContent>
|
||||||
value={editing.name ?? ""}
|
<div className="space-y-4">
|
||||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
{formError && (
|
||||||
/>
|
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
</div>
|
{formError}
|
||||||
<div>
|
</div>
|
||||||
<label className="label">Path (absoluto)</label>
|
)}
|
||||||
<PathField
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
value={editing.path ?? ""}
|
<div className="space-y-1.5">
|
||||||
onChange={(p) => setEditing({ ...editing, path: p })}
|
<Label htmlFor="smb-name">Nombre del recurso</Label>
|
||||||
/>
|
<Input
|
||||||
</div>
|
id="smb-name"
|
||||||
<div>
|
value={editing?.name ?? ""}
|
||||||
<label className="label">Comentario</label>
|
disabled={!!editing?.id}
|
||||||
<input
|
onChange={(e) => setEditing((p) => p && { ...p, name: e.target.value })}
|
||||||
className="input"
|
placeholder="MiCarpeta"
|
||||||
value={editing.comment ?? ""}
|
/>
|
||||||
onChange={(e) => setEditing({ ...editing, comment: e.target.value })}
|
</div>
|
||||||
/>
|
<div className="space-y-1.5">
|
||||||
</div>
|
<Label> Ruta </Label>
|
||||||
|
<PathField
|
||||||
<ChipPicker
|
value={editing?.path ?? ""}
|
||||||
label="Usuarios válidos (acceso permitido)"
|
onChange={(v) => setEditing((p) => p && { ...p, path: v })}
|
||||||
options={systemUsers}
|
/>
|
||||||
selected={editing.valid_users ?? []}
|
</div>
|
||||||
onChange={(selected) =>
|
</div>
|
||||||
setEditing({ ...editing, valid_users: selected })
|
<div className="space-y-1.5">
|
||||||
}
|
<Label htmlFor="smb-comment">Comentario</Label>
|
||||||
/>
|
<Input
|
||||||
|
id="smb-comment"
|
||||||
<ChipPicker
|
value={editing?.comment ?? ""}
|
||||||
label="Usuarios denegados (acceso explícitamente bloqueado)"
|
onChange={(e) => setEditing((p) => p && { ...p, comment: e.target.value })}
|
||||||
options={systemUsers}
|
placeholder="Descripción opcional"
|
||||||
selected={editing.invalid_users ?? []}
|
|
||||||
onChange={(selected) =>
|
|
||||||
setEditing({ ...editing, invalid_users: selected })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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
|
</div>
|
||||||
</label>
|
<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.guest_ok ?? false}
|
checked={editing?.read_only ?? false}
|
||||||
onChange={(e) => setEditing({ ...editing, guest_ok: e.target.checked })}
|
onChange={(v: boolean) => setEditing((p) => p && { ...p, read_only: v })}
|
||||||
/>
|
/>
|
||||||
Permitir invitado
|
<Label className="cursor-pointer">Solo lectura</Label>
|
||||||
</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>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="smb-groups">Grupos válidos (separados por coma)</Label>
|
||||||
|
<Input
|
||||||
|
id="smb-groups"
|
||||||
|
value={(editing?.valid_groups ?? []).join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((p) =>
|
||||||
|
p && {
|
||||||
|
...p,
|
||||||
|
valid_groups: e.target.value.split(",").map((v) => v.trim()).filter(Boolean),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
</DialogContent>
|
||||||
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
<DialogFooter>
|
||||||
Cancelar
|
<Button variant="ghost" type="button" onClick={() => setEditing(null)}>
|
||||||
</button>
|
Cancelar
|
||||||
<button className="btn-primary">Guardar</button>
|
</Button>
|
||||||
</div>
|
<Button type="submit">
|
||||||
</form>
|
{editing?.id ? "Guardar" : "Crear"}
|
||||||
</Modal>
|
</Button>
|
||||||
)}
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+212
-221
@@ -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);
|
await api.deleteWatchedMount(id);
|
||||||
setWatched(prev => [...prev, m].sort((a, b) => a.path.localeCompare(b.path)));
|
setMounts((prev) => prev.filter((m) => m.id !== id));
|
||||||
setNewPath("");
|
setDeletingMount(null);
|
||||||
|
toast({ type: "success", title: "Punto de montaje eliminado" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setWatchError(err instanceof Error ? err.message : "Error al añadir");
|
toast({ type: "error", title: "Error", description: err instanceof Error ? err.message : undefined });
|
||||||
} finally {
|
} finally {
|
||||||
setWatchLoading(false);
|
setDeleteMountLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeWatched(id: number) {
|
|
||||||
if (!confirm("¿Eliminar este punto de montaje vigilado?")) return;
|
|
||||||
await api.deleteWatchedMount(id);
|
|
||||||
setWatched(prev => prev.filter(m => m.id !== id));
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
Puntos de montaje vigilados
|
||||||
)}
|
</CardTitle>
|
||||||
{success && (
|
<CardDescription>
|
||||||
<div className="mb-4 rounded-md bg-emerald-500/15 px-3 py-2 text-sm text-emerald-200">
|
Rutas que nasctl monitoriza para mostrar uso de disco en el dashboard.
|
||||||
Contraseña cambiada correctamente.
|
</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
)}
|
<CardContent className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="space-y-2">
|
||||||
<div>
|
{[1, 2].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||||
<label className="label">Contraseña actual</label>
|
</div>
|
||||||
<input
|
) : mounts.length === 0 ? (
|
||||||
className="input"
|
<EmptyState
|
||||||
type="password"
|
icon={<HardDrive size={28} />}
|
||||||
value={oldPassword}
|
title="Sin puntos de montaje"
|
||||||
onChange={(e) => setOldPassword(e.target.value)}
|
description="Añade rutas para monitorizar su uso de disco."
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="label">Nueva contraseña</label>
|
{mounts.map((m) => (
|
||||||
<input
|
<div key={m.id} className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2">
|
||||||
className="input"
|
<HardDrive size={14} className="text-muted-foreground shrink-0" />
|
||||||
type="password"
|
<span className="flex-1 font-mono text-sm text-foreground truncate">{m.path}</span>
|
||||||
value={newPassword}
|
<Button
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
variant="ghost"
|
||||||
autoComplete="new-password"
|
size="icon"
|
||||||
/>
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
<p className="mt-1 text-xs text-slate-500">Mínimo 8 caracteres.</p>
|
onClick={() => setDeletingMount(m.id)}
|
||||||
</div>
|
>
|
||||||
<div>
|
<Trash2 size={12} />
|
||||||
<label className="label">Confirmar nueva contraseña</label>
|
</Button>
|
||||||
<input
|
</div>
|
||||||
className="input"
|
))}
|
||||||
type="password"
|
</div>
|
||||||
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">
|
{addingMount ? (
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
<div className="flex gap-2 items-end">
|
||||||
Puntos de montaje vigilados
|
<div className="flex-1 space-y-1">
|
||||||
</h2>
|
<Label>Ruta</Label>
|
||||||
|
<PathField
|
||||||
<form onSubmit={addWatched} className="mb-4 flex gap-2">
|
value={newMountPath}
|
||||||
<input
|
onChange={(v) => setNewMountPath(v)}
|
||||||
className="input flex-1"
|
/>
|
||||||
placeholder="/mnt/data"
|
|
||||||
value={newPath}
|
|
||||||
onChange={(e) => setNewPath(e.target.value)}
|
|
||||||
/>
|
|
||||||
<button className="btn-primary" type="submit" disabled={watchLoading}>
|
|
||||||
{watchLoading ? "..." : "Añadir"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{watchError && (
|
|
||||||
<div className="mb-3 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{watchError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watched.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{watched.map((m) => (
|
|
||||||
<div key={m.id} className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-slate-300">{m.path}</span>
|
|
||||||
<button
|
|
||||||
className="btn-ghost text-xs text-slate-400 hover:text-red-300"
|
|
||||||
onClick={() => removeWatched(m.id)}
|
|
||||||
>
|
|
||||||
Quitar
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Button size="sm" onClick={addMount} loading={mountLoading}>
|
||||||
</div>
|
Añadir
|
||||||
) : (
|
</Button>
|
||||||
<p className="text-sm text-slate-500">Sin puntos de montaje vigilados.</p>
|
<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>
|
||||||
|
|
||||||
{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">
|
||||||
</div>
|
<Globe size={16} className="text-primary" />
|
||||||
)}
|
Importar configuración
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="card">
|
<Card>
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
<CardHeader>
|
||||||
Recursos compartidos
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
</h2>
|
<Server size={16} className="text-primary" />
|
||||||
{shareDisks.length > 0 ? (
|
Estado del sistema
|
||||||
shareDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)
|
</CardTitle>
|
||||||
) : (
|
</CardHeader>
|
||||||
<p className="text-sm text-slate-500">Sin shares ni exports configurados.</p>
|
<CardContent>
|
||||||
)}
|
{systemStatus ? (
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
|
{systemStatus.disks.map((d) => (
|
||||||
|
<div key={d.path} className="flex items-center gap-3 text-sm">
|
||||||
|
{d.available ? (
|
||||||
|
<CheckCircle size={14} className="text-success shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle size={14} className="text-destructive shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-foreground">{d.path}</span>
|
||||||
|
{d.error && <span className="text-xs text-muted-foreground">— {d.error}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deletingMount !== null}
|
||||||
|
onClose={() => setDeletingMount(null)}
|
||||||
|
onConfirm={() => deletingMount !== null && deleteMount(deletingMount)}
|
||||||
|
title="Eliminar punto de montaje"
|
||||||
|
description="Este punto de montaje dejará de vigilado. Los datos no se eliminan del disco."
|
||||||
|
confirmLabel="Eliminar"
|
||||||
|
destructive
|
||||||
|
loading={deleteMountLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+325
-716
File diff suppressed because it is too large
Load Diff
+301
-150
@@ -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() {
|
||||||
const res = await api.listUsers();
|
try {
|
||||||
setUsers(res.users ?? []);
|
const res = await api.listUsers();
|
||||||
|
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);
|
||||||
await load();
|
try {
|
||||||
await refresh();
|
await api.deleteUser(deleting);
|
||||||
|
toast({ type: "success", title: "Usuario eliminado" });
|
||||||
|
setDeleting(null);
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
type: "error",
|
||||||
|
title: "Error al eliminar",
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
try {
|
||||||
|
const res = await api.importUsers();
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
toast({
|
||||||
|
type: "success",
|
||||||
|
title: `Importados ${res.imported} usuarios`,
|
||||||
|
description: "Revisa y aplica los cambios.",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
type: "error",
|
||||||
|
title: "Error al importar",
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = users.filter((u) =>
|
||||||
|
u.username.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
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">Usuarios del sistema</h1>
|
title="Usuarios del sistema"
|
||||||
<div className="flex gap-2">
|
description={`${users.length} usuario${users.length !== 1 ? "s" : ""} configurado${users.length !== 1 ? "s" : ""}`}
|
||||||
<button className="btn-ghost" onClick={async () => {
|
actions={
|
||||||
try {
|
<>
|
||||||
const res = await api.importUsers();
|
<Button variant="outline" size="sm" onClick={handleImport}>
|
||||||
await load();
|
Re-importar del sistema
|
||||||
await refresh();
|
</Button>
|
||||||
alert(`Importados ${res.imported} usuarios del sistema. Revisa y aplica los cambios.`);
|
<Button size="sm" onClick={() => setEditing({ ...empty })}>
|
||||||
} catch (err) {
|
<Plus size={14} />
|
||||||
alert(err instanceof Error ? err.message : "Error al importar");
|
Nuevo usuario
|
||||||
}
|
</Button>
|
||||||
}}>
|
</>
|
||||||
Re-importar del sistema
|
}
|
||||||
</button>
|
/>
|
||||||
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
|
||||||
Nuevo usuario
|
{users.length > 0 && (
|
||||||
</button>
|
<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>
|
</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">Usuario</th>
|
|
||||||
<th className="px-4 py-3">Grupos</th>
|
|
||||||
<th className="px-4 py-3">SMB</th>
|
|
||||||
<th className="px-4 py-3">Estado</th>
|
|
||||||
<th className="px-4 py-3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((u) => (
|
|
||||||
<tr key={u.id} className="border-b border-slate-800/60">
|
|
||||||
<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">
|
|
||||||
{u.smb_enabled ? (
|
|
||||||
<span className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-300">
|
|
||||||
Habilitado
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="rounded-full bg-slate-700 px-2 py-0.5 text-xs text-slate-300">
|
|
||||||
Deshabilitado
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-slate-400">{u.disabled ? "Bloqueado" : "Activo"}</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...u, password: "" })}>
|
|
||||||
Editar
|
|
||||||
</button>
|
|
||||||
<button className="btn-danger" onClick={() => remove(u.id)}>
|
|
||||||
Eliminar
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</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>
|
|
||||||
</table>
|
|
||||||
</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>
|
|
||||||
<label className="label">Nombre de usuario</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={editing.username ?? ""}
|
|
||||||
disabled={!!editing.id}
|
|
||||||
onChange={(e) => setEditing({ ...editing, username: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Grupos (separados por coma)</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={(editing.groups ?? []).join(", ")}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditing({
|
|
||||||
...editing,
|
|
||||||
groups: e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((v) => v.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">
|
|
||||||
Contraseña {editing.id && <span className="text-slate-500">(dejar vacío para no cambiar)</span>}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="password"
|
|
||||||
value={editing.password ?? ""}
|
|
||||||
onChange={(e) => setEditing({ ...editing, password: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-6">
|
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editing.smb_enabled ?? false}
|
|
||||||
onChange={(e) => setEditing({ ...editing, smb_enabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Acceso SMB
|
|
||||||
</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 className="flex justify-end gap-2 pt-2">
|
|
||||||
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button className="btn-primary">Guardar</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/50">
|
||||||
|
<th className="px-4 py-3 font-medium text-muted-foreground">Usuario</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-muted-foreground">Grupos</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-muted-foreground">SMB</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-muted-foreground">Estado</th>
|
||||||
|
<th className="px-4 py-3 w-12"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((u) => (
|
||||||
|
<tr key={u.id} className="border-b border-border/60 hover:bg-muted/30 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-semibold">
|
||||||
|
{u.username.slice(0, 2).toUpperCase()}
|
||||||
|
</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="text-muted-foreground/60">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant={u.smb_enabled ? "success" : "secondary"}>
|
||||||
|
{u.smb_enabled ? "Habilitado" : "Off"}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{u.disabled ? (
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={editing !== null}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
maxWidth="md"
|
||||||
|
>
|
||||||
|
<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 className="space-y-1.5">
|
||||||
|
<Label htmlFor="edit-groups">Grupos (separados por coma)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-groups"
|
||||||
|
value={(editing?.groups ?? []).join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
groups: e.target.value.split(",").map((v) => v.trim()).filter(Boolean),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="edit-password">
|
||||||
|
Contraseña {editing?.id && <span className="text-muted-foreground text-xs">(dejar vacío para no cambiar)</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-password"
|
||||||
|
type="password"
|
||||||
|
value={editing?.password ?? ""}
|
||||||
|
onChange={(e) => setEditing((prev) => prev && { ...prev, password: e.target.value })}
|
||||||
|
placeholder={editing?.id ? "••••••••" : ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={editing?.smb_enabled ?? false}
|
||||||
|
onChange={(v) => setEditing((prev) => prev && { ...prev, smb_enabled: v })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="edit-smb" className="cursor-pointer">Acceso SMB</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{editing?.id ? "Guardar cambios" : "Crear usuario"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-6
@@ -1,17 +1,114 @@
|
|||||||
/** @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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user