Files
Redflag/aggregator-web/src/components/Layout.tsx
Fimeg 2ade509b63 Update README with current features and screenshots
- Cross-platform support (Windows/Linux) with Windows Updates and Winget
- Added dependency confirmation workflow and refresh token authentication
- New screenshots: History, Live Operations, Windows Agent Details
- Local CLI features with terminal output and cache system
- Updated known limitations - Proxmox integration is broken
- Organized docs to docs/ folder and updated .gitignore
- Probably introduced a dozen bugs with Windows agents - stay tuned
2025-10-17 15:28:22 -04:00

340 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Computer,
Package,
Activity,
History,
Settings,
Menu,
X,
LogOut,
Search,
RefreshCw,
Container,
Bell,
} from 'lucide-react';
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
import { cn, formatRelativeTime } from '@/lib/utils';
interface LayoutProps {
children: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
const location = useLocation();
const navigate = useNavigate();
const { sidebarOpen, setSidebarOpen, setActiveTab } = useUIStore();
const { logout } = useAuthStore();
const { notifications, markNotificationRead, clearNotifications } = useRealtimeStore();
const [searchQuery, setSearchQuery] = useState('');
const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false);
const unreadCount = notifications.filter(n => !n.read).length;
const navigation = [
{
name: 'Dashboard',
href: '/dashboard',
icon: LayoutDashboard,
current: location.pathname === '/' || location.pathname === '/dashboard',
},
{
name: 'Agents',
href: '/agents',
icon: Computer,
current: location.pathname.startsWith('/agents'),
},
{
name: 'Updates',
href: '/updates',
icon: Package,
current: location.pathname.startsWith('/updates'),
},
{
name: 'Docker',
href: '/docker',
icon: Container,
current: location.pathname.startsWith('/docker'),
},
{
name: 'Live Operations',
href: '/live',
icon: Activity,
current: location.pathname === '/live',
},
{
name: 'History',
href: '/history',
icon: History,
current: location.pathname === '/history',
},
{
name: 'Settings',
href: '/settings',
icon: Settings,
current: location.pathname === '/settings',
},
];
const handleLogout = () => {
logout();
localStorage.removeItem('auth_token');
navigate('/login');
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
// Navigate to updates page with search query
navigate(`/updates?search=${encodeURIComponent(searchQuery.trim())}`);
setSearchQuery('');
}
};
// Notification helper functions
const getNotificationIcon = (type: string) => {
switch (type) {
case 'success':
return '✅';
case 'error':
return '❌';
case 'warning':
return '⚠️';
default:
return '';
}
};
const getNotificationColor = (type: string) => {
switch (type) {
case 'success':
return 'border-green-200 bg-green-50';
case 'error':
return 'border-red-200 bg-red-50';
case 'warning':
return 'border-yellow-200 bg-yellow-50';
default:
return 'border-blue-200 bg-blue-50';
}
};
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
<div
className={cn(
'fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">🚩</span>
</div>
<h1 className="text-xl font-bold text-gray-900">RedFlag</h1>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden text-gray-500 hover:text-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
<nav className="mt-6 px-3">
<div className="space-y-1">
{navigation.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.name}
to={item.href}
onClick={() => setActiveTab(item.name)}
className={cn(
'group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors',
item.current
? 'bg-primary-50 text-primary-700 border-r-2 border-primary-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
)}
>
<Icon
className={cn(
'mr-3 h-5 w-5 flex-shrink-0',
item.current ? 'text-primary-700' : 'text-gray-400 group-hover:text-gray-500'
)}
/>
{item.name}
</Link>
);
})}
</div>
</nav>
{/* User section */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
<button
onClick={handleLogout}
className="flex items-center w-full px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
>
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
Logout
</button>
</div>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col lg:pl-0">
{/* Top header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
<div className="flex items-center space-x-4 flex-1">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden text-gray-500 hover:text-gray-700"
>
<Menu className="w-5 h-5" />
</button>
{/* Search */}
<form onSubmit={handleSearch} className="hidden md:block flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search updates..."
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</form>
</div>
{/* Header actions - right to left order */}
<div className="flex items-center space-x-2 ml-4">
{/* Refresh button */}
<button
onClick={() => window.location.reload()}
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors"
title="Refresh page"
>
<RefreshCw className="w-4 h-4" />
</button>
{/* Notifications */}
<div className="relative">
<button
onClick={() => setIsNotificationDropdownOpen(!isNotificationDropdownOpen)}
className="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors relative"
title="Notifications"
>
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-600 text-white text-xs rounded-full flex items-center justify-center font-medium">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Notifications dropdown */}
{isNotificationDropdownOpen && (
<div className="absolute top-12 right-0 w-96 bg-white rounded-lg shadow-lg border border-gray-200 max-h-96 overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">Notifications</h3>
<div className="flex items-center space-x-2">
{notifications.length > 0 && (
<button
onClick={clearNotifications}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Clear All
</button>
)}
<button
onClick={() => setIsNotificationDropdownOpen(false)}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
</button>
</div>
</div>
{/* Notifications list */}
<div className="overflow-y-auto max-h-80">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<Bell className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p>No notifications</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={cn(
'p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors',
!notification.read && 'bg-blue-50 border-l-4 border-l-blue-500',
getNotificationColor(notification.type)
)}
onClick={() => {
markNotificationRead(notification.id);
setIsNotificationDropdownOpen(false);
}}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-0.5 text-lg">
{getNotificationIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900">
{notification.title}
</p>
{!notification.read && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
New
</span>
)}
</div>
<p className="text-sm text-gray-600 mt-1">
{notification.message}
</p>
<p className="text-xs text-gray-400 mt-2">
{formatRelativeTime(notification.timestamp)}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
</div>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto">
<div className="py-6">
{children}
</div>
</main>
</div>
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
onClick={() => setSidebarOpen(false)}
></div>
)}
</div>
);
};
export default Layout;