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

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

Образы

OutfitsPage

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

Страница отображает все образы пользователя с фильтрацией по происхождению (все / свои / чужие).

import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Row, Col } from 'antd';
import { Layers } from 'lucide-react';
import PageHeader from '../components/layout/PageHeader';
import ProfileAvatarButton from '../components/common/ProfileAvatarButton';
import OutfitCard from '../components/common/OutfitCard';
import { useLocalOutfits } from '../offline/hooks/useLocalOutfits';
import { useVirtualPagination } from '../hooks/useVirtualPagination';
import type { Outfit } from '../types/api';
import PrimaryButton from '../components/ui/PrimaryButton';
import EmptyState from '../components/ui/EmptyState';
import SkeletonCard from '../components/ui/SkeletonCard';
import s from './OutfitsPage.module.css';

type OriginFilter = 'all' | 'own' | 'external';

export default function OutfitsPage() {
  const navigate = useNavigate();
  const [originFilter, setOriginFilter] = useState<OriginFilter>('all');

  // Загрузка образов с фильтром по origin_type
  const { data, isLoading } = useLocalOutfits(
    originFilter === 'all' ? {} : { origin_type: originFilter },
  );

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

  // Виртуальная пагинация (batch 20)
  const paginationResetKey = useMemo(() => originFilter, [originFilter]);
  const { visibleItems: visibleOutfits, hasMore, sentinelRef } = useVirtualPagination(outfits, {
    batchSize: 20, resetKey: paginationResetKey,
  });

  const handleOutfitClick = (outfit: Outfit) => navigate(`/outfits/${outfit.id}`);

  return (
    <>
      <PageHeader title="Образы" rightContent={<ProfileAvatarButton />} />
      <div className={`${s.pageContent} ${s.outfitsPage}`}>
        <p className={s.pageIntro}>
          Собранные сочетания, сохранённые и присланные друзьями.
        </p>

        {/* Segmented control: Все / Свои / Чужие */}
        <div className={s.segmentedPill} role="tablist" aria-label="Фильтр образов">
          {[
            { label: 'Все', value: 'all' as const },
            { label: 'Свои', value: 'own' as const },
            { label: 'Чужие', value: 'external' as const },
          ].map((option) => (
            <button key={option.value} type="button"
              className={`${s.segmentedPillItem} ${originFilter === option.value ? s.segmentedPillItemActive : ''}`}
              onClick={() => setOriginFilter(option.value)}>
              {option.label}
            </button>
          ))}
        </div>

        {/* Сетка образов */}
        {isLoading ? (
          <Row gutter={[12, 12]}>
            {[0, 1, 2, 3].map((i) => (
              <Col xs={12} sm={8} md={6} key={i}><SkeletonCard imageHeight={128} /></Col>
            ))}
          </Row>
        ) : outfits.length === 0 ? (
          <EmptyState
            icon={<Layers size={22} strokeWidth={2} />}
            title={originFilter === 'external' ? 'Чужих образов пока нет' : 'Образов пока нет'}
            description={originFilter === 'external'
              ? 'Сохраняйте понравившиеся образы из ленты или по ссылкам от друзей.'
              : 'Создайте первый образ из вещей гардероба.'}
            action={originFilter !== 'external' ? (
              <PrimaryButton block onClick={() => navigate('/outfits/build')}>Создать образ</PrimaryButton>
            ) : undefined}
          />
        ) : (
          <Row gutter={[12, 12]}>
            {visibleOutfits.map((outfit) => (
              <Col xs={12} sm={8} md={6} key={outfit.id}>
                <OutfitCard outfit={outfit} onClick={handleOutfitClick} meta={buildOutfitMeta(outfit)} />
              </Col>
            ))}
          </Row>
        )}
        <div ref={sentinelRef} aria-hidden="true" />
      </div>
    </>
  );
}

// Мета-информация для карточки
function buildOutfitMeta(outfit: Outfit) {
  if (outfit.is_external) return 'внешний образ';
  if (outfit.origin_type === 'generated') return 'AI-подбор';
  return 'свой образ';
}

OutfitDetailPage

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

Детальный просмотр образа: обложка, состав (вещи по слотам), действия, похожие образы.

import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Form, message } from 'antd';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import PageHeader from '../components/layout/PageHeader';
import { offlineOutfitsService } from '../offline/services/offlineOutfitsService';
import { getOutfitImageUrl, getItemImageUrl, handleImgError } from '../utils/media';
import { useLocalSlots } from '../offline/hooks/useLocalReference';
import { useLocalOutfit } from '../offline/hooks/useLocalOutfits';
import { useLocalItems } from '../offline/hooks/useLocalItems';
import { showAppConfirm } from '../utils/showAppConfirm';
import SimilarOutfitsGrid from '../components/common/SimilarOutfitsGrid';
import { useSimilarOutfits } from '../embeddings/useEmbeddingSearch';
import { useLocalOutfitsByIds } from '../offline/hooks/useLocalOutfitsByIds';
import PrimaryButton from '../components/ui/PrimaryButton';
import SecondaryButton from '../components/ui/SecondaryButton';
import FilterChip from '../components/ui/FilterChip';
import BottomSheet from '../components/ui/BottomSheet';
import TextField from '../components/ui/TextField';
import s from './OutfitDetailPage.module.css';

export default function OutfitDetailPage() {
  const { outfitId } = useParams<{ outfitId: string }>();
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const [imgLoaded, setImgLoaded] = useState(false);
  const [titleEditOpen, setTitleEditOpen] = useState(false);
  const [titleForm] = Form.useForm();

  const { data: outfit, isLoading } = useLocalOutfit(outfitId);

  // Похожие образы (embedding-поиск)
  const { results: similarResults, isLoading: similarLoading } = useSimilarOutfits(outfitId, 6);
  const { data: similarOutfits = [] } = useLocalOutfitsByIds(similarResults.map((r) => r.id));

  const { data: allItems } = useLocalItems();
  const { data: slots } = useLocalSlots();

  // Удаление образа
  const deleteMutation = useMutation({
    mutationFn: () => offlineOutfitsService.delete(outfitId!),
    onSuccess: async () => {
      message.success('Образ удалён');
      await queryClient.refetchQueries({ queryKey: ['outfits'] });
      navigate('/outfits', { replace: true });
    },
  });

  // Обновление названия
  const updateTitleMutation = useMutation({
    mutationFn: (title: string) => offlineOutfitsService.update(outfitId!, { title }),
    onSuccess: async () => {
      setTitleEditOpen(false);
      message.success('Название обновлено');
      await queryClient.refetchQueries({ queryKey: ['outfits'] });
    },
  });

  if (!outfit) return /* empty state */;

  // Построение списка вещей из outfit_item_snapshots или outfit_items
  const slotMap = new Map(slots?.map((slot) => [slot.id, slot.name]) ?? []);
  const outfitItems = outfit.outfit_item_snapshots.length > 0
    ? outfit.outfit_item_snapshots.map((snapshot, index) => {
        const linked = outfit.outfit_items.find((item) => item.slot_id === snapshot.slot_id);
        const fullItem = linked ? allItems?.items.find((item) => item.id === linked.item_id) : undefined;
        return {
          key: `${snapshot.slot_id}-${index}`,
          title: snapshot.title || fullItem?.title || slotMap.get(snapshot.slot_id) || 'Вещь',
          slotName: slotMap.get(snapshot.slot_id) || 'Слот',
          mediaId: snapshot.media_id || fullItem?.primary_media_id,
          itemId: fullItem?.id,
        };
      })
    : outfit.outfit_items.map((entry, index) => {
        const fullItem = allItems?.items.find((item) => item.id === entry.item_id);
        return {
          key: `${entry.slot_id}-${index}`,
          title: fullItem?.title || slotMap.get(entry.slot_id) || 'Вещь',
          slotName: slotMap.get(entry.slot_id) || 'Слот',
          mediaId: fullItem?.primary_media_id,
          itemId: fullItem?.id,
        };
      });

  return (
    <>
      <PageHeader title="Образ" showBack />
      <div className={`${s.pageContent} ${s.outfitDetailPage}`}>
        {/* Обложка образа */}
        <div className={s.media}>
          <img src={getOutfitImageUrl(outfit.cover_media_id, outfit.cover_media_url)}
            alt={outfit.title || 'Образ'} className={s.image}
            style={{ display: imgLoaded ? 'block' : 'none' }}
            onLoad={() => setImgLoaded(true)} />
        </div>

        {/* Название + кнопка редактирования */}
        <div className={s.titleBlock}>
          <h1 className={s.title}>{outfit.title?.trim() || 'Без названия'}</h1>
          {!outfit.is_external && (
            <button type="button" className={s.titleEdit} onClick={() => {
              titleForm.setFieldsValue({ title: outfit.title || '' });
              setTitleEditOpen(true);
            }}>Редактировать название</button>
          )}
        </div>

        {/* Метка "чужой образ" */}
        {outfit.is_external && <FilterChip dotTone="warning">чужой образ</FilterChip>}

        {/* Действия */}
        <div className={s.actions}>
          {!outfit.is_external && (
            <PrimaryButton block onClick={() => navigate(`/share/${outfit.id}`)}>Поделиться</PrimaryButton>
          )}
          <SecondaryButton block onClick={() => showAppConfirm({
            title: 'Удалить образ?',
            content: 'Образ будет удалён навсегда. Ссылка для шаринга перестанет работать.',
            okText: 'Удалить', okButtonProps: { danger: true },
            onOk: () => deleteMutation.mutateAsync(),
          })} loading={deleteMutation.isPending}>
            Удалить образ
          </SecondaryButton>
        </div>

        {/* Состав образа (список вещей по слотам) */}
        {!outfit.is_external && outfitItems.length > 0 && (
          <section>
            <h2>Состав образа</h2>
            <div className={s.compositionList}>
              {outfitItems.map((item) => (
                <button key={item.key} type="button" className={s.compositionRow}
                  onClick={item.itemId ? () => navigate(`/wardrobe/${item.itemId}`) : undefined}>
                  <div className={s.compositionRowThumb}>
                    {item.mediaId && <img src={getItemImageUrl(item.mediaId)} alt={item.title} />}
                  </div>
                  <div>
                    <span className={s.compositionRowTitle}>{item.title}</span>
                    <span className={s.compositionRowSlot}>{item.slotName}</span>
                  </div>
                </button>
              ))}
            </div>
          </section>
        )}

        {/* Похожие образы (embedding-search) */}
        <SimilarOutfitsGrid outfits={similarOutfits} isLoading={similarLoading}
          onOutfitClick={(id) => navigate(`/outfits/${id}`)} />
      </div>

      {/* BottomSheet: редактирование названия */}
      <BottomSheet title="Название образа" open={titleEditOpen} onClose={() => setTitleEditOpen(false)}
        footer={<>
          <SecondaryButton onClick={() => setTitleEditOpen(false)}>Отмена</SecondaryButton>
          <PrimaryButton onClick={async () => {
            const values = await titleForm.validateFields();
            updateTitleMutation.mutate((values.title || '').trim());
          }} loading={updateTitleMutation.isPending}>Сохранить</PrimaryButton>
        </>}>
        <Form form={titleForm} layout="vertical" requiredMark={false}>
          <TextField name="title" label="Название" placeholder="Например: Городской casual" />
        </Form>
      </BottomSheet>
    </>
  );
}

Ключевые особенности

Функция Реализация
Фильтр "Свои/Чужие" Segmented control → origin_type параметр
Состав образа outfit_item_snapshots (приоритет) или outfit_items
Редактирование названия BottomSheet + updateTitleMutation
Похожие образы useSimilarOutfits(outfitId, 6) — embedding-search
Навигация к вещи Клик по compositionRow → /wardrobe/:itemId
Шеринг Кнопка → /share/:outfitId