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

Детальная карточка вещи — полный код

Гардероб

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-поиск