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