fix: adaptive #3

This commit is contained in:
nikita
2026-04-05 08:23:50 +03:00
parent 9f6defd25c
commit 5b90447984
2 changed files with 348 additions and 267 deletions
+14 -2
View File
@@ -1,6 +1,9 @@
import { useState, useEffect, type ReactNode } from "react";
import { Sidebar } from "@/app/providers/layout/sidebar/sidebar";
import { Navigation } from "@/app/providers/layout/navigation/navigation";
import {
Navigation,
BottomNav,
} from "@/app/providers/layout/navigation/navigation";
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
export const Layout = ({ children }: { children: ReactNode }) => {
@@ -8,6 +11,9 @@ export const Layout = ({ children }: { children: ReactNode }) => {
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.innerWidth < 856 : false,
);
const [isVerySmall, setIsVerySmall] = useState(() =>
typeof window !== "undefined" ? window.innerWidth < 600 : false,
);
const { fetchAgents } = useAgentStore();
const sidebarOpen = isMobile ? mobileOpen : true;
@@ -19,6 +25,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
if (!mobile) {
setMobileOpen(false);
}
setIsVerySmall(window.innerWidth < 600);
};
window.addEventListener("resize", handleResize);
@@ -55,8 +62,13 @@ export const Layout = ({ children }: { children: ReactNode }) => {
isMobile={isMobile}
/>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Navigation onToggleSidebar={toggleSidebar} isMobile={isMobile} />
<Navigation
onToggleSidebar={toggleSidebar}
isMobile={isMobile}
isVerySmall={isVerySmall}
/>
<div className="flex-1 overflow-auto p-4">{children}</div>
{isVerySmall && <BottomNav />}
</div>
</div>
);
@@ -24,11 +24,13 @@ import {
interface NavigationProps {
onToggleSidebar?: () => void;
isMobile?: boolean;
isVerySmall?: boolean;
}
export const Navigation: React.FC<NavigationProps> = ({
onToggleSidebar,
isMobile,
isVerySmall = false,
}) => {
const navigate = useNavigate();
const location = useLocation();
@@ -48,7 +50,6 @@ export const Navigation: React.FC<NavigationProps> = ({
const isActive = (path: string) => location.pathname === path;
// Закрытие дропдауна при клике вне
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
@@ -74,281 +75,349 @@ export const Navigation: React.FC<NavigationProps> = ({
setThemePickerOpen(false);
};
const renderNavItems = (showLabels: boolean, iconSize: number) => (
<div className="flex items-center gap-1 whitespace-nowrap">
{navItems
.filter((item) => {
if ((item as any).adminOnly && !user?.permission_admin) return false;
return true;
})
.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg font-medium transition-all flex-shrink-0"
style={{
backgroundColor: active ? "var(--accent)" : "transparent",
color: active ? "var(--accent-text)" : "var(--text-secondary)",
}}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
e.currentTarget.style.color = "var(--text-primary)";
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "var(--text-secondary)";
}
}}
title={item.label}
>
<Icon size={iconSize} />
{showLabels && <span className="text-xs">{item.label}</span>}
</button>
);
})}
</div>
);
return (
<>
{/* Верхний бар */}
<div
className="flex-shrink-0 border-b"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center justify-between px-4 py-2.5">
{/* Бургер — только на мобильных */}
{isMobile && (
<button
onClick={onToggleSidebar}
className="p-1.5 mr-2 rounded-lg transition-colors flex-shrink-0"
style={{
backgroundColor: "transparent",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
}}
aria-label="Открыть sidebar"
>
<FaBars size={14} />
</button>
)}
{/* Название по центру — только на очень маленьких экранах */}
{isVerySmall && (
<div className="flex-1 text-center mx-4">
<span
className="text-sm font-bold"
style={{ color: "var(--text-primary)" }}
>
HellreigN
</span>
</div>
)}
{/* Навигация — только если НЕ очень маленький экран */}
{!isVerySmall && (
<div className="flex items-center flex-1 mx-4 overflow-x-auto scrollbar-hide">
{renderNavItems(true, 12)}
</div>
)}
{/* Профиль пользователя — дропдаун */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all"
style={{
backgroundColor: dropdownOpen
? "var(--bg-secondary)"
: "transparent",
border: "1px solid var(--border)",
}}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--accent)" }}
>
<FaUser size={11} style={{ color: "var(--accent-text)" }} />
</div>
<span
className="text-xs font-medium"
style={{ color: "var(--text-primary)" }}
>
{user?.name || user?.login || "Пользователь"}
</span>
<FaChevronDown
size={10}
style={{
color: "var(--text-secondary)",
transform: dropdownOpen ? "rotate(180deg)" : "rotate(0)",
transition: "transform 0.2s",
}}
/>
</button>
{dropdownOpen && (
<div
className="absolute right-0 top-full mt-2 rounded-lg shadow-xl border z-50"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
minWidth: "220px",
}}
>
<div
className="px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<div className="flex items-center gap-2 mb-1">
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--accent)" }}
>
<FaUser
size={12}
style={{ color: "var(--accent-text)" }}
/>
</div>
<div>
<p
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
{user?.name || user?.login}
</p>
<p
className="text-[10px]"
style={{ color: "var(--text-muted)" }}
>
{user?.login}
</p>
</div>
</div>
</div>
<div className="relative">
<button
onClick={() => setThemePickerOpen(!themePickerOpen)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
style={{ color: "var(--text-primary)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaPalette
size={12}
style={{ color: "var(--text-secondary)" }}
/>
<span className="flex-1 text-left">
Тема: {themes.find((t) => t.id === currentTheme)?.name}
</span>
<FaChevronDown
size={9}
style={{
color: "var(--text-muted)",
transform: themePickerOpen
? "rotate(180deg)"
: "rotate(0)",
transition: "transform 0.2s",
}}
/>
</button>
{themePickerOpen && (
<div
className="absolute right-full top-0 mr-1 rounded-lg shadow-xl border z-50"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
minWidth: "180px",
}}
>
{themes.map((t) => (
<button
key={t.id}
onClick={() => handleThemeChange(t.id)}
className="w-full flex items-center gap-3 px-4 py-2 text-xs transition-colors first:rounded-t-lg last:rounded-b-lg"
style={{
color:
currentTheme === t.id
? "var(--accent)"
: "var(--text-primary)",
backgroundColor:
currentTheme === t.id
? "var(--bg-secondary)"
: "transparent",
}}
onMouseEnter={(e) => {
if (currentTheme !== t.id) {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}
}}
onMouseLeave={(e) => {
if (currentTheme !== t.id) {
e.currentTarget.style.backgroundColor =
"transparent";
}
}}
>
<div
className="w-4 h-4 rounded-full border"
style={{
backgroundColor: t.colors.primary,
borderColor: "var(--border)",
}}
/>
<span>{t.name}</span>
</button>
))}
</div>
)}
</div>
{user?.permission_admin && (
<button
onClick={() => {
setDropdownOpen(false);
navigate("/admin");
}}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
style={{ color: "var(--text-primary)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaShieldAlt size={12} style={{ color: "#f59e0b" }} />
<span>Админка</span>
</button>
)}
<div
className="my-1 border-b"
style={{ borderColor: "var(--border)" }}
/>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors rounded-b-lg"
style={{ color: "var(--error-text)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"rgba(239, 68, 68, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaSignOutAlt size={12} />
<span>Выйти</span>
</button>
</div>
)}
</div>
</div>
</div>
</>
);
};
export const BottomNav: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuthStore();
const navItems = [
{ path: "/templates", label: "Шаблоны", icon: FaCode },
{ path: "/add-agents", label: "Деплой", icon: FaRocket },
{ path: "/registration", label: "Регистрация", icon: FaKey },
{ path: "/logs", label: "Логи", icon: FaFileAlt },
];
const isActive = (path: string) => location.pathname === path;
return (
<div
className="flex-shrink-0 border-b"
className="flex-shrink-0 border-t"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center justify-between px-4 py-2.5">
{/* Бургер — только на мобильных */}
{isMobile && (
<button
onClick={onToggleSidebar}
className="p-1.5 mr-2 rounded-lg transition-colors flex-shrink-0"
style={{
backgroundColor: "transparent",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
}}
aria-label="Открыть sidebar"
>
<FaBars size={14} />
</button>
)}
{/* Навигация */}
<div className="flex items-center flex-1 mx-4 overflow-x-auto scrollbar-hide">
<div className="flex items-center gap-1 whitespace-nowrap">
{navItems
.filter((item) => {
if ((item as any).adminOnly && !user?.permission_admin)
return false;
return true;
})
.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all flex-shrink-0"
style={{
backgroundColor: active ? "var(--accent)" : "transparent",
color: active
? "var(--accent-text)"
: "var(--text-secondary)",
}}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
e.currentTarget.style.color = "var(--text-primary)";
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "var(--text-secondary)";
}
}}
>
<Icon size={12} />
<span>{item.label}</span>
</button>
);
})}
</div>
</div>
{/* Профиль пользователя — дропдаун */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all"
style={{
backgroundColor: dropdownOpen
? "var(--bg-secondary)"
: "transparent",
border: "1px solid var(--border)",
}}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--accent)" }}
>
<FaUser size={11} style={{ color: "var(--accent-text)" }} />
</div>
<span
className="text-xs font-medium"
style={{ color: "var(--text-primary)" }}
>
{user?.name || user?.login || "Пользователь"}
</span>
<FaChevronDown
size={10}
style={{
color: "var(--text-secondary)",
transform: dropdownOpen ? "rotate(180deg)" : "rotate(0)",
transition: "transform 0.2s",
}}
/>
</button>
{/* Dropdown menu */}
{dropdownOpen && (
<div
className="absolute right-0 top-full mt-2 rounded-lg shadow-xl border z-50"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
minWidth: "220px",
}}
>
{/* User info */}
<div
className="px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<div className="flex items-center gap-2 mb-1">
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--accent)" }}
>
<FaUser size={12} style={{ color: "var(--accent-text)" }} />
</div>
<div>
<p
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
{user?.name || user?.login}
</p>
<p
className="text-[10px]"
style={{ color: "var(--text-muted)" }}
>
{user?.login}
</p>
</div>
</div>
</div>
{/* Theme selector */}
<div className="relative">
<button
onClick={() => setThemePickerOpen(!themePickerOpen)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
style={{ color: "var(--text-primary)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaPalette
size={12}
style={{ color: "var(--text-secondary)" }}
/>
<span className="flex-1 text-left">
Тема: {themes.find((t) => t.id === currentTheme)?.name}
</span>
<FaChevronDown
size={9}
style={{
color: "var(--text-muted)",
transform: themePickerOpen
? "rotate(180deg)"
: "rotate(0)",
transition: "transform 0.2s",
}}
/>
</button>
{/* Theme sub-dropdown */}
{themePickerOpen && (
<div
className="absolute right-full top-0 mr-1 rounded-lg shadow-xl border z-50"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
minWidth: "180px",
}}
>
{themes.map((t) => (
<button
key={t.id}
onClick={() => handleThemeChange(t.id)}
className="w-full flex items-center gap-3 px-4 py-2 text-xs transition-colors first:rounded-t-lg last:rounded-b-lg"
style={{
color:
currentTheme === t.id
? "var(--accent)"
: "var(--text-primary)",
backgroundColor:
currentTheme === t.id
? "var(--bg-secondary)"
: "transparent",
}}
onMouseEnter={(e) => {
if (currentTheme !== t.id) {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}
}}
onMouseLeave={(e) => {
if (currentTheme !== t.id) {
e.currentTarget.style.backgroundColor =
"transparent";
}
}}
>
<div
className="w-4 h-4 rounded-full border"
style={{
backgroundColor: t.colors.primary,
borderColor: "var(--border)",
}}
/>
<span>{t.name}</span>
</button>
))}
</div>
)}
</div>
{/* Admin (if admin) */}
{user?.permission_admin && (
<button
onClick={() => {
setDropdownOpen(false);
navigate("/admin");
}}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
style={{ color: "var(--text-primary)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaShieldAlt size={12} style={{ color: "#f59e0b" }} />
<span>Админка</span>
</button>
)}
{/* Divider */}
<div
className="my-1 border-b"
style={{ borderColor: "var(--border)" }}
/>
{/* Logout */}
<div className="flex items-center justify-around px-2 py-2">
{navItems
.filter((item) => {
if ((item as any).adminOnly && !user?.permission_admin)
return false;
return true;
})
.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors rounded-b-lg"
style={{ color: "var(--error-text)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"rgba(239, 68, 68, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
key={item.path}
onClick={() => navigate(item.path)}
className="flex items-center justify-center p-3 rounded-lg transition-all"
style={{
backgroundColor: active ? "var(--accent)" : "transparent",
color: active
? "var(--accent-text)"
: "var(--text-secondary)",
}}
title={item.label}
>
<FaSignOutAlt size={12} />
<span>Выйти</span>
<Icon size={20} />
</button>
</div>
)}
</div>
);
})}
</div>
</div>
);