mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add ui for major components
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
Languages,
|
||||
LogOut,
|
||||
Moon,
|
||||
Sun,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Sidebar, { SidebarItem } from "./Sidebar";
|
||||
|
||||
export interface DashboardLayoutProps {
|
||||
@@ -7,32 +16,195 @@ export interface DashboardLayoutProps {
|
||||
activeHref?: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
headerRight?: ReactNode;
|
||||
enableThemeToggle?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const THEME_STORAGE_KEY = "edr-theme";
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === "dark" || stored === "light") return stored;
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
const iconButtonClass =
|
||||
"inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] dark:border-slate-700 dark:text-slate-300 dark:hover:border-[#33578D]/40 dark:hover:bg-[#33578D]/20 dark:hover:text-white";
|
||||
|
||||
const DashboardLayout = ({
|
||||
title,
|
||||
sidebarItems,
|
||||
activeHref,
|
||||
onNavigate,
|
||||
headerRight,
|
||||
enableThemeToggle = false,
|
||||
children,
|
||||
}: DashboardLayoutProps) => (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar
|
||||
title={title}
|
||||
items={sidebarItems}
|
||||
activeHref={activeHref}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
|
||||
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||
<div>{headerRight}</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
}: DashboardLayoutProps) => {
|
||||
const [theme, setTheme] = useState<Theme>(() =>
|
||||
enableThemeToggle ? getInitialTheme() : "light",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableThemeToggle) return;
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}, [theme, enableThemeToggle]);
|
||||
|
||||
const toggleTheme = () =>
|
||||
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (
|
||||
userMenuRef.current &&
|
||||
!userMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setIsUserMenuOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
const themeToggleButton = enableThemeToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={
|
||||
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
||||
}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
<Sidebar
|
||||
title={title}
|
||||
items={sidebarItems}
|
||||
activeHref={activeHref}
|
||||
onNavigate={onNavigate}
|
||||
headerExtra={themeToggleButton}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-base font-medium text-slate-700 dark:text-slate-200">
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Change language"
|
||||
className={iconButtonClass}
|
||||
>
|
||||
<Languages className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className={`${iconButtonClass} relative`}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white dark:ring-slate-900" />
|
||||
</button>
|
||||
|
||||
<div ref={userMenuRef} className="relative ml-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
onClick={() => setIsUserMenuOpen((open) => !open)}
|
||||
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#33578D]/20 hover:bg-[#33578D]/5 aria-expanded:border-[#33578D]/30 aria-expanded:bg-[#33578D]/10 dark:hover:border-[#33578D]/30 dark:hover:bg-[#33578D]/10 dark:aria-expanded:border-[#33578D]/40 dark:aria-expanded:bg-[#33578D]/20"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#33578D] text-xs font-semibold text-white">
|
||||
JD
|
||||
</div>
|
||||
<span className="hidden text-sm font-medium text-slate-700 md:block dark:text-slate-200">
|
||||
John Doe
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-slate-400 transition dark:text-slate-500 ${
|
||||
isUserMenuOpen ? "rotate-180 text-[#33578D]" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isUserMenuOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
<div className="border-b border-slate-100 px-4 py-3 dark:border-slate-700">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
John Doe
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
john.doe@edr.com
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="#profile"
|
||||
role="menuitem"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 transition hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</a>
|
||||
<a
|
||||
href="#logout"
|
||||
role="menuitem"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{headerRight}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-950">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardLayout;
|
||||
|
||||
@@ -12,39 +12,70 @@ export interface SidebarProps {
|
||||
items: SidebarItem[];
|
||||
activeHref?: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
headerExtra?: ReactNode;
|
||||
}
|
||||
|
||||
const Sidebar = ({ title, items, activeHref, onNavigate }: SidebarProps) => (
|
||||
<aside className="flex w-60 flex-col gap-1 border-r border-gray-200 bg-white px-3 py-4">
|
||||
const Sidebar = ({
|
||||
title,
|
||||
items,
|
||||
activeHref,
|
||||
onNavigate,
|
||||
headerExtra,
|
||||
}: SidebarProps) => (
|
||||
<aside className="flex w-64 flex-col gap-1 border-r border-slate-200 bg-[#33578D]/10 px-3 py-5 dark:border-slate-800 dark:bg-slate-900">
|
||||
{title ? (
|
||||
<div className="px-2 pb-3 text-sm font-semibold text-gray-700">
|
||||
{title}
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#33578D] text-sm font-bold text-white">
|
||||
{title.charAt(0)}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
{headerExtra}
|
||||
</div>
|
||||
) : null}
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(event) => {
|
||||
if (onNavigate) {
|
||||
event.preventDefault();
|
||||
onNavigate(item.href);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors",
|
||||
activeHref === item.href
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
{item.icon ? (
|
||||
<span className="text-gray-500">{item.icon}</span>
|
||||
) : null}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
activeHref?.toLowerCase() === item.href.toLowerCase();
|
||||
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(event) => {
|
||||
if (onNavigate) {
|
||||
event.preventDefault();
|
||||
onNavigate(item.href);
|
||||
}
|
||||
}}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={clsx(
|
||||
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition",
|
||||
isActive
|
||||
? "bg-[#33578D] text-white shadow-sm shadow-[#33578D]/20"
|
||||
: "text-slate-700 hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.icon ? (
|
||||
<span
|
||||
className={clsx(
|
||||
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
||||
isActive
|
||||
? "text-white"
|
||||
: "text-slate-500 group-hover:text-[#33578D] dark:text-slate-400 dark:group-hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user