feat(ui): Implement adaptive navigation system (Stage 1)
- Added useDisplay hook for breakpoint-based layout - Added useScrollDirection hook for hide-on-scroll logic - Implemented three-tier navigation: - Navigation Bar (Compact < 600px) with auto-hide - Navigation Rail (Medium 600-1240px) - Expanded Navigation Rail (Expanded > 1240px) - Added support for sub-menus in expanded rail - Added notification badges support - Integrated ThemeProvider into App Refs #3 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Navigation } from './Navigation';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { useDisplay } from '../hooks/useDisplay';
|
||||
|
||||
export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isRailExpanded } = useStore();
|
||||
const { navMode } = useDisplay();
|
||||
|
||||
const isExpanded = navMode === 'rail-expanded' && isRailExpanded;
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Navigation />
|
||||
<main className="main-content">
|
||||
<main className={`main-content ${isExpanded ? 'with-expanded-rail' : ''}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,84 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { User, Calendar, GraduationCap, MessageSquare, Settings } from 'lucide-react';
|
||||
import {
|
||||
Calendar,
|
||||
GraduationCap,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
User,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Menu
|
||||
} from 'lucide-react';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { useDisplay } from '../hooks/useDisplay';
|
||||
import { useScrollDirection } from '../hooks/useScrollDirection';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/profile', label: 'Profile', icon: User },
|
||||
{ path: '/schedule', label: 'Schedule', icon: Calendar },
|
||||
{ path: '/grades', label: 'Grades', icon: GraduationCap },
|
||||
{ path: '/messages', label: 'Messages', icon: MessageSquare },
|
||||
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
iconFilled: any;
|
||||
badgeKey?: 'messages';
|
||||
children?: { path: string; label: string }[];
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/profile', label: 'Профиль', icon: User, iconFilled: User },
|
||||
{
|
||||
path: '/schedule',
|
||||
label: 'Расписание',
|
||||
icon: Calendar,
|
||||
iconFilled: Calendar,
|
||||
children: [
|
||||
{ path: '/schedule/week', label: 'Текущая неделя' },
|
||||
{ path: '/schedule/session', label: 'Сессия' },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/grades',
|
||||
label: 'Успеваемость',
|
||||
icon: GraduationCap,
|
||||
iconFilled: GraduationCap,
|
||||
children: [
|
||||
{ path: '/grades/list', label: 'Оценки' },
|
||||
{ path: '/grades/statements', label: 'Ведомости' },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/docs',
|
||||
label: 'Документы',
|
||||
icon: FileText,
|
||||
iconFilled: FileText,
|
||||
children: [
|
||||
{ path: '/docs/certs', label: 'Справки' },
|
||||
{ path: '/docs/apps', label: 'Заявления' },
|
||||
]
|
||||
},
|
||||
{ path: '/messages', label: 'Сообщения', icon: MessageSquare, iconFilled: MessageSquare, badgeKey: 'messages' },
|
||||
];
|
||||
|
||||
export const Navigation: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<nav className="navigation-rail">
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<div className="nav-item-icon-wrapper">
|
||||
<item.icon size={24} />
|
||||
</div>
|
||||
<span className="nav-item-label">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
const { navMode } = useDisplay();
|
||||
const { isRailExpanded, toggleRail, badges } = useStore();
|
||||
const scrollDir = useScrollDirection();
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||
|
||||
<nav className="navigation-bar">
|
||||
{navItems.map((item) => (
|
||||
const toggleSubmenu = (label: string) => {
|
||||
setExpandedItems(prev =>
|
||||
prev.includes(label) ? prev.filter(i => i !== label) : [...prev, label]
|
||||
);
|
||||
};
|
||||
|
||||
const renderBadge = (count?: number) => {
|
||||
if (!count) return null;
|
||||
return <span className="m3-badge">{count > 99 ? '99+' : count}</span>;
|
||||
};
|
||||
|
||||
if (navMode === 'bar') {
|
||||
return (
|
||||
<nav className={`navigation-bar ${scrollDir === 'down' ? 'hidden' : ''}`}>
|
||||
{navItems.slice(0, 5).map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
@@ -39,11 +86,71 @@ export const Navigation: React.FC = () => {
|
||||
>
|
||||
<div className="nav-item-icon-wrapper">
|
||||
<item.icon size={24} />
|
||||
{item.badgeKey && renderBadge(badges[item.badgeKey])}
|
||||
</div>
|
||||
<span className="nav-item-label">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isExpanded = navMode === 'rail-expanded' && isRailExpanded;
|
||||
|
||||
return (
|
||||
<nav className={`navigation-rail ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div className="rail-header">
|
||||
<button className="icon-button" onClick={toggleRail}>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
{isExpanded && <span className="rail-title">Bonch</span>}
|
||||
</div>
|
||||
|
||||
<div className="rail-items">
|
||||
{navItems.map((item) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isItemExpanded = expandedItems.includes(item.label);
|
||||
|
||||
return (
|
||||
<div key={item.label} className="nav-group">
|
||||
<NavLink
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (hasChildren && isExpanded) {
|
||||
toggleSubmenu(item.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="nav-item-icon-wrapper">
|
||||
<item.icon size={24} />
|
||||
{item.badgeKey && renderBadge(badges[item.badgeKey])}
|
||||
</div>
|
||||
{isExpanded && <span className="nav-item-label">{item.label}</span>}
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="submenu-arrow">
|
||||
{isItemExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
{isExpanded && isItemExpanded && hasChildren && (
|
||||
<div className="submenu">
|
||||
{item.children!.map(child => (
|
||||
<NavLink
|
||||
key={child.path}
|
||||
to={child.path}
|
||||
className={({ isActive }) => `submenu-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
19
src/hooks/useDisplay.ts
Normal file
19
src/hooks/useDisplay.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
export function useDisplay() {
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setWidth(window.innerWidth);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const navMode = useMemo(() => {
|
||||
if (width < 600) return 'bar';
|
||||
if (width < 1240) return 'rail';
|
||||
return 'rail-expanded';
|
||||
}, [width]);
|
||||
|
||||
return { width, navMode };
|
||||
}
|
||||
23
src/hooks/useScrollDirection.ts
Normal file
23
src/hooks/useScrollDirection.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useScrollDirection() {
|
||||
const [scrollDir, setScrollDirection] = useState<'up' | 'down'>('up');
|
||||
|
||||
useEffect(() => {
|
||||
let lastScrollY = window.pageYOffset;
|
||||
|
||||
const updateScrollDirection = () => {
|
||||
const scrollY = window.pageYOffset;
|
||||
const direction = scrollY > lastScrollY ? 'down' : 'up';
|
||||
if (direction !== scrollDir && (scrollY - lastScrollY > 10 || scrollY - lastScrollY < -10)) {
|
||||
setScrollDirection(direction);
|
||||
}
|
||||
lastScrollY = scrollY > 0 ? scrollY : 0;
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', updateScrollDirection);
|
||||
return () => window.removeEventListener('scroll', updateScrollDirection);
|
||||
}, [scrollDir]);
|
||||
|
||||
return scrollDir;
|
||||
}
|
||||
@@ -7,11 +7,18 @@ interface AppState {
|
||||
isMockMode: boolean;
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
seedColor: string;
|
||||
isRailExpanded: boolean;
|
||||
badges: {
|
||||
messages: number;
|
||||
};
|
||||
setApiDomain: (domain: string) => void;
|
||||
setApiKey: (key: string) => void;
|
||||
setMockMode: (isMock: boolean) => void;
|
||||
setTheme: (theme: 'system' | 'light' | 'dark') => void;
|
||||
setSeedColor: (color: string) => void;
|
||||
toggleRail: () => void;
|
||||
setRailExpanded: (expanded: boolean) => void;
|
||||
setBadge: (key: keyof AppState['badges'], count: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -22,12 +29,21 @@ export const useStore = create<AppState>()(
|
||||
apiKey: '',
|
||||
isMockMode: import.meta.env.VITE_MOCK_MODE === 'true',
|
||||
theme: 'system',
|
||||
seedColor: '#0061a4', // Default M3 Blue
|
||||
seedColor: '#0061a4',
|
||||
isRailExpanded: true,
|
||||
badges: {
|
||||
messages: 3, // Mock value
|
||||
},
|
||||
setApiDomain: (apiDomain) => set({ apiDomain }),
|
||||
setApiKey: (apiKey) => set({ apiKey }),
|
||||
setMockMode: (isMockMode) => set({ isMockMode }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setSeedColor: (seedColor) => set({ seedColor }),
|
||||
toggleRail: () => set((state) => ({ isRailExpanded: !state.isRailExpanded })),
|
||||
setRailExpanded: (isRailExpanded) => set({ isRailExpanded }),
|
||||
setBadge: (key, count) => set((state) => ({
|
||||
badges: { ...state.badges, [key]: count }
|
||||
})),
|
||||
reset: () => set({ apiKey: '' }),
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
--md-sys-color-outline: #73777f;
|
||||
--md-sys-color-error: #ba1a1a;
|
||||
|
||||
--nav-width: 80px;
|
||||
--bottom-nav-height: 80px;
|
||||
--nav-rail-width: 80px;
|
||||
--nav-rail-expanded-width: 280px;
|
||||
--nav-bar-height: 80px;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
@@ -49,7 +50,41 @@ body {
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* Material 3 Components */
|
||||
/* M3 Components */
|
||||
|
||||
.m3-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background-color: var(--md-sys-color-error);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
|
||||
.m3-button {
|
||||
display: inline-flex;
|
||||
@@ -65,6 +100,7 @@ body {
|
||||
transition: box-shadow 0.2s, background-color 0.2s;
|
||||
background-color: transparent;
|
||||
color: var(--md-sys-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.m3-button-filled {
|
||||
@@ -97,12 +133,7 @@ body {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.m3-text-field:focus {
|
||||
outline: none;
|
||||
border-bottom: 2px solid var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
/* Layout and Navigation */
|
||||
/* Layout */
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
@@ -111,59 +142,107 @@ body {
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding-bottom: var(--bottom-nav-height);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.main-content {
|
||||
padding-bottom: 0;
|
||||
margin-left: var(--nav-width);
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-rail {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--nav-width);
|
||||
background-color: var(--md-sys-color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-right: 1px solid var(--md-sys-color-outline);
|
||||
z-index: 100;
|
||||
}
|
||||
/* Navigation Bar (Mobile) */
|
||||
|
||||
.navigation-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: var(--bottom-nav-height);
|
||||
height: var(--nav-bar-height);
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
z-index: 100;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.navigation-bar.hidden {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.navigation-bar { display: none; }
|
||||
.main-content { margin-left: var(--nav-rail-width); }
|
||||
}
|
||||
|
||||
@media (min-width: 1240px) {
|
||||
.main-content.with-expanded-rail { margin-left: var(--nav-rail-expanded-width); }
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.main-content { padding-bottom: calc(var(--nav-bar-height) + 16px); }
|
||||
}
|
||||
|
||||
/* Navigation Rail (Tablet/Desktop) */
|
||||
|
||||
.navigation-rail {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--nav-rail-width);
|
||||
background-color: var(--md-sys-color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 0;
|
||||
border-right: 1px solid var(--md-sys-color-outline);
|
||||
z-index: 100;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.navigation-rail.expanded {
|
||||
width: var(--nav-rail-expanded-width);
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rail-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rail-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Nav Items Shared */
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
height: 56px;
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.navigation-bar .nav-item {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-item-icon-wrapper {
|
||||
width: 64px;
|
||||
height: 32px;
|
||||
@@ -172,6 +251,7 @@ body {
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-item-icon-wrapper {
|
||||
@@ -182,16 +262,47 @@ body {
|
||||
.nav-item-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.navigation-bar {
|
||||
display: none;
|
||||
}
|
||||
.navigation-rail.expanded .nav-item {
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.navigation-rail {
|
||||
display: none;
|
||||
}
|
||||
.navigation-rail.expanded .nav-item-label {
|
||||
font-size: 14px;
|
||||
margin-left: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Submenu */
|
||||
|
||||
.submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 56px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 14px;
|
||||
border-radius: 24px;
|
||||
margin: 0 12px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.submenu-item.active {
|
||||
background-color: var(--md-sys-color-secondary-container);
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
.submenu-arrow {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user