Layout-компоненты — полный справочник¶
Уровень: L3 (deep-dive) | Вверх: README.md | Раздел: ../README.md
Все компоненты расположены в frontend/src/components/layout/. Формируют визуальный каркас приложения.
AppLayout¶
Файл: AppLayout.tsx (16 строк)
Корневой layout для всех защищённых маршрутов. Оборачивает страницы в единую структуру: offline-баннер сверху, контент в центре, навигация снизу.
Пропсы¶
Нет внешних пропсов. Дочерние страницы рендерятся через <Outlet /> (React Router).
Полный код¶
import { Outlet } from 'react-router-dom';
import BottomNav from './BottomNav';
import OfflineBanner from '../ui/OfflineBanner';
import s from './AppLayout.module.css';
export default function AppLayout() {
return (
<div className={s.layout}>
<OfflineBanner />
<main className={s.content} role="main">
<Outlet />
</main>
<BottomNav />
</div>
);
}
CSS (AppLayout.module.css)¶
/* Контейнер ограничен max-width для планшетов, центрирован */
.layout {
max-width: var(--pl-content-max-width); /* 768px */
margin: 0 auto;
min-height: 100vh;
min-height: 100svh;
background: var(--pl-color-bg);
position: relative;
overflow-x: clip; /* Предотвращает горизонтальный скролл */
}
/* Нижний padding учитывает высоту навигации + safe-area */
.content {
padding-bottom: var(--pl-screen-bottom-offset);
/* = calc(74px + env(safe-area-inset-bottom)) */
min-height: 100vh;
min-height: 100svh;
}
Визуальная структура¶
┌─────────────────────────┐
│ OfflineBanner (условно) │ ← Появляется только при offline
├─────────────────────────┤
│ │
│ <Outlet /> │ ← Текущая страница
│ (HomePage, Feed...) │
│ │
│ │
├─────────────────────────┤
│ BottomNav │ ← Фиксированная навигация
└─────────────────────────┘
BottomNav¶
Файл: BottomNav.tsx (91 строка)
Нижняя навигация в формате floating pill. 5 вкладок с иконками, центральная кнопка «Добавить» выделена акцентным цветом.
Пропсы¶
Нет внешних пропсов. Использует useLocation и useNavigate из React Router.
Маршруты навигации¶
| Ключ | Путь | Иконка | Label |
|---|---|---|---|
| home | / |
House | Главная |
| feed | /feed |
LayoutGrid | Лента |
| add | /add |
Plus | Добавить |
| wardrobe | /wardrobe |
Shirt | Гардероб |
| outfits | /outfits |
Sparkles | Образы |
Полный код¶
import { useLocation, useNavigate } from 'react-router-dom';
import { House, LayoutGrid, Plus, Shirt, Sparkles } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import s from './BottomNav.module.css';
interface NavItem {
key: string;
path: string;
label: string;
icon: LucideIcon;
isCenter?: boolean; // Центральная кнопка с акцентным фоном
}
const navItems: NavItem[] = [
{ key: 'home', path: '/', label: 'Главная', icon: House },
{ key: 'feed', path: '/feed', label: 'Лента', icon: LayoutGrid },
{ key: 'add', path: '/add', label: 'Добавить', icon: Plus, isCenter: true },
{ key: 'wardrobe', path: '/wardrobe', label: 'Гардероб', icon: Shirt },
{ key: 'outfits', path: '/outfits', label: 'Образы', icon: Sparkles },
];
// Определение активной вкладки по текущему pathname
function getActiveKey(pathname: string): string {
if (pathname === '/') return 'home';
if (pathname.startsWith('/feed')) return 'feed';
if (pathname.startsWith('/add')) return 'add';
if (pathname.startsWith('/wardrobe')) return 'wardrobe';
if (pathname.startsWith('/outfits')) return 'outfits';
return '';
}
export default function BottomNav() {
const { pathname } = useLocation();
const navigate = useNavigate();
const activeKey = getActiveKey(pathname);
return (
<nav className={s.nav} aria-label="Основная навигация">
<div className={s.pill}>
{navItems.map((item) => {
const isActive = activeKey === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
className={[
s.item,
item.isCenter ? s.itemCenter : '',
isActive ? s.itemActive : '',
].join(' ').trim()}
onClick={() => navigate(item.path)}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
type="button"
>
<span className={s.icon} aria-hidden="true">
<Icon size={item.isCenter ? 20 : 18} strokeWidth={2.1} />
</span>
</button>
);
})}
</div>
</nav>
);
}
CSS (BottomNav.module.css)¶
/* Фиксированная позиция внизу экрана */
.nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
margin: 0 auto;
width: 100%;
max-width: var(--pl-content-max-width); /* 768px */
display: flex;
justify-content: center;
align-items: center;
padding: 0 var(--pl-page-padding);
padding-bottom: max(8px, env(safe-area-inset-bottom, 0px));
background: transparent; /* Навигация "плавает" */
pointer-events: none; /* Клики проходят сквозь padding */
z-index: var(--pl-z-nav); /* 100 */
}
/* Pill-контейнер с полупрозрачным фоном и blur */
.pill {
width: 100%;
max-width: 420px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: var(--pl-radius-pill); /* 999px */
border: 1px solid var(--pl-color-border);
background: rgba(255, 253, 252, 0.9);
backdrop-filter: blur(12px);
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 8px 20px rgba(0,0,0,0.06);
pointer-events: auto; /* Клики работают на pill */
}
/* Кнопка навигации */
.item {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
background: none;
cursor: pointer;
padding: 0;
color: var(--pl-color-text-muted);
border-radius: 14px;
transition: color 0.2s;
-webkit-tap-highlight-color: transparent;
}
/* Активная вкладка — акцентный цвет */
.itemActive {
color: var(--pl-color-accent);
}
/* Центральная кнопка «Добавить» — pill-shaped, залитая акцентом */
.itemCenter {
background: var(--pl-color-accent);
color: var(--pl-color-text-inverse);
border-radius: 999px;
}
.itemCenter.itemActive {
background: var(--pl-color-accent);
color: var(--pl-color-text-inverse);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
}
Визуальная структура¶
┌──────────────────────────────────┐
│ 🏠 📱 [+] 👕 ✨ │
│ home feed add wardrobe outfits│
└──────────────────────────────────┘
↑ floating pill, blur
PageHeader¶
Файл: PageHeader.tsx (46 строк)
Заголовок страницы с опциональной кнопкой «Назад» и правым контентом (действия).
Пропсы¶
interface PageHeaderProps {
title?: string; // Текст заголовка
showBack?: boolean; // Показать кнопку «Назад»
onBack?: () => void; // Кастомный обработчик (по умолчанию navigate(-1))
rightContent?: ReactNode; // Контент справа (кнопки действий)
}
Полный код¶
import { useNavigate } from 'react-router-dom';
import { ArrowLeftOutlined } from '@ant-design/icons';
import type { ReactNode } from 'react';
import s from './PageHeader.module.css';
interface PageHeaderProps {
title?: string;
showBack?: boolean;
onBack?: () => void;
rightContent?: ReactNode;
}
export default function PageHeader({ title, showBack, onBack, rightContent }: PageHeaderProps) {
const navigate = useNavigate();
const handleBack = () => {
if (onBack) {
onBack();
} else {
navigate(-1); // Стандартная навигация назад
}
};
return (
<header className={`page-header ${s.header} ${showBack ? s.headerBack : ''}`}>
{showBack && (
<button type="button" onClick={handleBack} aria-label="Назад" className={s.back}>
<ArrowLeftOutlined className={s.backIcon} />
</button>
)}
{title && <h1 className={s.title}>{title}</h1>}
{rightContent && (
<div className={s.right} style={{ marginLeft: title ? 8 : 'auto' }}>
{rightContent}
</div>
)}
</header>
);
}
CSS (PageHeader.module.css)¶
/* Заголовок с отступом от верха (учитывает notch) */
.header {
display: flex;
align-items: center;
min-height: 56px;
padding: var(--pl-screen-top-offset) var(--pl-page-padding) 0;
/* = env(safe-area-inset-top) 16px 0 */
background: var(--pl-color-bg);
}
.headerBack {
gap: var(--pl-space-3); /* 12px между кнопкой и заголовком */
}
/* Кнопка «Назад» — круглая, с границей и мягкой тенью */
.back {
padding: 0;
width: var(--pl-control-height-header-btn); /* 40px */
height: var(--pl-control-height-header-btn);
flex-shrink: 0;
border: 1px solid var(--pl-color-border);
border-radius: var(--pl-radius-pill); /* 999px */
background: var(--pl-color-surface-overlay); /* rgba(255,253,252,0.85) */
color: var(--pl-color-text);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--pl-shadow-soft);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.backIcon { font-size: 16px; }
/* Заголовок — display шрифт, обрезается если не влезает */
.title {
flex: 1;
text-align: left;
margin: 0;
font-family: var(--pl-font-display);
font-size: var(--pl-font-size-page-title); /* 24px */
font-weight: 700;
line-height: 1.15;
color: var(--pl-color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Правая часть — flex-shrink: 0, не сжимается */
.right {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
margin-left: 8px;
}
Примеры использования¶
// WardrobeItemPage — заголовок с кнопкой назад и действиями справа
<PageHeader
title="Вещь"
showBack
rightContent={
<>
<ActionIconButton icon={<Edit3 size={18} />} onClick={handleEdit} />
<ActionIconButton icon={<Trash2 size={18} />} onClick={handleDelete} />
</>
}
/>
// OutfitsPage — только заголовок
<PageHeader title="Мои образы" />
// SettingsPage — заголовок с кнопкой назад
<PageHeader title="Настройки" showBack />