Список гардероба — полный код¶
↑ Гардероб
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 |