Перейти к содержанию

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.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 />

Визуальная структура

┌─────────────────────────────────┐
│ [←]  Заголовок страницы   [⋯]  │
│ back      title         right   │
└─────────────────────────────────┘