mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-09 08:26:16 +00:00
956 lines
33 KiB
JavaScript
956 lines
33 KiB
JavaScript
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Activity,
|
|
BarChart3,
|
|
BookOpen,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Clock,
|
|
Container,
|
|
GitBranch,
|
|
Github,
|
|
Globe,
|
|
Home,
|
|
LogOut,
|
|
Mail,
|
|
Menu,
|
|
Package,
|
|
Plus,
|
|
RefreshCw,
|
|
Route,
|
|
Server,
|
|
Settings,
|
|
Star,
|
|
UserCircle,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
|
import { useAuth } from "../contexts/AuthContext";
|
|
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
|
import { dashboardAPI, versionAPI } from "../utils/api";
|
|
import DiscordIcon from "./DiscordIcon";
|
|
import GlobalSearch from "./GlobalSearch";
|
|
import Logo from "./Logo";
|
|
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
|
|
|
|
const Layout = ({ children }) => {
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
|
// Load sidebar state from localStorage, default to false
|
|
const saved = localStorage.getItem("sidebarCollapsed");
|
|
return saved ? JSON.parse(saved) : false;
|
|
});
|
|
const [_userMenuOpen, setUserMenuOpen] = useState(false);
|
|
const [githubStars, setGithubStars] = useState(null);
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const {
|
|
user,
|
|
logout,
|
|
canViewDashboard,
|
|
canViewHosts,
|
|
canManageHosts,
|
|
canViewPackages,
|
|
canViewUsers,
|
|
canManageUsers,
|
|
canViewReports,
|
|
canExportData,
|
|
canManageSettings,
|
|
} = useAuth();
|
|
const { updateAvailable } = useUpdateNotification();
|
|
const userMenuRef = useRef(null);
|
|
|
|
// Fetch dashboard stats for the "Last updated" info
|
|
const {
|
|
data: stats,
|
|
refetch,
|
|
isFetching,
|
|
} = useQuery({
|
|
queryKey: ["dashboardStats"],
|
|
queryFn: () => dashboardAPI.getStats().then((res) => res.data),
|
|
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
|
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
|
});
|
|
|
|
// Fetch version info
|
|
const { data: versionInfo } = useQuery({
|
|
queryKey: ["versionInfo"],
|
|
queryFn: () => versionAPI.getCurrent().then((res) => res.data),
|
|
staleTime: 300000, // Consider data stale after 5 minutes
|
|
});
|
|
|
|
// Build navigation based on permissions
|
|
const buildNavigation = () => {
|
|
const nav = [];
|
|
|
|
// Dashboard - only show if user can view dashboard
|
|
if (canViewDashboard()) {
|
|
nav.push({ name: "Dashboard", href: "/", icon: Home });
|
|
}
|
|
|
|
// Inventory section - only show if user has any inventory permissions
|
|
if (canViewHosts() || canViewPackages() || canViewReports()) {
|
|
const inventoryItems = [];
|
|
|
|
if (canViewHosts()) {
|
|
inventoryItems.push({ name: "Hosts", href: "/hosts", icon: Server });
|
|
inventoryItems.push({
|
|
name: "Repos",
|
|
href: "/repositories",
|
|
icon: GitBranch,
|
|
});
|
|
}
|
|
|
|
if (canViewPackages()) {
|
|
inventoryItems.push({
|
|
name: "Packages",
|
|
href: "/packages",
|
|
icon: Package,
|
|
});
|
|
}
|
|
|
|
if (canViewReports()) {
|
|
inventoryItems.push(
|
|
{
|
|
name: "Services",
|
|
href: "/services",
|
|
icon: Activity,
|
|
comingSoon: true,
|
|
},
|
|
{
|
|
name: "Docker",
|
|
href: "/docker",
|
|
icon: Container,
|
|
comingSoon: true,
|
|
},
|
|
{
|
|
name: "Reporting",
|
|
href: "/reporting",
|
|
icon: BarChart3,
|
|
comingSoon: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
if (inventoryItems.length > 0) {
|
|
nav.push({
|
|
section: "Inventory",
|
|
items: inventoryItems,
|
|
});
|
|
}
|
|
}
|
|
|
|
return nav;
|
|
};
|
|
|
|
// Build settings navigation separately (for bottom placement)
|
|
const buildSettingsNavigation = () => {
|
|
const settingsNav = [];
|
|
|
|
// Settings section - consolidated all settings into one page
|
|
if (
|
|
canManageSettings() ||
|
|
canViewUsers() ||
|
|
canManageUsers() ||
|
|
canViewReports() ||
|
|
canExportData()
|
|
) {
|
|
const settingsItems = [];
|
|
|
|
settingsItems.push({
|
|
name: "Settings",
|
|
href: "/settings/users",
|
|
icon: Settings,
|
|
showUpgradeIcon: updateAvailable,
|
|
});
|
|
|
|
settingsNav.push({
|
|
section: "Settings",
|
|
items: settingsItems,
|
|
});
|
|
}
|
|
|
|
return settingsNav;
|
|
};
|
|
|
|
const navigation = buildNavigation();
|
|
const settingsNavigation = buildSettingsNavigation();
|
|
|
|
const isActive = (path) => location.pathname === path;
|
|
|
|
// Get page title based on current route
|
|
const getPageTitle = () => {
|
|
const path = location.pathname;
|
|
|
|
if (path === "/") return "Dashboard";
|
|
if (path === "/hosts") return "Hosts";
|
|
if (path === "/packages") return "Packages";
|
|
if (path === "/repositories" || path.startsWith("/repositories/"))
|
|
return "Repositories";
|
|
if (path === "/services") return "Services";
|
|
if (path === "/docker") return "Docker";
|
|
if (path === "/users") return "Users";
|
|
if (path === "/permissions") return "Permissions";
|
|
if (path === "/settings") return "Settings";
|
|
if (path === "/options") return "PatchMon Options";
|
|
if (path === "/audit-log") return "Audit Log";
|
|
if (path === "/settings/profile") return "My Profile";
|
|
if (path.startsWith("/hosts/")) return "Host Details";
|
|
if (path.startsWith("/packages/")) return "Package Details";
|
|
if (path.startsWith("/settings/")) return "Settings";
|
|
|
|
return "PatchMon";
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
await logout();
|
|
setUserMenuOpen(false);
|
|
};
|
|
|
|
const handleAddHost = () => {
|
|
// Navigate to hosts page with add modal parameter
|
|
navigate("/hosts?action=add");
|
|
};
|
|
|
|
// Fetch GitHub stars count
|
|
const fetchGitHubStars = useCallback(async () => {
|
|
// Skip if already fetched recently
|
|
const lastFetch = localStorage.getItem("githubStarsFetchTime");
|
|
const now = Date.now();
|
|
if (lastFetch && now - parseInt(lastFetch, 15) < 600000) {
|
|
// 15 minute cache
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(
|
|
"https://api.github.com/repos/9technologygroup/patchmon.net",
|
|
);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setGithubStars(data.stargazers_count);
|
|
localStorage.setItem("githubStarsFetchTime", now.toString());
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch GitHub stars:", error);
|
|
}
|
|
}, []);
|
|
|
|
// Short format for navigation area
|
|
const formatRelativeTimeShort = (date) => {
|
|
if (!date) return "Never";
|
|
|
|
const now = new Date();
|
|
const dateObj = new Date(date);
|
|
|
|
// Check if date is valid
|
|
if (Number.isNaN(dateObj.getTime())) return "Invalid date";
|
|
|
|
const diff = now - dateObj;
|
|
const seconds = Math.floor(diff / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
|
|
if (days > 0) return `${days}d ago`;
|
|
if (hours > 0) return `${hours}h ago`;
|
|
if (minutes > 0) return `${minutes}m ago`;
|
|
return `${seconds}s ago`;
|
|
};
|
|
|
|
// Save sidebar collapsed state to localStorage
|
|
useEffect(() => {
|
|
localStorage.setItem("sidebarCollapsed", JSON.stringify(sidebarCollapsed));
|
|
}, [sidebarCollapsed]);
|
|
|
|
// Close user menu when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event) => {
|
|
if (userMenuRef.current && !userMenuRef.current.contains(event.target)) {
|
|
setUserMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
// Fetch GitHub stars on component mount
|
|
useEffect(() => {
|
|
fetchGitHubStars();
|
|
}, [fetchGitHubStars]);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-secondary-50">
|
|
{/* Mobile sidebar */}
|
|
<div
|
|
className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="fixed inset-0 bg-secondary-600 bg-opacity-75 cursor-default"
|
|
onClick={() => setSidebarOpen(false)}
|
|
aria-label="Close sidebar"
|
|
/>
|
|
<div className="relative flex w-full max-w-[280px] flex-col bg-white pb-4 pt-5 shadow-xl">
|
|
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
|
<button
|
|
type="button"
|
|
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
|
onClick={() => setSidebarOpen(false)}
|
|
>
|
|
<X className="h-6 w-6 text-white" />
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-shrink-0 items-center justify-center px-4">
|
|
<Link to="/" className="flex items-center">
|
|
<Logo className="h-10 w-auto" alt="PatchMon Logo" />
|
|
</Link>
|
|
</div>
|
|
<nav className="mt-8 flex-1 space-y-6 px-2">
|
|
{/* Show message for users with very limited permissions */}
|
|
{navigation.length === 0 && settingsNavigation.length === 0 && (
|
|
<div className="px-2 py-4 text-center">
|
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
|
<p className="mb-2">Limited access</p>
|
|
<p className="text-xs">
|
|
Contact your administrator for additional permissions
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{navigation.map((item) => {
|
|
if (item.name) {
|
|
// Single item (Dashboard)
|
|
return (
|
|
<Link
|
|
key={item.name}
|
|
to={item.href}
|
|
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
|
isActive(item.href)
|
|
? "bg-primary-100 text-primary-900"
|
|
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
|
|
}`}
|
|
onClick={() => setSidebarOpen(false)}
|
|
>
|
|
<item.icon className="mr-3 h-5 w-5" />
|
|
{item.name}
|
|
</Link>
|
|
);
|
|
} else if (item.section) {
|
|
// Section with items
|
|
return (
|
|
<div key={item.section}>
|
|
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2">
|
|
{item.section}
|
|
</h3>
|
|
<div className="space-y-1">
|
|
{item.items.map((subItem) => (
|
|
<div key={subItem.name}>
|
|
{subItem.name === "Hosts" && canManageHosts() ? (
|
|
// Special handling for Hosts item with integrated + button (mobile)
|
|
<Link
|
|
to={subItem.href}
|
|
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
|
isActive(subItem.href)
|
|
? "bg-primary-100 text-primary-900"
|
|
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
|
|
}`}
|
|
onClick={() => setSidebarOpen(false)}
|
|
>
|
|
<subItem.icon className="mr-3 h-5 w-5" />
|
|
<span className="flex items-center gap-2 flex-1">
|
|
{subItem.name}
|
|
{subItem.name === "Hosts" &&
|
|
stats?.cards?.totalHosts !== undefined && (
|
|
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
|
|
{stats.cards.totalHosts}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setSidebarOpen(false);
|
|
handleAddHost();
|
|
}}
|
|
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
|
|
title="Add Host"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
</Link>
|
|
) : (
|
|
// Standard navigation item (mobile)
|
|
<Link
|
|
to={subItem.href}
|
|
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
|
isActive(subItem.href)
|
|
? "bg-primary-100 text-primary-900"
|
|
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
|
|
} ${subItem.comingSoon ? "opacity-50 cursor-not-allowed" : ""}`}
|
|
onClick={
|
|
subItem.comingSoon
|
|
? (e) => e.preventDefault()
|
|
: () => setSidebarOpen(false)
|
|
}
|
|
>
|
|
<subItem.icon className="mr-3 h-5 w-5" />
|
|
<span className="flex items-center gap-2">
|
|
{subItem.name}
|
|
{subItem.name === "Hosts" &&
|
|
stats?.cards?.totalHosts !== undefined && (
|
|
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
|
|
{stats.cards.totalHosts}
|
|
</span>
|
|
)}
|
|
{subItem.comingSoon && (
|
|
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
|
Soon
|
|
</span>
|
|
)}
|
|
</span>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
|
|
{/* Settings Section - Mobile */}
|
|
{settingsNavigation.map((item) => {
|
|
if (item.section) {
|
|
// Settings section (no heading)
|
|
return (
|
|
<div key={item.section}>
|
|
<div className="space-y-1">
|
|
{item.items.map((subItem) => (
|
|
<Link
|
|
key={subItem.name}
|
|
to={subItem.href}
|
|
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
|
isActive(subItem.href)
|
|
? "bg-primary-100 text-primary-900"
|
|
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
|
|
}`}
|
|
onClick={() => setSidebarOpen(false)}
|
|
>
|
|
<subItem.icon className="mr-3 h-5 w-5" />
|
|
<span className="flex items-center gap-2">
|
|
{subItem.name}
|
|
{subItem.showUpgradeIcon && (
|
|
<UpgradeNotificationIcon className="h-3 w-3" />
|
|
)}
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop sidebar */}
|
|
<div
|
|
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 relative ${
|
|
sidebarCollapsed ? "lg:w-16" : "lg:w-56"
|
|
} bg-white dark:bg-secondary-800`}
|
|
>
|
|
<div
|
|
className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
|
|
sidebarCollapsed ? "px-2 shadow-lg" : "px-6"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${
|
|
sidebarCollapsed ? "justify-center" : "justify-center"
|
|
}`}
|
|
>
|
|
{sidebarCollapsed ? (
|
|
<Link to="/" className="flex items-center">
|
|
<img
|
|
src="/assets/favicon.svg"
|
|
alt="PatchMon"
|
|
className="h-12 w-12 object-contain"
|
|
/>
|
|
</Link>
|
|
) : (
|
|
<Link to="/" className="flex items-center">
|
|
<Logo className="h-10 w-auto" alt="PatchMon Logo" />
|
|
</Link>
|
|
)}
|
|
</div>
|
|
{/* Collapse/Expand button on border */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
className="absolute top-5 -right-3 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 shadow-md hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
|
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
>
|
|
{sidebarCollapsed ? (
|
|
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
|
|
) : (
|
|
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
|
|
)}
|
|
</button>
|
|
<nav className="flex flex-1 flex-col">
|
|
<ul className="flex flex-1 flex-col gap-y-6">
|
|
{/* Show message for users with very limited permissions */}
|
|
{navigation.length === 0 && settingsNavigation.length === 0 && (
|
|
<li className="px-2 py-4 text-center">
|
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
|
<p className="mb-2">Limited access</p>
|
|
<p className="text-xs">
|
|
Contact your administrator for additional permissions
|
|
</p>
|
|
</div>
|
|
</li>
|
|
)}
|
|
{navigation.map((item) => {
|
|
if (item.name) {
|
|
// Single item (Dashboard)
|
|
return (
|
|
<li
|
|
key={item.name}
|
|
className={sidebarCollapsed ? "" : "-mx-2"}
|
|
>
|
|
<Link
|
|
to={item.href}
|
|
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
|
|
isActive(item.href)
|
|
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
|
|
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
} ${sidebarCollapsed ? "justify-center p-2" : "p-2"}`}
|
|
title={sidebarCollapsed ? item.name : ""}
|
|
>
|
|
<item.icon
|
|
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
|
|
/>
|
|
{!sidebarCollapsed && (
|
|
<span className="truncate">{item.name}</span>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
);
|
|
} else if (item.section) {
|
|
// Section with items
|
|
return (
|
|
<li key={item.section}>
|
|
{!sidebarCollapsed && (
|
|
<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2">
|
|
{item.section}
|
|
</h3>
|
|
)}
|
|
<ul
|
|
className={`space-y-1 ${sidebarCollapsed ? "" : "-mx-2"}`}
|
|
>
|
|
{item.items.map((subItem) => (
|
|
<li key={subItem.name}>
|
|
{subItem.name === "Hosts" && canManageHosts() ? (
|
|
// Special handling for Hosts item with integrated + button
|
|
<div className="flex items-center gap-1">
|
|
<Link
|
|
to={subItem.href}
|
|
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 flex-1 ${
|
|
isActive(subItem.href)
|
|
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
|
|
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
} ${sidebarCollapsed ? "justify-center p-2" : "p-2"}`}
|
|
title={sidebarCollapsed ? subItem.name : ""}
|
|
>
|
|
<subItem.icon
|
|
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
|
|
/>
|
|
{!sidebarCollapsed && (
|
|
<span className="truncate flex items-center gap-2 flex-1">
|
|
{subItem.name}
|
|
{subItem.name === "Hosts" &&
|
|
stats?.cards?.totalHosts !==
|
|
undefined && (
|
|
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
|
|
{stats.cards.totalHosts}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
{!sidebarCollapsed && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleAddHost();
|
|
}}
|
|
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
|
|
title="Add Host"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
// Standard navigation item
|
|
<Link
|
|
to={subItem.href}
|
|
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 ${
|
|
isActive(subItem.href)
|
|
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
|
|
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
} ${sidebarCollapsed ? "justify-center p-2 relative" : "p-2"} ${
|
|
subItem.comingSoon
|
|
? "opacity-50 cursor-not-allowed"
|
|
: ""
|
|
}`}
|
|
title={sidebarCollapsed ? subItem.name : ""}
|
|
onClick={
|
|
subItem.comingSoon
|
|
? (e) => e.preventDefault()
|
|
: undefined
|
|
}
|
|
>
|
|
<div
|
|
className={`flex items-center ${sidebarCollapsed ? "justify-center" : ""}`}
|
|
>
|
|
<subItem.icon
|
|
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
|
|
/>
|
|
{sidebarCollapsed &&
|
|
subItem.showUpgradeIcon && (
|
|
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
|
|
)}
|
|
</div>
|
|
{!sidebarCollapsed && (
|
|
<span className="truncate flex items-center gap-2">
|
|
{subItem.name}
|
|
{subItem.name === "Hosts" &&
|
|
stats?.cards?.totalHosts !==
|
|
undefined && (
|
|
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
|
|
{stats.cards.totalHosts}
|
|
</span>
|
|
)}
|
|
{subItem.comingSoon && (
|
|
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
|
Soon
|
|
</span>
|
|
)}
|
|
{subItem.showUpgradeIcon && (
|
|
<UpgradeNotificationIcon className="h-3 w-3" />
|
|
)}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</li>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
</ul>
|
|
|
|
{/* Settings Section - Bottom of Navigation */}
|
|
{settingsNavigation.length > 0 && (
|
|
<ul className="gap-y-6">
|
|
{settingsNavigation.map((item) => {
|
|
if (item.section) {
|
|
// Settings section (no heading)
|
|
return (
|
|
<li key={item.section}>
|
|
<ul
|
|
className={`space-y-1 ${sidebarCollapsed ? "" : "-mx-2"}`}
|
|
>
|
|
{item.items.map((subItem) => (
|
|
<li key={subItem.name}>
|
|
<Link
|
|
to={subItem.href}
|
|
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 ${
|
|
isActive(subItem.href)
|
|
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
|
|
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
} ${sidebarCollapsed ? "justify-center p-2 relative" : "p-2"}`}
|
|
title={sidebarCollapsed ? subItem.name : ""}
|
|
>
|
|
<div
|
|
className={`flex items-center ${sidebarCollapsed ? "justify-center" : ""}`}
|
|
>
|
|
<subItem.icon
|
|
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
|
|
/>
|
|
{sidebarCollapsed &&
|
|
subItem.showUpgradeIcon && (
|
|
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
|
|
)}
|
|
</div>
|
|
{!sidebarCollapsed && (
|
|
<span className="truncate flex items-center gap-2">
|
|
{subItem.name}
|
|
{subItem.showUpgradeIcon && (
|
|
<UpgradeNotificationIcon className="h-3 w-3" />
|
|
)}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</li>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
</ul>
|
|
)}
|
|
</nav>
|
|
|
|
{/* Profile Section - Bottom of Sidebar */}
|
|
<div className="border-t border-secondary-200 dark:border-secondary-600">
|
|
{!sidebarCollapsed ? (
|
|
<div>
|
|
{/* User Info with Sign Out - Username is clickable */}
|
|
<div className="flex items-center justify-between -mx-2 py-2">
|
|
<Link
|
|
to="/settings/profile"
|
|
className={`flex-1 min-w-0 rounded-md p-2 transition-all duration-200 ${
|
|
isActive("/settings/profile")
|
|
? "bg-primary-50 dark:bg-primary-600"
|
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-x-3">
|
|
<UserCircle
|
|
className={`h-5 w-5 shrink-0 ${
|
|
isActive("/settings/profile")
|
|
? "text-primary-700 dark:text-white"
|
|
: "text-secondary-500 dark:text-secondary-400"
|
|
}`}
|
|
/>
|
|
<div className="flex flex-col min-w-0">
|
|
<span
|
|
className={`text-sm leading-6 font-semibold truncate ${
|
|
isActive("/settings/profile")
|
|
? "text-primary-700 dark:text-white"
|
|
: "text-secondary-700 dark:text-secondary-200"
|
|
}`}
|
|
>
|
|
{user?.first_name || user?.username}
|
|
</span>
|
|
{user?.role === "admin" && (
|
|
<span
|
|
className={`text-xs leading-4 ${
|
|
isActive("/settings/profile")
|
|
? "text-primary-600 dark:text-primary-200"
|
|
: "text-secondary-500 dark:text-secondary-400"
|
|
}`}
|
|
>
|
|
Role: Admin
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={handleLogout}
|
|
className="ml-2 p-1.5 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded transition-colors"
|
|
title="Sign out"
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
{/* Updated info */}
|
|
{stats && (
|
|
<div className="px-2 py-1 border-t border-secondary-200 dark:border-secondary-700">
|
|
<div className="flex items-center gap-x-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
<Clock className="h-3 w-3 flex-shrink-0" />
|
|
<span className="truncate">
|
|
Updated: {formatRelativeTimeShort(stats.lastUpdated)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0 disabled:opacity-50"
|
|
title="Refresh data"
|
|
>
|
|
<RefreshCw
|
|
className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`}
|
|
/>
|
|
</button>
|
|
{versionInfo && (
|
|
<span className="text-xs text-secondary-400 dark:text-secondary-500 flex-shrink-0">
|
|
v{versionInfo.version}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<Link
|
|
to="/settings/profile"
|
|
className={`flex items-center justify-center p-2 rounded-md transition-colors ${
|
|
isActive("/settings/profile")
|
|
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
|
|
: "text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
}`}
|
|
title={`My Profile (${user?.username})`}
|
|
>
|
|
<UserCircle className="h-5 w-5" />
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={handleLogout}
|
|
className="flex items-center justify-center w-full p-2 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-md transition-colors"
|
|
title="Sign out"
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
</button>
|
|
{/* Updated info for collapsed sidebar */}
|
|
{stats && (
|
|
<div className="flex flex-col items-center py-1 border-t border-secondary-200 dark:border-secondary-700">
|
|
<button
|
|
type="button"
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded disabled:opacity-50"
|
|
title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
|
|
>
|
|
<RefreshCw
|
|
className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`}
|
|
/>
|
|
</button>
|
|
{versionInfo && (
|
|
<span className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">
|
|
v{versionInfo.version}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content */}
|
|
<div
|
|
className={`flex flex-col min-h-screen transition-all duration-300 ${
|
|
sidebarCollapsed ? "lg:pl-16" : "lg:pl-56"
|
|
}`}
|
|
>
|
|
{/* Top bar */}
|
|
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
|
|
<button
|
|
type="button"
|
|
className="-m-2.5 p-2.5 text-secondary-700 lg:hidden"
|
|
onClick={() => setSidebarOpen(true)}
|
|
>
|
|
<Menu className="h-6 w-6" />
|
|
</button>
|
|
|
|
{/* Separator */}
|
|
<div className="h-6 w-px bg-secondary-200 lg:hidden" />
|
|
|
|
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
|
<div className="relative flex items-center">
|
|
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 whitespace-nowrap">
|
|
{getPageTitle()}
|
|
</h2>
|
|
</div>
|
|
|
|
{/* Global Search Bar */}
|
|
<div className="hidden md:flex items-center max-w-sm">
|
|
<GlobalSearch />
|
|
</div>
|
|
|
|
<div className="flex flex-1 items-center gap-x-4 lg:gap-x-6 justify-end">
|
|
{/* External Links */}
|
|
<div className="flex items-center gap-2">
|
|
<a
|
|
href="https://github.com/PatchMon/PatchMon"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm group relative"
|
|
>
|
|
<Github className="h-5 w-5 flex-shrink-0" />
|
|
{githubStars !== null && (
|
|
<div className="flex items-center gap-0.5">
|
|
<Star className="h-3 w-3 fill-current text-yellow-500" />
|
|
<span className="text-sm font-medium">{githubStars}</span>
|
|
</div>
|
|
)}
|
|
</a>
|
|
<a
|
|
href="https://github.com/orgs/PatchMon/projects/2/views/1"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
|
title="Roadmap"
|
|
>
|
|
<Route className="h-5 w-5" />
|
|
</a>
|
|
<a
|
|
href="https://patchmon.net/discord"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
|
title="Discord"
|
|
>
|
|
<DiscordIcon className="h-5 w-5" />
|
|
</a>
|
|
<a
|
|
href="https://docs.patchmon.net"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
|
title="Documentation"
|
|
>
|
|
<BookOpen className="h-5 w-5" />
|
|
</a>
|
|
<a
|
|
href="mailto:support@patchmon.net"
|
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
|
title="Email support@patchmon.net"
|
|
>
|
|
<Mail className="h-5 w-5" />
|
|
</a>
|
|
<a
|
|
href="https://patchmon.net"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
|
title="Visit patchmon.net"
|
|
>
|
|
<Globe className="h-5 w-5" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800">
|
|
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Layout;
|