Детальная карточка вещи — полный код¶
↑ Гардероб
WardrobeItemPage¶
Файл: frontend/src/pages/WardrobeItemPage.tsx
Страница отображает детальную информацию о вещи: фото, название, атрибуты (категория, цвет, стиль). Позволяет редактировать атрибуты, удалять вещь и переходить к созданию образа.
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Form, message } from 'antd';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Shirt } from 'lucide-react';
import PageHeader from '../components/layout/PageHeader';
import SimilarItemsRail from '../components/common/SimilarItemsRail';
import { useSimilarItems } from '../embeddings/useEmbeddingSearch';
import { useLocalItemsByIds } from '../offline/hooks/useLocalItemsByIds';
import { useLocalCategories, useLocalColors, useLocalStyles } from '../offline/hooks/useLocalReference';
import { useLocalItem } from '../offline/hooks/useLocalItems';
import { offlineItemsService } from '../offline/services/offlineItemsService';
import { getItemImageUrlLarge } from '../utils/media';
import type { ItemUpdate } from '../types/api';
import { showAppConfirm } from '../utils/showAppConfirm';
import FilterChip from '../components/ui/FilterChip';
import SecondaryButton from '../components/ui/SecondaryButton';
import PrimaryButton from '../components/ui/PrimaryButton';
import BottomSheet from '../components/ui/BottomSheet';
import TextField from '../components/ui/TextField';
import SelectField from '../components/ui/SelectField';
import EmptyState from '../components/ui/EmptyState';
import SkeletonCard from '../components/ui/SkeletonCard';
import s from './WardrobeItemPage.module.css';
interface WardrobeItemFormValues {
title?: string;
category_id?: number;
primary_color_id?: number;
style_node_id?: number;
}
export default function WardrobeItemPage() {
const { itemId } = useParams<{ itemId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [imgLoaded, setImgLoaded] = useState(false);
const [form] = Form.useForm<WardrobeItemFormValues>();
// Загрузка вещи (offline-first: сначала IndexedDB, потом API)
const { data: item, isLoading } = useLocalItem(itemId);
// Поиск похожих вещей через embedding-поиск (до 8 результатов)
const { results: similarResults, isLoading: similarLoading } = useSimilarItems(itemId, 8);
const { data: similarItems = [] } = useLocalItemsByIds(similarResults.map((r) => r.id));
// Справочники для отображения меток
const { data: categories } = useLocalCategories();
const { data: colors } = useLocalColors();
const { data: styles } = useLocalStyles();
// Мутация удаления вещи
const deleteMutation = useMutation({
mutationFn: () => offlineItemsService.delete(itemId!),
onSuccess: async () => {
message.success('Вещь удалена');
await queryClient.refetchQueries({ queryKey: ['items'] });
await queryClient.invalidateQueries({ queryKey: ['outfits'] });
await queryClient.invalidateQueries({ queryKey: ['shared-outfits'] });
navigate('/wardrobe', { replace: true });
},
onError: () => message.error('Не удалось удалить вещь'),
});
// Мутация обновления атрибутов
const updateMutation = useMutation({
mutationFn: (data: ItemUpdate) => offlineItemsService.update(itemId!, data),
onSuccess: async () => {
message.success('Изменения сохранены');
await queryClient.refetchQueries({ queryKey: ['items'] });
setEditOpen(false);
},
onError: () => message.error('Не удалось сохранить изменения'),
});
const imageUrl = getItemImageUrlLarge(item?.primary_media_id, item?.primary_media_url);
// Формирование чипов-меток (категория, цвет, стиль)
const itemMeta = useMemo(() => {
if (!item) return [];
const categoryName = categories?.find((entry) => entry.id === item.category_id)?.name;
const colorName = colors?.find((entry) => entry.id === item.primary_color_id)?.name;
const styleName = styles?.find((entry) => item.style_node_ids?.includes(entry.id))?.name;
return [
colorName ? { label: colorName, dotTone: 'warning' as const } : null,
categoryName ? { label: categoryName, dotTone: 'sage' as const } : null,
styleName ? { label: styleName, dotTone: 'accent' as const } : null,
].filter(Boolean) as Array<{ label: string; dotTone: 'accent' | 'sage' | 'warning' }>;
}, [categories, colors, item, styles]);
// Подтверждение удаления через модальное окно
const handleDelete = () => {
showAppConfirm({
title: 'Удалить вещь?',
content: 'Вещь исчезнет из гардероба. Образы, где она участвует, будут удалены вместе с ней.',
okText: 'Удалить',
okButtonProps: { danger: true },
cancelText: 'Отмена',
onOk: () => deleteMutation.mutateAsync(),
});
};
// Открытие BottomSheet с предзаполненной формой
const handleEditOpen = () => {
if (item) {
form.setFieldsValue({
title: item.title,
category_id: item.category_id,
primary_color_id: item.primary_color_id,
style_node_id: item.style_node_ids?.[0],
});
}
setEditOpen(true);
};
// Сохранение изменений из формы редактирования
const handleEditSave = async () => {
try {
const values = await form.validateFields();
updateMutation.mutate({
title: values.title,
category_id: values.category_id,
primary_color_id: values.primary_color_id,
style_node_ids: values.style_node_id ? [values.style_node_id] : [],
});
} catch { /* Ant Design validation */ }
};
if (isLoading) {
return (
<>
<PageHeader title="Вещь" showBack />
<div className={`${s.pageContent} ${s.wardrobeItemPage}`}>
<SkeletonCard imageHeight={260} mediaLayout="fixed-height" />
</div>
</>
);
}
if (!item) {
return (
<>
<PageHeader title="Вещь" showBack />
<div className={s.pageContent}>
<EmptyState
icon={<Shirt size={22} strokeWidth={2} />}
title="Вещь не найдена"
description="Вернитесь в гардероб и выберите другую карточку."
action={<SecondaryButton block onClick={() => navigate('/wardrobe', { replace: true })}>Вернуться</SecondaryButton>}
/>
</div>
</>
);
}
return (
<>
<PageHeader title="Вещь" showBack />
<div className={`${s.pageContent} ${s.wardrobeItemPage}`}>
{/* Фото вещи */}
<div className={s.media}>
<img src={imageUrl} alt={item.title || 'Вещь'} className={s.image}
style={{ display: imgLoaded ? 'block' : 'none' }}
onLoad={() => setImgLoaded(true)} />
</div>
{/* Название */}
<h2 className={s.itemTitle}>{item.title || 'Без названия'}</h2>
{/* Чипы атрибутов */}
{itemMeta.length > 0 && (
<div className={s.chips}>
{itemMeta.map((meta) => (
<FilterChip key={`${meta.label}-${meta.dotTone}`} dotTone={meta.dotTone}>
{meta.label}
</FilterChip>
))}
</div>
)}
{/* Действия */}
<div className={s.actions}>
<SecondaryButton block onClick={handleEditOpen}>Редактировать</SecondaryButton>
<PrimaryButton block
onClick={() => navigate(`/outfits/build?fromItem=${encodeURIComponent(item.id)}`)}>
Создать образ
</PrimaryButton>
<button type="button" className={s.deleteLink} onClick={handleDelete}>Удалить</button>
</div>
{/* Рекомендации похожих вещей (embedding-поиск) */}
<SimilarItemsRail
items={similarItems}
isLoading={similarLoading}
onItemClick={(id) => navigate(`/wardrobe/${id}`)}
/>
</div>
{/* BottomSheet: редактирование */}
<BottomSheet title="Редактировать вещь" open={editOpen} onClose={() => setEditOpen(false)}
footer={<>
<SecondaryButton onClick={() => setEditOpen(false)}>Позже</SecondaryButton>
<PrimaryButton onClick={handleEditSave} loading={updateMutation.isPending}>Сохранить</PrimaryButton>
</>}>
<Form form={form} layout="vertical" requiredMark={false}>
<TextField name="title" label="Название" placeholder="Например: Белая футболка" />
<SelectField name="category_id" label="Категория" rules={[{ required: true }]}
selectProps={{ options: categories?.map((e) => ({ label: e.name, value: e.id })) }} />
<SelectField name="primary_color_id" label="Цвет"
selectProps={{ allowClear: true, options: colors?.map((e) => ({ label: e.name, value: e.id })) }} />
<SelectField name="style_node_id" label="Стиль"
selectProps={{ allowClear: true, options: styles?.map((e) => ({ label: e.name, value: e.id })) }} />
</Form>
</BottomSheet>
</>
);
}
Ключевые возможности¶
| Функция | Реализация |
|---|---|
| Просмотр фото | getItemImageUrlLarge() — URL крупного изображения |
| Атрибуты-чипы | Маппинг id → name через справочники categories/colors/styles |
| Редактирование | BottomSheet + Ant Design Form с валидацией |
| Удаление | showAppConfirm → deleteMutation → invalidate queries |
| Создание образа | Навигация на /outfits/build?fromItem=<id> |
| Похожие вещи | useSimilarItems(itemId, 8) — embedding-поиск |