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

Список гардероба — полный код

Гардероб

WardrobePage

Файл: frontend/src/pages/WardrobePage.tsx

Страница отображает все вещи пользователя в виде сетки карточек. Поддерживает мультифильтрацию: по слотам (быстрые чипы), категориям, цветам, сезонам и стилям (расширенная панель BottomSheet).

import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Col, Row } from 'antd';
import ItemCard from '../components/common/ItemCard';
import { useLocalColors, useLocalCategories, useLocalStyles } from '../offline/hooks/useLocalReference';
import { useLocalItems } from '../offline/hooks/useLocalItems';
import { useDebounce } from '../hooks/useDebounce';
import { useVirtualPagination } from '../hooks/useVirtualPagination';
import type { Item, Season } from '../types/api';
import FilterChip from '../components/ui/FilterChip';
import BottomSheet from '../components/ui/BottomSheet';
import EmptyState from '../components/ui/EmptyState';
import PrimaryButton from '../components/ui/PrimaryButton';
import SkeletonCard from '../components/ui/SkeletonCard';
import s from './WardrobePage.module.css';

// Быстрые фильтры по слотам (верх/низ/обувь)
const SLOT_CHIPS = [
  { value: undefined as number | undefined, label: 'Все', dotTone: 'accent' as const },
  { value: 1, label: 'Верх', dotTone: 'sage' as const },
  { value: 2, label: 'Низ', dotTone: 'warning' as const },
  { value: 3, label: 'Обувь', dotTone: 'accent' as const },
];

export default function WardrobePage() {
  const navigate = useNavigate();
  const [searchParams, setSearchParams] = useSearchParams();

  // Состояние фильтров
  const [slotId, setSlotId] = useState<number | undefined>();
  const [categoryId, setCategoryId] = useState<number | undefined>();
  const [colorId, setColorId] = useState<number | undefined>();
  const [season, setSeason] = useState<Season | undefined>();
  const [styleNodeId, setStyleNodeId] = useState<number | undefined>();

  // BottomSheet открыт?
  const filterOpen = searchParams.get('filters') === 'open';
  const advancedCount = [categoryId, colorId, season, styleNodeId].filter(Boolean).length;

  // Debounce фильтров для предотвращения частых перезапросов
  const debouncedFilters = useDebounce(
    {
      slot_id: categoryId ? undefined : slotId,
      category_id: categoryId,
      color_id: colorId,
      season,
      style_node_id: styleNodeId,
    },
    400,
  );

  // Справочные данные
  const { data: colors } = useLocalColors();
  const { data: styles } = useLocalStyles();
  const { data: categories } = useLocalCategories();

  // Загрузка вещей (offline-first)
  const { data, isLoading, isFetching } = useLocalItems(debouncedFilters);

  const items = data?.items ?? [];

  // Виртуальная пагинация (20 элементов на batch, IntersectionObserver)
  const paginationResetKey = useMemo(() => JSON.stringify(debouncedFilters), [debouncedFilters]);
  const { visibleItems, hasMore, sentinelRef } = useVirtualPagination(items, {
    batchSize: 20,
    resetKey: paginationResetKey,
  });

  const totalItems = data?.total ?? items.length;

  return (
    <>
      <div className={`${s.pageContent} ${s.wardrobePage}`}>
        {/* Заголовок с количеством */}
        <div className={s.header}>
          <h1 className={s.title}>Гардероб</h1>
          <div className={s.meta}>{totalItems} {pluralizeItems(totalItems)}</div>
        </div>

        {/* Быстрые фильтры-чипы: Все / Верх / Низ / Обувь / Фильтры */}
        <div className={s.filters}>
          {SLOT_CHIPS.map((chip) => (
            <FilterChip
              key={chip.label}
              softActive={chip.value !== undefined && slotId === chip.value && advancedCount === 0}
              dotTone={chip.dotTone}
              onClick={() => { setSlotId(chip.value); handleResetAdvanced(); }}
            >
              {chip.label}
            </FilterChip>
          ))}
          <FilterChip softActive={filterOpen || advancedCount > 0} dotTone="accent"
            onClick={() => { setSlotId(undefined); openFiltersSheet(); }}>
            Фильтры
          </FilterChip>
        </div>

        {/* Сетка вещей (Row + Col, responsive) */}
        {isLoading ? (
          <Row gutter={[12, 12]}>
            {Array.from({ length: 8 }).map((_, i) => (
              <Col xs={12} sm={8} md={6} key={i}><SkeletonCard imageHeight={150} /></Col>
            ))}
          </Row>
        ) : items.length === 0 ? (
          <EmptyState
            title={hasFilters ? 'Ничего не найдено' : 'Гардероб пока пуст'}
            description={hasFilters
              ? 'Сбросьте часть фильтров.'
              : 'Добавьте первую вещь.'}
            action={!hasFilters ? (
              <PrimaryButton block onClick={() => navigate('/add')}>Добавить вещь</PrimaryButton>
            ) : undefined}
          />
        ) : (
          <Row gutter={[12, 12]}>
            {visibleItems.map((item: Item) => (
              <Col xs={12} sm={8} md={6} key={item.id}>
                <ItemCard item={item} onClick={(entry) => navigate(`/wardrobe/${entry.id}`)}
                  meta={buildItemMeta(item, categoryMap, colorMap)} />
              </Col>
            ))}
          </Row>
        )}

        {/* Sentinel для виртуальной пагинации */}
        <div ref={sentinelRef} aria-hidden="true" />
      </div>

      {/* BottomSheet: расширенные фильтры */}
      <BottomSheet open={filterOpen} onClose={closeFiltersSheet} height="85dvh"
        footer={<PrimaryButton block onClick={closeFiltersSheet} loading={isFetching}>
          Показать {totalItems} {pluralizeItems(totalItems)}
        </PrimaryButton>}>
        <FilterSection title="Категория">
          {/* Чипы категорий из справочника */}
        </FilterSection>
        <FilterSection title="Цвет">
          {/* Чипы цветов из справочника */}
        </FilterSection>
        <FilterSection title="Сезон">
          {/* Чипы: Весна, Лето, Осень, Зима */}
        </FilterSection>
        <FilterSection title="Стиль">
          {/* Чипы стилей из справочника */}
        </FilterSection>
      </BottomSheet>
    </>
  );
}

Механизм фильтрации

stateDiagram-v2
    [*] --> NoFilter: Открытие страницы
    NoFilter --> SlotFilter: Клик "Верх"/"Низ"/"Обувь"
    NoFilter --> AdvancedFilter: Открыть BottomSheet
    SlotFilter --> NoFilter: Клик "Все"
    SlotFilter --> AdvancedFilter: Открыть BottomSheet
    AdvancedFilter --> AdvancedFilter: Выбор категории/цвета/сезона/стиля
    AdvancedFilter --> NoFilter: "Сбросить"

    state AdvancedFilter {
        [*] --> Debounce400ms
        Debounce400ms --> useLocalItems
        useLocalItems --> RenderGrid
    }

Диаграмма

Виртуальная пагинация

Хук useVirtualPagination реализует прогрессивную загрузку: - batchSize: 20 — начальное количество видимых элементов - sentinelRef — IntersectionObserver, подгружает следующий batch при скролле - resetKey — сбрасывает пагинацию при смене фильтров

Адаптивная сетка

Breakpoint Колонок в ряду Col span
xs (< 576px) 2 12
sm (>= 576px) 3 8
md (>= 768px) 4 6

Pluralize (склонение)

function pluralizeItems(count: number) {
  const mod10 = count % 10;
  const mod100 = count % 100;
  if (mod10 === 1 && mod100 !== 11) return 'вещь';
  if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'вещи';
  return 'вещей';
}