add ui for major components

This commit is contained in:
yaschalew
2026-05-15 22:17:31 +03:00
parent 84f4f7edba
commit b5fe2c2e3c
46 changed files with 7702 additions and 257 deletions

View File

@@ -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;

View File

@@ -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>
);