gitea
This commit is contained in:
49
frontend/components/layout/dashboard-layout.tsx
Normal file
49
frontend/components/layout/dashboard-layout.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
import { Sidebar } from '@/components/layout/sidebar'
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children, title, description }: DashboardLayoutProps) {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
|
||||
{/* Main Content Area - responsive margin for sidebar */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Header */}
|
||||
{(title || description) && (
|
||||
<header className="sticky top-0 z-30 border-b border-border/50 bg-background/80 backdrop-blur-lg">
|
||||
<div className="px-8 py-6 pl-16 lg:pl-8">
|
||||
{title && <h1 className="text-2xl font-bold">{title}</h1>}
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Page Content - extra top padding on mobile for hamburger button */}
|
||||
<main className="p-8 pt-4 lg:pt-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
250
frontend/components/layout/sidebar.tsx
Normal file
250
frontend/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { settingsAPI } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { clearAuth } from '@/lib/auth'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Monitors',
|
||||
href: '/monitors',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Incidents',
|
||||
href: '/incidents',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Analytics',
|
||||
href: '/analytics',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
// Fetch user settings to show current plan
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await settingsAPI.get()
|
||||
return response.settings || {}
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Default to stored user plan from localStorage if API fails or is loading
|
||||
const getStoredPlan = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) return JSON.parse(userStr).plan;
|
||||
} catch { return 'free'; }
|
||||
}
|
||||
return 'free';
|
||||
}
|
||||
|
||||
// Capitalize plan name
|
||||
const planName = (settingsData?.plan || getStoredPlan() || 'free').charAt(0).toUpperCase() +
|
||||
(settingsData?.plan || getStoredPlan() || 'free').slice(1);
|
||||
|
||||
// Determine badge color
|
||||
const getBadgeVariant = (plan: string) => {
|
||||
switch (plan?.toLowerCase()) {
|
||||
case 'pro': return 'default'; // Primary color
|
||||
case 'business': return 'secondary';
|
||||
case 'enterprise': return 'destructive'; // Or another prominent color
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
// Use controlled state if provided, otherwise use internal state
|
||||
const sidebarOpen = isOpen !== undefined ? isOpen : mobileOpen
|
||||
const handleClose = onClose || (() => setMobileOpen(false))
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/dashboard') {
|
||||
return pathname === '/dashboard'
|
||||
}
|
||||
if (href === '/monitors') {
|
||||
return pathname === '/monitors' || pathname?.startsWith('/monitors/')
|
||||
}
|
||||
return pathname === href || pathname?.startsWith(href + '/')
|
||||
}
|
||||
|
||||
const handleNavClick = () => {
|
||||
// Close mobile sidebar after navigation
|
||||
if (window.innerWidth < 1024) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Hamburger Button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="fixed top-4 left-4 z-50 p-2 rounded-lg bg-card border border-border/50 shadow-md lg:hidden"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg className="h-6 w-6 text-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={mobileOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Mobile Overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden transition-opacity duration-300"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-40 h-screen w-64 border-r border-border/50 bg-card/95 backdrop-blur-sm",
|
||||
"transition-transform duration-300 ease-in-out",
|
||||
"lg:translate-x-0",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-border/50 px-6">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
|
||||
<svg
|
||||
className="h-5 w-5 text-primary-foreground"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-foreground">WebMonitor</h1>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Badge variant={getBadgeVariant(planName)} className="px-1.5 py-0 h-5 text-[10px] uppercase">
|
||||
{planName}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={handleNavClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
active
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className={cn(active && 'text-primary')}>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border/50 p-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user