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
|
||||
VERSION?=0.8.3
|
||||
VERSION?=1.0.0
|
||||
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)
|
||||
BUILD_FLAGS=CGO_ENABLED=0
|
||||
|
||||
@@ -4,6 +4,26 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+12
-2
@@ -12,6 +12,7 @@ import Log from "./pages/Log";
|
||||
import Settings from "./pages/Settings";
|
||||
import Files from "./pages/Files";
|
||||
import Storage from "./pages/Storage";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
type AuthState = { loading: boolean; authenticated: boolean; username: string };
|
||||
|
||||
@@ -26,7 +27,16 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
@@ -60,7 +70,7 @@ export default function App() {
|
||||
<Route path="/storage" element={<Storage />} />
|
||||
<Route path="/log" element={<Log />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</DirtyProvider>
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { AlertTriangle } from "../lib/icons";
|
||||
import { useDirty } from "../DirtyContext";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
export default function DirtyBanner() {
|
||||
export function DirtyBanner() {
|
||||
const { modules, apply, applying, error } = useDirty();
|
||||
if (modules.length === 0 && !error) return null;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10">
|
||||
<div className="sticky top-0 z-20">
|
||||
{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">
|
||||
<span className="text-sm">
|
||||
Tienes cambios sin aplicar:{" "}
|
||||
<strong className="font-semibold">
|
||||
{modules.map((m) => m.module).join(", ")}
|
||||
</strong>
|
||||
</span>
|
||||
<button className="btn-primary" onClick={apply} disabled={applying}>
|
||||
{applying ? "Aplicando..." : "Aplicar cambios"}
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-warning/30 bg-warning/10 px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle size={16} className="text-warning shrink-0" />
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-foreground">
|
||||
Tienes cambios sin aplicar:
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{modules.map((m) => m.module).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={apply} disabled={applying} loading={applying}>
|
||||
{applying ? "Aplicando…" : "Aplicar cambios"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="border-b border-red-700/50 bg-red-500/15 px-6 py-3 text-sm text-red-200">
|
||||
Error al aplicar: {error}
|
||||
<div className="flex items-center gap-3 border-b border-destructive/30 bg-destructive/10 px-6 py-3 text-sm">
|
||||
<AlertTriangle size={16} className="text-destructive shrink-0" />
|
||||
<span className="text-destructive flex-1">Error al aplicar: {error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,71 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { api, VersionInfo } from "../api";
|
||||
import DirtyBanner from "./DirtyBanner";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
import { DirtyBanner } from "./DirtyBanner";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Dashboard", end: true },
|
||||
{ to: "/users", label: "Usuarios" },
|
||||
{ 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");
|
||||
}
|
||||
interface LayoutProps {
|
||||
username: string;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export default function Layout({ username, onLogout }: LayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="flex w-60 flex-col border-r border-slate-800 bg-slate-900/80">
|
||||
<div className="px-6 py-5 text-xl font-bold tracking-tight text-white">
|
||||
nas<span className="text-brand-500">ctl</span>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{navItems.map((item) => (
|
||||
<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">
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<div className="hidden lg:flex lg:w-64 lg:shrink-0">
|
||||
<Sidebar username={username} onLogout={onLogout} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<Topbar username={username} onLogout={onLogout} />
|
||||
<DirtyBanner />
|
||||
<div className="p-8">
|
||||
<main className="flex-1 p-6 lg:p-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</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;
|
||||
|
||||
@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 {
|
||||
@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 {
|
||||
.btn {
|
||||
@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;
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted)) transparent;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-brand-600 text-white hover:bg-brand-700;
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn bg-slate-800 text-slate-200 hover:bg-slate-700;
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-red-600 text-white hover:bg-red-700;
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
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 {
|
||||
@apply mb-1 block text-sm font-medium text-slate-300;
|
||||
.animate-skeleton-pulse {
|
||||
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 { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
import { ToastProvider } from "./components/ui/Toast";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
+225
-85
@@ -1,116 +1,256 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, formatBytes, SystemStatus } from "../api";
|
||||
import { api, formatBytes, SystemStatus, ApplyLogEntry } from "../api";
|
||||
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> = {
|
||||
samba: "bg-emerald-500/20 text-emerald-300",
|
||||
nfs: "bg-amber-500/20 text-amber-300",
|
||||
manual: "bg-cyan-500/20 text-cyan-300",
|
||||
const SOURCE_COLORS: Record<string, "success" | "warning" | "default"> = {
|
||||
samba: "success",
|
||||
nfs: "warning",
|
||||
manual: "default",
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
samba: "SMB",
|
||||
nfs: "NFS",
|
||||
samba: "SMB",
|
||||
nfs: "NFS",
|
||||
manual: "Vigilado",
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recentLog, setRecentLog] = useState<ApplyLogEntry[]>([]);
|
||||
const { modules } = useDirty();
|
||||
|
||||
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(
|
||||
(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 (
|
||||
<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="card">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Uso de disco
|
||||
</h2>
|
||||
{visibleDisks.length ? (
|
||||
<div className="space-y-4">
|
||||
{visibleDisks.map((d) => (
|
||||
<div key={d.path}>
|
||||
<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">{d.path}</span>
|
||||
{d.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>
|
||||
))}
|
||||
{d.used_by && d.used_by.length > 0 && d.sources.length > 1 && (
|
||||
<span className="text-xs text-slate-500">{d.used_by.join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-slate-400 text-xs">
|
||||
{formatBytes(d.used_bytes)} / {formatBytes(d.total_bytes)}
|
||||
</span>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{kpis.map(({ label, value, icon: Icon, color, bg }) => (
|
||||
<Card key={label} className="overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`rounded-lg p-2.5 ${bg}`}>
|
||||
<Icon size={18} className={color} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{loading ? "—" : value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive size={16} className="text-primary" />
|
||||
Uso de disco
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded bg-slate-800">
|
||||
<div
|
||||
className="h-full bg-brand-500"
|
||||
style={{ width: `${Math.min(d.used_percent, 100)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : visibleDisks.length ? (
|
||||
<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>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</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 { 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() {
|
||||
const [entries, setEntries] = useState<ApplyLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<"all" | "success" | "error">("all");
|
||||
const [moduleFilter, setModuleFilter] = useState("");
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Historial de aplicaciones</h1>
|
||||
<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">Fecha</th>
|
||||
<th className="px-4 py-3">Módulo</th>
|
||||
<th className="px-4 py-3">Resultado</th>
|
||||
<th className="px-4 py-3">Mensaje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b border-slate-800/60">
|
||||
<td className="px-4 py-3 text-slate-400">{new Date(e.created_at).toLocaleString()}</td>
|
||||
<td className="px-4 py-3 font-medium text-slate-100">{e.module}</td>
|
||||
<td className="px-4 py-3">
|
||||
{e.success ? (
|
||||
<span className="text-emerald-300">OK</span>
|
||||
) : (
|
||||
<span className="text-red-300">Error</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-300">{e.message}</td>
|
||||
</tr>
|
||||
<PageHeader
|
||||
title="Historial"
|
||||
description="Registro de cambios aplicados al sistema"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => api.applyLog().then((r) => setEntries(r.entries)).catch(() => {})}>
|
||||
Actualizar
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="flex gap-1 rounded-lg border border-border p-1">
|
||||
{(["all", "success", "error"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
filter === f
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "Todos" : f === "success" ? "Éxitos" : "Errores"}
|
||||
</button>
|
||||
))}
|
||||
</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 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-center text-slate-500">
|
||||
Sin registros todavía.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<div className="py-12 text-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<History size={32} />}
|
||||
title="Sin entradas"
|
||||
description={
|
||||
filter !== "all"
|
||||
? `No hay entradas con filtro "${filter}".`
|
||||
: "Aún no se ha aplicado ningún cambio."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{Object.entries(grouped).map(([date, dateEntries]) => (
|
||||
<div key={date}>
|
||||
<div className="px-4 py-2 bg-muted/40">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{date}</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40">
|
||||
{dateEntries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-start gap-3 px-4 py-3 hover:bg-muted/20 transition-colors">
|
||||
<div className={`mt-0.5 rounded-full p-1 shrink-0 ${
|
||||
entry.success ? "bg-success/10" : "bg-destructive/10"
|
||||
}`}>
|
||||
{entry.success ? (
|
||||
<CheckCircle size={14} className="text-success" />
|
||||
) : (
|
||||
<AlertCircle size={14} className="text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" size="sm">{entry.module}</Badge>
|
||||
<span className="text-sm text-foreground">{entry.message}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(entry.created_at).toLocaleTimeString("es-ES")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+73
-23
@@ -1,11 +1,16 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
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 }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -22,31 +27,76 @@ export default function Login({ onLoggedIn }: { onLoggedIn: (username: string) =
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<form onSubmit={handleSubmit} className="card w-full max-w-sm">
|
||||
<div className="mb-6 text-center text-2xl font-bold text-white">
|
||||
nas<span className="text-brand-500">ctl</span>
|
||||
<div className="flex min-h-screen items-center justify-center p-4 relative overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 -z-10"
|
||||
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>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<label className="label">Usuario</label>
|
||||
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 px-3 py-2.5 text-sm text-destructive flex items-center gap-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<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 className="mb-6">
|
||||
<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 { api, NFSExport, NFSClient } from "../api";
|
||||
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";
|
||||
|
||||
const DEFAULT_EXPORT = {
|
||||
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(",");
|
||||
}
|
||||
import { useToast } from "../components/ui/Toast";
|
||||
import { Network, Plus, Trash2, Pencil, MoreVertical, X } from "../lib/icons";
|
||||
|
||||
export default function Nfs() {
|
||||
const [exports, setExports] = useState<NFSExport[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<Partial<NFSExport> | null>(null);
|
||||
const [hostDrafts, setHostDrafts] = useState<NFSClient[]>([]);
|
||||
const [advanced, setAdvanced] = useState<Record<string, boolean>>({});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
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 { refresh } = useDirty();
|
||||
const { toast } = useToast();
|
||||
|
||||
async function load() {
|
||||
const res = await api.listExports();
|
||||
setExports(res.exports ?? []);
|
||||
try {
|
||||
const res = await api.listExports();
|
||||
setExports(res.exports ?? []);
|
||||
} catch {
|
||||
setExports([]);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
|
||||
const payload = {
|
||||
...editing,
|
||||
clients: hostDrafts.map(h => ({ ...h })),
|
||||
advanced: serializeAdvanced(advanced),
|
||||
};
|
||||
|
||||
setFormError(null);
|
||||
try {
|
||||
if (editing.id) {
|
||||
await api.updateExport(editing.id, payload);
|
||||
await api.updateExport(editing.id, editing);
|
||||
toast({ type: "success", title: "Export NFS actualizado" });
|
||||
} else {
|
||||
await api.createExport(payload);
|
||||
await api.createExport(editing);
|
||||
toast({ type: "success", title: "Export NFS creado" });
|
||||
}
|
||||
setEditing(null);
|
||||
setHostDrafts([]);
|
||||
await load();
|
||||
await refresh();
|
||||
} 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) {
|
||||
if (!confirm("¿Eliminar este export?")) return;
|
||||
await api.deleteExport(id);
|
||||
await load();
|
||||
await refresh();
|
||||
async function handleDelete() {
|
||||
if (deleting === null) return;
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
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) {
|
||||
setAdvanced(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
async function handleImport() {
|
||||
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() {
|
||||
const defaults = clientDefaults(editing ?? {});
|
||||
setHostDrafts(prev => [...prev, defaults as NFSClient]);
|
||||
function emptyExport(): Partial<NFSExport> {
|
||||
return {
|
||||
path: "",
|
||||
clients: [],
|
||||
read_only: false,
|
||||
async: true,
|
||||
root_squash: true,
|
||||
subtree_check: false,
|
||||
fsid: 0,
|
||||
advanced: "",
|
||||
};
|
||||
}
|
||||
|
||||
function removeHost(idx: number) {
|
||||
setHostDrafts(prev => prev.filter((_, i) => i !== idx));
|
||||
function addClient() {
|
||||
setEditing((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
clients: [
|
||||
...(prev.clients ?? []),
|
||||
{ host: "", read_only: false, async: true, root_squash: true, subtree_check: false, advanced: { all_squash: false, secure: false, wdelay: false, hide: false, crossmnt: false } },
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateHost(idx: number, field: keyof NFSClient, value: string | boolean) {
|
||||
setHostDrafts(prev => prev.map((h, i) => i === idx ? { ...h, [field]: value } : h));
|
||||
function removeClient(index: number) {
|
||||
setEditing((prev) => {
|
||||
if (!prev) return prev;
|
||||
const clients = [...(prev.clients ?? [])];
|
||||
clients.splice(index, 1);
|
||||
return { ...prev, clients };
|
||||
});
|
||||
}
|
||||
|
||||
function toggleHostAdvanced(idx: number, key: string) {
|
||||
setHostDrafts(prev => prev.map((h, i) => {
|
||||
if (i !== idx) return h;
|
||||
return { ...h, advanced: { ...h.advanced, [key]: !h.advanced[key] } };
|
||||
}));
|
||||
function updateClient(index: number, field: keyof NFSClient, value: unknown) {
|
||||
setEditing((prev) => {
|
||||
if (!prev) return prev;
|
||||
const clients = [...(prev.clients ?? [])];
|
||||
clients[index] = { ...clients[index], [field]: value };
|
||||
return { ...prev, clients };
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Exports NFS</h1>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-ghost" onClick={async () => {
|
||||
if (!confirm("Esto reemplazará todos los exports configurados con el contenido actual de /etc/exports. ¿Continuar?")) return;
|
||||
try {
|
||||
const res = await api.importNfs();
|
||||
await load();
|
||||
await refresh();
|
||||
alert(`Importados ${res.imported} exports desde /etc/exports. Revisa y aplica los cambios.`);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Error al importar");
|
||||
}
|
||||
}}>
|
||||
Re-importar del sistema
|
||||
</button>
|
||||
<button className="btn-primary" onClick={openNew}>
|
||||
Nuevo export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="NFS"
|
||||
description={`${exports.length} export${exports.length !== 1 ? "s" : ""} configurado${exports.length !== 1 ? "s" : ""}`}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handleImport}>
|
||||
Importar de /etc/exports
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditing(emptyExport())}>
|
||||
<Plus size={14} />
|
||||
Nuevo export
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="card overflow-x-auto p-0">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-slate-800 text-slate-400">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Path</th>
|
||||
<th className="px-4 py-3">Clientes</th>
|
||||
<th className="px-4 py-3">Flags (export)</th>
|
||||
<th className="px-4 py-3">FSID</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exports.map((x) => (
|
||||
<tr key={x.id} className="border-b border-slate-800/60">
|
||||
<td className="px-4 py-3 font-medium text-slate-100">{x.path}</td>
|
||||
<td className="px-4 py-3 text-slate-300">
|
||||
{x.clients.length === 0
|
||||
? "*"
|
||||
: x.clients.map(c => c.host).join(", ")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400 text-xs font-mono">{exportFlagsSummary(x)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center rounded bg-slate-700 px-2 py-0.5 text-xs font-mono text-slate-300">
|
||||
{x.fsid}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="btn-ghost mr-2" onClick={() => openEdit({ ...x })}>
|
||||
Editar
|
||||
</button>
|
||||
<button className="btn-danger" onClick={() => remove(x.id)}>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{exports.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-6 text-center text-slate-500">
|
||||
No hay exports configurados.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<Modal title={editing.id ? "Editar export" : "Nuevo export"} onClose={() => { setEditing(null); setHostDrafts([]); }}>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Path (absoluto)</label>
|
||||
<PathField
|
||||
value={editing.path ?? ""}
|
||||
onChange={p => setEditing({ ...editing, path: p })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<Card className="overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-6 space-y-4">
|
||||
{[1, 2].map((i) => <Skeleton key={i} className="h-20 w-full" />)}
|
||||
</div>
|
||||
) : exports.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Network size={32} />}
|
||||
title="Sin exports NFS"
|
||||
description="Crea un export o impórtalo de /etc/exports."
|
||||
action={{ label: "Crear export", onClick: () => setEditing(emptyExport()) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{exports.map((exp) => (
|
||||
<div
|
||||
key={exp.id}
|
||||
className="flex items-start gap-4 p-4 hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<div className="rounded-lg bg-primary/10 p-2.5 mt-0.5">
|
||||
<Network size={16} className="text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-foreground font-mono">{exp.path}</span>
|
||||
{exp.read_only && <Badge variant="warning" size="sm">Ro</Badge>}
|
||||
{exp.root_squash && <Badge variant="secondary" size="sm">Root squash</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{exp.clients.map((c, i) => (
|
||||
<Badge key={i} variant="outline" size="sm">{c.host}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<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({ ...exp }) },
|
||||
{ label: "Eliminar", icon: <Trash2 size={14} />, onClick: () => setDeleting(exp.id), destructive: true },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{hostDrafts.length === 0 && (
|
||||
<p className="text-sm text-slate-500 py-2">Sin hosts — usa "Añadir host" para agregar.</p>
|
||||
)}
|
||||
{hostDrafts.map((c, idx) => (
|
||||
<div key={idx} className="rounded border border-slate-700 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="192.168.1.20 o 192.168.1.0/24"
|
||||
value={c.host}
|
||||
onChange={e => updateHost(idx, "host", e.target.value)}
|
||||
/>
|
||||
<button type="button" className="btn-ghost text-red-400 text-xs px-2" onClick={() => removeHost(idx)}>
|
||||
✕
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onClose={() => setEditing(null)}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing?.id ? "Editar export NFS" : "Nuevo export NFS"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={save}>
|
||||
<DialogContent>
|
||||
<div className="space-y-4">
|
||||
{formError && (
|
||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Ruta</Label>
|
||||
<PathField
|
||||
value={editing?.path ?? ""}
|
||||
onChange={(v) => setEditing((p) => p && { ...p, path: v })}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Clientes NFS</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addClient}>
|
||||
<Plus size={12} />
|
||||
Añadir cliente
|
||||
</Button>
|
||||
</div>
|
||||
{(editing?.clients ?? []).map((client, i) => (
|
||||
<div key={i} className="flex gap-2 items-start">
|
||||
<Input
|
||||
placeholder="192.168.1.0/24"
|
||||
value={client.host}
|
||||
onChange={(e) => updateClient(i, "host", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Switch checked={client.read_only} onChange={(v: boolean) => updateClient(i, "read_only", v)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeClient(i)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div 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 className="space-y-2">
|
||||
<span className="label">Opciones por defecto (plantilla para hosts nuevos)</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" className="checkbox" checked={!!editing.read_only} onChange={e => setEditing({ ...editing, read_only: e.target.checked })} />
|
||||
Read-only
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" className="checkbox" checked={!!editing.async} onChange={e => setEditing({ ...editing, async: e.target.checked })} />
|
||||
Async
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" className="checkbox" checked={!!editing.root_squash} onChange={e => setEditing({ ...editing, root_squash: e.target.checked })} />
|
||||
Root squash
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" className="checkbox" checked={!!editing.subtree_check} onChange={e => setEditing({ ...editing, subtree_check: e.target.checked })} />
|
||||
Subtree check
|
||||
</label>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={editing?.read_only ?? false} onChange={(v: boolean) => setEditing((p) => p && { ...p, read_only: v })} />
|
||||
<Label className="cursor-pointer">Solo lectura</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={editing?.async ?? false} onChange={(v: boolean) => setEditing((p) => p && { ...p, async: v })} />
|
||||
<Label className="cursor-pointer">Async</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={editing?.root_squash ?? true} onChange={(v: boolean) => setEditing((p) => p && { ...p, root_squash: v })} />
|
||||
<Label className="cursor-pointer">Root squash</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={editing?.subtree_check ?? false} onChange={(v: boolean) => setEditing((p) => p && { ...p, subtree_check: v })} />
|
||||
<Label className="cursor-pointer">Subtree check</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" type="button" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||
<Button type="submit">{editing?.id ? "Guardar" : "Crear"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<details className="group" open={showAdvanced}>
|
||||
<summary
|
||||
className="cursor-pointer text-sm text-slate-400 hover:text-slate-200"
|
||||
onClick={e => { e.preventDefault(); setShowAdvanced(v => !v); }}
|
||||
>
|
||||
{showAdvanced ? "▾" : "▸"} Opciones avanzadas (plantilla)
|
||||
</summary>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{ADVANCED_KEYS.map(({ key, label }) => (
|
||||
<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>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Eliminar export NFS"
|
||||
description="¿Eliminar este export? Se eliminará de /etc/exports al aplicar."
|
||||
confirmLabel="Eliminar"
|
||||
destructive
|
||||
loading={deleteLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { api, SambaShare, User } from "../api";
|
||||
import { api, SambaShare } from "../api";
|
||||
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";
|
||||
|
||||
const empty: Partial<SambaShare> = {
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { useToast } from "../components/ui/Toast";
|
||||
import { Share2, Plus, Trash2, Pencil, MoreVertical } from "../lib/icons";
|
||||
|
||||
export default function Samba() {
|
||||
const [shares, setShares] = useState<SambaShare[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<Partial<SambaShare> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [systemUsers, setSystemUsers] = useState<User[]>([]);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const { refresh } = useDirty();
|
||||
const { toast } = useToast();
|
||||
|
||||
async function load() {
|
||||
const res = await api.listShares();
|
||||
setShares(res.shares ?? []);
|
||||
}
|
||||
|
||||
async function loadSystemUsers() {
|
||||
try {
|
||||
const res = await api.listUsers();
|
||||
setSystemUsers(res.users ?? []);
|
||||
const res = await api.listShares();
|
||||
setShares(res.shares ?? []);
|
||||
} catch {
|
||||
setSystemUsers([]);
|
||||
setShares([]);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
load().finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function openEditor(share: Partial<SambaShare> | null) {
|
||||
setEditing(share ? { ...share } : { ...empty });
|
||||
loadSystemUsers();
|
||||
}
|
||||
|
||||
async function save(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
setFormError(null);
|
||||
try {
|
||||
if (editing.id) {
|
||||
await api.updateShare(editing.id, editing);
|
||||
toast({ type: "success", title: "Recurso SMB actualizado" });
|
||||
} else {
|
||||
await api.createShare(editing);
|
||||
toast({ type: "success", title: "Recurso SMB creado" });
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error al guardar");
|
||||
setFormError(err instanceof Error ? err.message : "Error al guardar");
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm("¿Eliminar este share?")) return;
|
||||
await api.deleteShare(id);
|
||||
await load();
|
||||
await refresh();
|
||||
async function handleDelete() {
|
||||
if (deleting === null) return;
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await api.deleteShare(deleting);
|
||||
toast({ type: "success", title: "Recurso eliminado" });
|
||||
setDeleting(null);
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast({ type: "error", title: "Error", description: err instanceof Error ? err.message : undefined });
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
try {
|
||||
const res = await api.importSamba();
|
||||
await load();
|
||||
await refresh();
|
||||
toast({ type: "success", title: `Importados ${res.imported} recursos` });
|
||||
} catch (err) {
|
||||
toast({ type: "error", title: "Error", description: err instanceof Error ? err.message : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
const emptyShare = (): Partial<SambaShare> => ({
|
||||
name: "",
|
||||
path: "",
|
||||
comment: "",
|
||||
read_only: false,
|
||||
guest_ok: false,
|
||||
valid_users: [],
|
||||
valid_groups: [],
|
||||
invalid_users: [],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Shares SMB / Samba</h1>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-ghost" onClick={async () => {
|
||||
if (!confirm("Esto reemplazará todos los shares configurados con el contenido actual de smb.conf. ¿Continuar?")) return;
|
||||
try {
|
||||
const res = await api.importSamba();
|
||||
await load();
|
||||
await refresh();
|
||||
alert(`Importados ${res.imported} shares desde smb.conf. Revisa y aplica los cambios.`);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Error al importar");
|
||||
}
|
||||
}}>
|
||||
Re-importar del sistema
|
||||
</button>
|
||||
<button className="btn-primary" onClick={() => openEditor(null)}>
|
||||
Nuevo share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="SMB / Samba"
|
||||
description={`${shares.length} recurso${shares.length !== 1 ? "s" : ""} configurado${shares.length !== 1 ? "s" : ""}`}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handleImport}>
|
||||
Importar de smb.conf
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditing(emptyShare())}>
|
||||
<Plus size={14} />
|
||||
Nuevo recurso
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<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">Nombre</th>
|
||||
<th className="px-4 py-3">Path</th>
|
||||
<th className="px-4 py-3">Modo</th>
|
||||
<th className="px-4 py-3">Invitado</th>
|
||||
<th className="px-4 py-3">Usuarios</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shares.map((s) => (
|
||||
<tr key={s.id} className="border-b border-slate-800/60">
|
||||
<td className="px-4 py-3 font-medium text-slate-100">{s.name}</td>
|
||||
<td className="px-4 py-3 text-slate-300">{s.path}</td>
|
||||
<td className="px-4 py-3">{s.read_only ? "Solo lectura" : "Lectura/Escritura"}</td>
|
||||
<td className="px-4 py-3">{s.guest_ok ? "Sí" : "No"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{s.valid_users.map((u) => (
|
||||
<span key={u} className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-300">
|
||||
{u}
|
||||
</span>
|
||||
))}
|
||||
{s.invalid_users.map((u) => (
|
||||
<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>
|
||||
)}
|
||||
<Card className="overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-6 space-y-4">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||
</div>
|
||||
) : shares.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Share2 size={32} />}
|
||||
title="Sin recursos SMB"
|
||||
description="Crea un recurso o impórtalo de smb.conf."
|
||||
action={{ label: "Crear recurso", onClick: () => setEditing(emptyShare()) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{shares.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-start gap-4 p-4 hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<div className="rounded-lg bg-primary/10 p-2.5 mt-0.5">
|
||||
<Share2 size={16} className="text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-foreground">{share.name}</span>
|
||||
{share.read_only && <Badge variant="warning" size="sm">Solo lectura</Badge>}
|
||||
{share.guest_ok && <Badge variant="secondary" size="sm">Guest</Badge>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="btn-ghost mr-2" onClick={() => openEditor(s)}>
|
||||
Editar
|
||||
</button>
|
||||
<button className="btn-danger" onClick={() => remove(s.id)}>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{share.path}</p>
|
||||
{share.comment && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{share.comment}</p>
|
||||
)}
|
||||
{(share.valid_users.length > 0 || share.valid_groups.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{share.valid_users.map((u) => (
|
||||
<Badge key={u} variant="outline" size="sm">{u}</Badge>
|
||||
))}
|
||||
{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 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-6 text-center text-slate-500">
|
||||
No hay shares configurados.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{editing && (
|
||||
<Modal title={editing.id ? "Editar share" : "Nuevo share"} onClose={() => setEditing(null)}>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="label">Nombre</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editing.name ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Path (absoluto)</label>
|
||||
<PathField
|
||||
value={editing.path ?? ""}
|
||||
onChange={(p) => setEditing({ ...editing, path: p })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Comentario</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editing.comment ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, comment: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChipPicker
|
||||
label="Usuarios válidos (acceso permitido)"
|
||||
options={systemUsers}
|
||||
selected={editing.valid_users ?? []}
|
||||
onChange={(selected) =>
|
||||
setEditing({ ...editing, valid_users: selected })
|
||||
}
|
||||
/>
|
||||
|
||||
<ChipPicker
|
||||
label="Usuarios denegados (acceso explícitamente bloqueado)"
|
||||
options={systemUsers}
|
||||
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 })}
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onClose={() => setEditing(null)}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing?.id ? "Editar recurso SMB" : "Nuevo recurso SMB"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={save}>
|
||||
<DialogContent>
|
||||
<div className="space-y-4">
|
||||
{formError && (
|
||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smb-name">Nombre del recurso</Label>
|
||||
<Input
|
||||
id="smb-name"
|
||||
value={editing?.name ?? ""}
|
||||
disabled={!!editing?.id}
|
||||
onChange={(e) => setEditing((p) => p && { ...p, name: e.target.value })}
|
||||
placeholder="MiCarpeta"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label> Ruta </Label>
|
||||
<PathField
|
||||
value={editing?.path ?? ""}
|
||||
onChange={(v) => setEditing((p) => p && { ...p, path: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smb-comment">Comentario</Label>
|
||||
<Input
|
||||
id="smb-comment"
|
||||
value={editing?.comment ?? ""}
|
||||
onChange={(e) => setEditing((p) => p && { ...p, comment: e.target.value })}
|
||||
placeholder="Descripción opcional"
|
||||
/>
|
||||
Solo lectura
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.guest_ok ?? false}
|
||||
onChange={(e) => setEditing({ ...editing, guest_ok: e.target.checked })}
|
||||
/>
|
||||
Permitir invitado
|
||||
</label>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={editing?.read_only ?? false}
|
||||
onChange={(v: boolean) => setEditing((p) => p && { ...p, read_only: v })}
|
||||
/>
|
||||
<Label className="cursor-pointer">Solo lectura</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={editing?.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 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>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" type="button" onClick={() => setEditing(null)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{editing?.id ? "Guardar" : "Crear"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Eliminar recurso SMB"
|
||||
description="¿Eliminar este recurso? Se eliminará de smb.conf al aplicar."
|
||||
confirmLabel="Eliminar"
|
||||
destructive
|
||||
loading={deleteLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+212
-221
@@ -1,250 +1,241 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, formatBytes, SystemStatus, WatchedMount } from "../api";
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
system: "bg-blue-500/20 text-blue-300",
|
||||
mount: "bg-purple-500/20 text-purple-300",
|
||||
samba: "bg-emerald-500/20 text-emerald-300",
|
||||
nfs: "bg-amber-500/20 text-amber-300",
|
||||
manual: "bg-cyan-500/20 text-cyan-300",
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
system: "Sistema",
|
||||
mount: "Mount",
|
||||
samba: "SMB",
|
||||
nfs: "NFS",
|
||||
manual: "Vigilado",
|
||||
};
|
||||
|
||||
function DiskUsageItem({ disk }: { disk: SystemStatus["disks"][number] }) {
|
||||
return (
|
||||
<div className="mb-4 last:mb-0">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, WatchedMount, VersionInfo } from "../api";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Label } from "../components/ui/Label";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../components/ui/Card";
|
||||
import { EmptyState } from "../components/ui/EmptyState";
|
||||
import { Skeleton } from "../components/ui/Skeleton";
|
||||
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
|
||||
import PathField from "../components/PathField";
|
||||
import { useToast } from "../components/ui/Toast";
|
||||
import {
|
||||
HardDrive,
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
AlertCircle,
|
||||
Server,
|
||||
Cpu,
|
||||
CheckCircle,
|
||||
} from "../lib/icons";
|
||||
|
||||
export default function Settings() {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [watched, setWatched] = useState<WatchedMount[]>([]);
|
||||
const [newPath, setNewPath] = useState("");
|
||||
const [watchError, setWatchError] = useState<string | null>(null);
|
||||
const [watchLoading, setWatchLoading] = useState(false);
|
||||
const [mounts, setMounts] = useState<WatchedMount[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addingMount, setAddingMount] = useState(false);
|
||||
const [newMountPath, setNewMountPath] = useState("");
|
||||
const [mountLoading, setMountLoading] = useState(false);
|
||||
const [deletingMount, setDeletingMount] = useState<number | null>(null);
|
||||
const [deleteMountLoading, setDeleteMountLoading] = useState(false);
|
||||
const [version, setVersion] = useState<VersionInfo | null>(null);
|
||||
const [systemStatus, setSystemStatus] = useState<{ disks: { path: string; available: boolean; error?: string }[] } | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
api.systemStatus().then(setStatus).catch(() => setStatus(null));
|
||||
api.listWatchedMounts().then(r => setWatched(r.mounts ?? [])).catch(() => setWatched([]));
|
||||
api.listWatchedMounts().then(r => setMounts(r.mounts)).catch(() => {}).finally(() => setLoading(false));
|
||||
api.version().then(setVersion).catch(() => {});
|
||||
api.systemStatus().then(setSystemStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
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);
|
||||
async function addMount() {
|
||||
if (!newMountPath.trim()) return;
|
||||
setMountLoading(true);
|
||||
try {
|
||||
await api.changePassword(oldPassword, newPassword);
|
||||
setSuccess(true);
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
const m = await api.createWatchedMount(newMountPath.trim());
|
||||
setMounts((prev) => [...prev, m]);
|
||||
setNewMountPath("");
|
||||
setAddingMount(false);
|
||||
toast({ type: "success", title: "Punto de montaje añadido" });
|
||||
} 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 {
|
||||
setLoading(false);
|
||||
setMountLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addWatched(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setWatchError(null);
|
||||
const path = newPath.trim();
|
||||
if (!path) return;
|
||||
setWatchLoading(true);
|
||||
async function deleteMount(id: number) {
|
||||
setDeleteMountLoading(true);
|
||||
try {
|
||||
const m = await api.createWatchedMount(path);
|
||||
setWatched(prev => [...prev, m].sort((a, b) => a.path.localeCompare(b.path)));
|
||||
setNewPath("");
|
||||
await api.deleteWatchedMount(id);
|
||||
setMounts((prev) => prev.filter((m) => m.id !== id));
|
||||
setDeletingMount(null);
|
||||
toast({ type: "success", title: "Punto de montaje eliminado" });
|
||||
} 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 {
|
||||
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 (
|
||||
<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">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Cambiar contraseña</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-4 rounded-md bg-emerald-500/15 px-3 py-2 text-sm text-emerald-200">
|
||||
Contraseña cambiada correctamente.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Contraseña actual</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<HardDrive size={16} className="text-primary" />
|
||||
Puntos de montaje vigilados
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Rutas que nasctl monitoriza para mostrar uso de disco en el dashboard.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : mounts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<HardDrive size={28} />}
|
||||
title="Sin puntos de montaje"
|
||||
description="Añade rutas para monitorizar su uso de disco."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Nueva contraseña</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">Mínimo 8 caracteres.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Confirmar nueva contraseña</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<button className="btn-primary" type="submit" disabled={loading}>
|
||||
{loading ? "Guardando..." : "Guardar contraseña"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{mounts.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2">
|
||||
<HardDrive size={14} className="text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 font-mono text-sm text-foreground truncate">{m.path}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeletingMount(m.id)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Puntos de montaje vigilados
|
||||
</h2>
|
||||
|
||||
<form onSubmit={addWatched} className="mb-4 flex gap-2">
|
||||
<input
|
||||
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>
|
||||
{addingMount ? (
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label>Ruta</Label>
|
||||
<PathField
|
||||
value={newMountPath}
|
||||
onChange={(v) => setNewMountPath(v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Sin puntos de montaje vigilados.</p>
|
||||
)}
|
||||
<Button size="sm" onClick={addMount} loading={mountLoading}>
|
||||
Añadir
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setAddingMount(false); setNewMountPath(""); }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setAddingMount(true)}>
|
||||
<Plus size={14} />
|
||||
Añadir punto de montaje
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{manualDisks.length > 0 && (
|
||||
<div className="mt-4 border-t border-slate-800 pt-4">
|
||||
{manualDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Globe size={16} className="text-primary" />
|
||||
Importar configuración
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Importa recursos y exports existentes desde los archivos de configuración del sistema.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
{[
|
||||
{ label: "Importar Samba", onClick: async () => {
|
||||
try {
|
||||
const r = await api.importSamba();
|
||||
toast({ type: "success", title: `${r.imported} recursos importados de smb.conf` });
|
||||
} catch (e) { toast({ type: "error", title: "Error", description: e instanceof Error ? e.message : undefined }); }
|
||||
}},
|
||||
{ label: "Importar NFS", onClick: async () => {
|
||||
try {
|
||||
const r = await api.importNfs();
|
||||
toast({ type: "success", title: `${r.imported} exports importados de /etc/exports` });
|
||||
} catch (e) { toast({ type: "error", title: "Error", description: e instanceof Error ? e.message : undefined }); }
|
||||
}},
|
||||
{ label: "Importar Usuarios", onClick: async () => {
|
||||
try {
|
||||
const r = await api.importUsers();
|
||||
toast({ type: "success", title: `${r.imported} usuarios importados del sistema` });
|
||||
} catch (e) { toast({ type: "error", title: "Error", description: e instanceof Error ? e.message : undefined }); }
|
||||
}},
|
||||
].map(({ label, onClick }) => (
|
||||
<Button key={label} variant="outline" size="sm" onClick={onClick}>
|
||||
<RefreshCw size={12} />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Recursos compartidos
|
||||
</h2>
|
||||
{shareDisks.length > 0 ? (
|
||||
shareDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Sin shares ni exports configurados.</p>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Server size={16} className="text-primary" />
|
||||
Estado del sistema
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{systemStatus ? (
|
||||
<div className="space-y-2">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
+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 { api, User } from "../api";
|
||||
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 };
|
||||
|
||||
@@ -13,187 +33,318 @@ const empty: EditUser = {
|
||||
password: "",
|
||||
};
|
||||
|
||||
export default function Users() {
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 { toast } = useToast();
|
||||
|
||||
async function load() {
|
||||
const res = await api.listUsers();
|
||||
setUsers(res.users ?? []);
|
||||
try {
|
||||
const res = await api.listUsers();
|
||||
setUsers(res.users ?? []);
|
||||
} catch {
|
||||
setUsers([]);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
load().finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function save(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
setFormError(null);
|
||||
try {
|
||||
if (editing.id) {
|
||||
await api.updateUser(editing.id, editing);
|
||||
toast({ type: "success", title: "Usuario actualizado" });
|
||||
} else {
|
||||
await api.createUser(editing);
|
||||
toast({ type: "success", title: "Usuario creado" });
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await refresh();
|
||||
} 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) {
|
||||
if (!confirm("¿Eliminar este usuario? Se ejecutará userdel al aplicar.")) return;
|
||||
await api.deleteUser(id);
|
||||
await load();
|
||||
await refresh();
|
||||
async function handleDelete() {
|
||||
if (deleting === null) return;
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Usuarios del sistema</h1>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-ghost" onClick={async () => {
|
||||
try {
|
||||
const res = await api.importUsers();
|
||||
await load();
|
||||
await refresh();
|
||||
alert(`Importados ${res.imported} usuarios del sistema. Revisa y aplica los cambios.`);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Error al importar");
|
||||
}
|
||||
}}>
|
||||
Re-importar del sistema
|
||||
</button>
|
||||
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
||||
Nuevo usuario
|
||||
</button>
|
||||
<PageHeader
|
||||
title="Usuarios del sistema"
|
||||
description={`${users.length} usuario${users.length !== 1 ? "s" : ""} configurado${users.length !== 1 ? "s" : ""}`}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handleImport}>
|
||||
Re-importar del sistema
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditing({ ...empty })}>
|
||||
<Plus size={14} />
|
||||
Nuevo usuario
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{users.length > 0 && (
|
||||
<div className="max-w-sm">
|
||||
<Input
|
||||
placeholder="Buscar usuarios…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/></svg>}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+103
-6
@@ -1,17 +1,114 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#eff6ff",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
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: [],
|
||||
};
|
||||
};
|
||||
@@ -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