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

Конструктор образов — полный код

Образы

OutfitBuilderPage

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

import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { message } from 'antd';
import { Plus, X } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import PageHeader from '../components/layout/PageHeader';
import { outfitsApi } from '../api/outfits';
import { offlineOutfitsService } from '../offline/services/offlineOutfitsService';
import { findDuplicateOutfit } from '../offline/repositories/outfitsRepository';
import { useLocalItems } from '../offline/hooks/useLocalItems';
import { useLocalCategories, useLocalSlots } from '../offline/hooks/useLocalReference';
import { useLocalItem } from '../offline/hooks/useLocalItems';
import { useNetworkStatus } from '../offline/network/useNetworkStatus';
import { getItemImageUrl, handleImgError } from '../utils/media';
import { useOutfitBuilderStore } from '../stores/outfitBuilder';
import type { OutfitCreate, OutfitItemInput, Item } from '../types/api';
import { showAppConfirm } from '../utils/showAppConfirm';
import PrimaryButton from '../components/ui/PrimaryButton';
import s from './OutfitBuilderPage.module.css';

export default function OutfitBuilderPage() {
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const fromItemId = searchParams.get('fromItem')?.trim() || undefined;
  const queryClient = useQueryClient();
  const store = useOutfitBuilderStore();
  const online = useNetworkStatus();
  const [suggestedTitle, setSuggestedTitle] = useState('');
  const lastAutoAppliedTitleRef = useRef<string>('');
  const handledFromItemRef = useRef<string | null>(null);

  const { data: slots, isLoading: slotsLoading } = useLocalSlots();
  const { data: categories = [], isFetched: categoriesFetched } = useLocalCategories();
  const { data: seedItem, isFetched: seedItemFetched, isLoading: seedItemLoading } = useLocalItem(fromItemId);

  // Построение массива выбранных вещей для suggest-title
  const suggestItems = useMemo(
    () => store.slots
      .filter((ss) => ss.item)
      .map((ss) => ({ item_id: ss.item!.id, slot_id: ss.slot.id, layer_index: 0 })),
    [store.slots],
  );

  // Инициализация слотов при загрузке (фильтруем dress)
  useEffect(() => {
    if (slots) store.initSlots(slots.filter((sl) => sl.code !== 'dress'));
  }, [slots]);

  // Обработка параметра fromItem (предзаполнение слота из WardrobeItemPage)
  useEffect(() => {
    if (!fromItemId) { handledFromItemRef.current = null; return; }
    if (handledFromItemRef.current === fromItemId) return;
    if (slotsLoading || store.slots.length === 0) return;
    if (seedItemLoading || !seedItemFetched) return;
    if (seedItem?.category_id != null && !categoriesFetched) return;

    if (!seedItem) {
      handledFromItemRef.current = fromItemId;
      navigate('/outfits/build', { replace: true });
      return;
    }

    // Определяем слот по категории вещи
    const category = categories.find((c) => c.id === seedItem.category_id);
    let slotId = category?.default_slot_id;
    const allowed = new Set(store.slots.map((ss) => ss.slot.id));
    if (slotId == null || !allowed.has(slotId)) slotId = store.slots[0]?.slot.id;

    handledFromItemRef.current = fromItemId;
    store.setSlotItem(slotId!, {
      id: seedItem.id, title: seedItem.title,
      media_id: seedItem.primary_media_id, media_url: seedItem.primary_media_url,
    });
    navigate('/outfits/build', { replace: true });
  }, [fromItemId, slotsLoading, seedItem, seedItemFetched, seedItemLoading, categories, categoriesFetched, store.slots]);

  // Автогенерация названия (debounce 500ms)
  useEffect(() => {
    if (!online || suggestItems.length === 0) { setSuggestedTitle(''); return; }

    let cancelled = false;
    const timerId = window.setTimeout(async () => {
      try {
        const response = await outfitsApi.suggestTitle({ outfit_items: suggestItems });
        if (!cancelled) {
          const nextSuggestion = response.title.trim();
          setSuggestedTitle(nextSuggestion);
          // Автоприменение если пользователь не менял вручную
          const currentTitle = store.title.trim();
          const lastAutoTitle = lastAutoAppliedTitleRef.current.trim();
          const canAutoApply = currentTitle.length === 0 ||
            (lastAutoTitle.length > 0 && currentTitle === lastAutoTitle);
          if (canAutoApply) {
            store.setTitle(nextSuggestion);
            lastAutoAppliedTitleRef.current = nextSuggestion;
          }
        }
      } catch { if (!cancelled) setSuggestedTitle(''); }
    }, 500);

    return () => { cancelled = true; window.clearTimeout(timerId); };
  }, [online, JSON.stringify(suggestItems)]);

  // Создание образа
  const createMutation = useMutation({
    mutationFn: (data: OutfitCreate) => offlineOutfitsService.create(data),
    onSuccess: async () => {
      message.success('Образ создан');
      store.clearAll();
      await queryClient.refetchQueries({ queryKey: ['outfits'] });
      navigate('/outfits', { replace: true });
    },
    onError: () => message.error('Не удалось создать образ'),
  });

  const handleCreate = async () => {
    const filledSlots = store.slots.filter((ss) => ss.item);
    if (filledSlots.length === 0) {
      message.warning('Добавьте хотя бы одну вещь');
      return;
    }

    const items: OutfitItemInput[] = filledSlots.map((ss) => ({
      item_id: ss.item!.id, slot_id: ss.slot.id, layer_index: 0,
    }));

    // Проверка на дубликат
    const duplicateTitle = await findDuplicateOutfit(items);
    if (duplicateTitle) {
      message.warning(`Образ «${duplicateTitle}» уже содержит эти вещи`);
      return;
    }

    createMutation.mutate({ title: store.title.trim() || undefined, outfit_items: items });
  };

  // Рендер: сцена (stage) + горизонтальные ряды вещей для каждого слота
  return (
    <>
      <PageHeader title="Конструктор образа" showBack />
      <div className={`${s.pageContent} ${s.builderPage}`}>
        {/* Поле ввода названия с placeholder из AI-подсказки */}
        <label className={s.titleField}>
          <input type="text" value={store.title}
            onChange={(e) => store.setTitle(e.target.value)}
            placeholder={suggestedTitle || 'Название образа (необязательно)'} />
        </label>

        {/* Stage: визуальная сборка образа */}
        <div className={s.stage}>
          {store.slots.map((ss) => (
            <div key={ss.slot.id} className={`${s.stageSlot} ${ss.item ? s.stageSlotFilled : ''}`}>
              {ss.item ? (
                <div className={s.stageSlotInner}>
                  <img src={getItemImageUrl(ss.item.media_id, ss.item.media_url)} alt={ss.item.title || ss.slot.name} />
                  <button onClick={() => store.clearSlot(ss.slot.id)}><X size={16} /></button>
                </div>
              ) : (
                <div className={s.stagePlaceholder}>{ss.slot.name}</div>
              )}
            </div>
          ))}
        </div>

        {/* Горизонтальные ряды вещей для каждого слота */}
        {store.slots.map((ss) => (
          <SlotSection key={ss.slot.id}
            slotId={ss.slot.id} slotName={ss.slot.name} selectedItemId={ss.item?.id}
            onSelectItem={(item) => store.setSlotItem(ss.slot.id, { id: item.id, title: item.title, media_id: item.primary_media_id, media_url: item.primary_media_url })}
            onClearSlot={() => store.clearSlot(ss.slot.id)}
            onViewAll={() => { store.startSelecting(); navigate(`/outfits/build/select?slotId=${ss.slot.id}`); }}
          />
        ))}

        <PrimaryButton block onClick={handleCreate} loading={createMutation.isPending}>
          Создать образ
        </PrimaryButton>
      </div>
    </>
  );
}

ItemSelectPage

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

Полноэкранная страница выбора вещи для конкретного слота.

import { Col, Row } from 'antd';
import { useNavigate, useSearchParams } from 'react-router-dom';
import ItemCard from '../components/common/ItemCard';
import PageHeader from '../components/layout/PageHeader';
import { useLocalItems } from '../offline/hooks/useLocalItems';
import { useLocalSlots } from '../offline/hooks/useLocalReference';
import { useOutfitBuilderStore } from '../stores/outfitBuilder';
import type { Item } from '../types/api';
import EmptyState from '../components/ui/EmptyState';
import PrimaryButton from '../components/ui/PrimaryButton';
import s from './ItemSelectPage.module.css';

export default function ItemSelectPage() {
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const slotParam = Number(searchParams.get('slotId') || 0);
  const slotId = Number.isFinite(slotParam) && slotParam > 0 ? slotParam : undefined;
  const store = useOutfitBuilderStore();

  const { data: slots } = useLocalSlots();
  const slotName = slots?.find((slot) => slot.id === slotId)?.name;
  const selectedItemId = slotId
    ? store.slots.find((ss) => ss.slot.id === slotId)?.item?.id
    : undefined;

  // Загрузка вещей только для текущего слота
  const { data, isLoading } = useLocalItems(slotId ? { slot_id: slotId } : {});
  const items = data?.items ?? [];

  const handleSelect = (item: Item) => {
    if (!slotId) return;
    // Повторный клик — снятие выбора
    if (item.id === selectedItemId) {
      store.clearSlot(slotId);
      navigate(-1);
      return;
    }
    // Установка вещи в слот
    store.setSlotItem(slotId, {
      id: item.id, title: item.title,
      media_id: item.primary_media_id, media_url: item.primary_media_url,
    });
    navigate(-1); // Возврат в конструктор
  };

  return (
    <>
      <PageHeader title={slotName ? `Выбрать ${slotName.toLowerCase()}` : 'Выбор вещи'} showBack />
      <div className={`${s.pageContent} ${s.itemSelectPage}`}>
        {isLoading ? /* Skeleton */ null : items.length === 0 ? (
          <EmptyState title="Нет вещей для этого слота"
            action={<PrimaryButton block onClick={() => navigate('/add')}>Добавить вещь</PrimaryButton>} />
        ) : (
          <Row gutter={[12, 12]}>
            {items.map((item) => (
              <Col xs={12} sm={8} md={6} key={item.id}>
                <ItemCard item={item}
                  meta={item.id === selectedItemId ? 'Нажмите ещё раз, чтобы снять' : undefined}
                  onClick={handleSelect} />
              </Col>
            ))}
          </Row>
        )}
      </div>
    </>
  );
}

outfitBuilderStore (Zustand)

Файл: frontend/src/stores/outfitBuilder.ts

import { create } from 'zustand';
import type { Slot } from '../types/api';

export interface BuilderItem {
  id: string;
  title?: string;
  media_id?: string;
  media_url?: string;
}

export interface BuilderSlotState {
  slot: Slot;
  item?: BuilderItem;
}

interface OutfitBuilderStore {
  slots: BuilderSlotState[];
  title: string;
  isSelecting: boolean;         // Флаг: идёт навигация на ItemSelectPage
  initSlots: (slots: Slot[]) => void;
  setTitle: (title: string) => void;
  startSelecting: () => void;   // Предотвращает сброс слотов при возврате
  setSlotItem: (slotId: number, item: BuilderItem) => void;
  clearSlot: (slotId: number) => void;
  clearAll: () => void;
}

export const useOutfitBuilderStore = create<OutfitBuilderStore>((set, get) => ({
  slots: [],
  title: '',
  isSelecting: false,

  initSlots: (slots) => {
    const { isSelecting } = get();
    // Если пользователь возвращается из выбора — не сбрасывать слоты
    if (isSelecting) {
      set({ isSelecting: false });
      return;
    }
    set({
      slots: slots
        .filter((s) => s.min_layers > 0)
        .sort((a, b) => a.order_index - b.order_index)
        .map((slot) => ({ slot })),
    });
  },

  startSelecting: () => set({ isSelecting: true }),
  setTitle: (title) => set({ title }),

  setSlotItem: (slotId, item) =>
    set((state) => ({
      slots: state.slots.map((ss) =>
        ss.slot.id === slotId ? { ...ss, item } : ss,
      ),
    })),

  clearSlot: (slotId) =>
    set((state) => ({
      slots: state.slots.map((ss) =>
        ss.slot.id === slotId ? { ...ss, item: undefined } : ss,
      ),
    })),

  clearAll: () =>
    set((state) => ({
      title: '',
      slots: state.slots.map((ss) => ({ ...ss, item: undefined })),
    })),
}));

Сохранение состояния при навигации

Проблема: при переходе на ItemSelectPage и обратно React заново маунтит OutfitBuilderPage, вызывая initSlots и сбрасывая выбранные вещи.

Решение: флаг isSelecting: 1. Перед навигацией: store.startSelecting() — устанавливает isSelecting = true 2. При возврате: initSlots() видит isSelecting === true → только сбрасывает флаг, не трогает слоты