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, Zap, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaReddit, FaYoutube } from "react-icons/fa"; import { Link, useLocation, useNavigate } from "react-router-dom"; import trianglify from "trianglify"; import { useAuth } from "../contexts/AuthContext"; import { useColorTheme } from "../contexts/ColorThemeContext"; 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 { themeConfig } = useColorTheme(); const userMenuRef = useRef(null); const bgCanvasRef = 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, }); } // Add Automation item (available to all users with inventory access) inventoryItems.push({ name: "Automation", href: "/automation", icon: RefreshCw, new: true, }); if (canViewReports()) { inventoryItems.push( { name: "Docker", href: "/docker", icon: Container, beta: true, }, { name: "Services", href: "/services", icon: Activity, comingSoon: true, }, { name: "Reporting", href: "/reporting", icon: BarChart3, comingSoon: true, }, ); } // Add Pro-Action item (available to all users with inventory access) inventoryItems.push({ name: "Pro-Action", href: "/pro-action", icon: Zap, 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 === "/pro-action") return "Pro-Action"; if (path === "/automation") return "Automation"; 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"); }; // Generate Trianglify background for dark mode useEffect(() => { const generateBackground = () => { if ( bgCanvasRef.current && themeConfig?.login && document.documentElement.classList.contains("dark") ) { // Get current date as seed for daily variation const today = new Date(); const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; // Generate pattern with selected theme configuration const pattern = trianglify({ width: window.innerWidth, height: window.innerHeight, cellSize: themeConfig.login.cellSize, variance: themeConfig.login.variance, seed: dateSeed, xColors: themeConfig.login.xColors, yColors: themeConfig.login.yColors, }); // Render to canvas pattern.toCanvas(bgCanvasRef.current); } }; generateBackground(); // Regenerate on window resize or theme change const handleResize = () => { generateBackground(); }; window.addEventListener("resize", handleResize); // Watch for dark mode changes const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName === "class") { generateBackground(); } }); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); return () => { window.removeEventListener("resize", handleResize); observer.disconnect(); }; }, [themeConfig]); // Fetch GitHub stars count const fetchGitHubStars = useCallback(async () => { // Try to load cached star count first const cachedStars = localStorage.getItem("githubStarsCount"); if (cachedStars) { setGithubStars(parseInt(cachedStars, 10)); } // Skip API call if fetched recently const lastFetch = localStorage.getItem("githubStarsFetchTime"); const now = Date.now(); if (lastFetch && now - parseInt(lastFetch, 10) < 600000) { // 10 minute cache return; } try { const response = await fetch( "https://api.github.com/repos/9technologygroup/patchmon.net", { headers: { Accept: "application/vnd.github.v3+json", }, }, ); if (response.ok) { const data = await response.json(); setGithubStars(data.stargazers_count); localStorage.setItem( "githubStarsCount", data.stargazers_count.toString(), ); localStorage.setItem("githubStarsFetchTime", now.toString()); } else if (response.status === 403 || response.status === 429) { console.warn("GitHub API rate limit exceeded, using cached value"); } } catch (error) { console.error("Failed to fetch GitHub stars:", error); // Keep using cached value if available } }, []); // 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]); // Set CSS custom properties for glassmorphism and theme colors in dark mode useEffect(() => { const updateThemeStyles = () => { const isDark = document.documentElement.classList.contains("dark"); const root = document.documentElement; if (isDark && themeConfig?.app) { // Glass navigation bars - very light for pattern visibility root.style.setProperty("--sidebar-bg", "rgba(0, 0, 0, 0.15)"); root.style.setProperty("--sidebar-blur", "blur(12px)"); root.style.setProperty("--topbar-bg", "rgba(0, 0, 0, 0.15)"); root.style.setProperty("--topbar-blur", "blur(12px)"); root.style.setProperty("--button-bg", "rgba(255, 255, 255, 0.15)"); root.style.setProperty("--button-blur", "blur(8px)"); // Theme-colored cards and buttons - darker to stand out root.style.setProperty("--card-bg", themeConfig.app.cardBg); root.style.setProperty("--card-border", themeConfig.app.cardBorder); root.style.setProperty("--card-bg-hover", themeConfig.app.bgTertiary); root.style.setProperty("--theme-button-bg", themeConfig.app.buttonBg); root.style.setProperty( "--theme-button-hover", themeConfig.app.buttonHover, ); } else { // Light mode - standard colors root.style.setProperty("--sidebar-bg", "white"); root.style.setProperty("--sidebar-blur", "none"); root.style.setProperty("--topbar-bg", "white"); root.style.setProperty("--topbar-blur", "none"); root.style.setProperty("--button-bg", "white"); root.style.setProperty("--button-blur", "none"); root.style.setProperty("--card-bg", "white"); root.style.setProperty("--card-border", "#e5e7eb"); root.style.setProperty("--card-bg-hover", "#f9fafb"); root.style.setProperty("--theme-button-bg", "#f3f4f6"); root.style.setProperty("--theme-button-hover", "#e5e7eb"); } }; updateThemeStyles(); // Watch for dark mode changes const observer = new MutationObserver(() => { updateThemeStyles(); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); return () => observer.disconnect(); }, [themeConfig]); return (
{/* Full-screen Trianglify Background (Dark Mode Only) */}
{/* Mobile sidebar */}
{/* Desktop sidebar */}
{/* Collapse/Expand button on border */}
{sidebarCollapsed ? ( PatchMon ) : ( )}
{/* Profile Section - Bottom of Sidebar */}
{!sidebarCollapsed ? (
{/* User Info with Sign Out - Username is clickable */}
{user?.first_name || user?.username} {user?.role === "admin" && ( Role: Admin )}
{/* Updated info */} {stats && (
Updated: {formatRelativeTimeShort(stats.lastUpdated)} {versionInfo && ( v{versionInfo.version} )}
)}
) : (
{/* Updated info for collapsed sidebar */} {stats && (
{versionInfo && ( v{versionInfo.version} )}
)}
)}
{/* Main content */}
{/* Top bar */}
{/* Separator */}
{/* Page title - hidden on dashboard, hosts, repositories, packages, automation, docker, and host details to give more space to search */} {![ "/", "/hosts", "/repositories", "/packages", "/automation", "/docker", ].includes(location.pathname) && !location.pathname.startsWith("/hosts/") && !location.pathname.startsWith("/docker/") && (

{getPageTitle()}

)} {/* Global Search Bar */}
{/* External Links */}
{/* 1) GitHub */} {githubStars !== null && (
{githubStars}
)}
{/* 2) Buy Me a Coffee */} Buy Me a Coffee {/* 3) Roadmap */} {/* 4) Docs */} {/* 5) Discord */} {/* 6) Email */} {/* 7) YouTube */} {/* 8) Reddit */} {/* 9) Web */}
{children}
); }; export default Layout;