Лента образов — полный код¶
FeedPage¶
Файл: frontend/src/pages/FeedPage.tsx
Персонализированная лента AI-сгенерированных образов. Использует cursor-based пагинацию, snap-scroll для навигации между карточками и кеширование сессии.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { InfiniteData } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { message } from 'antd';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Bookmark, Loader2, Shirt, Share2, Sparkles } from 'lucide-react';
import { feedApi } from '../api/feed';
import { outfitsApi } from '../api/outfits';
import { useWeather } from '../hooks/useWeather';
import { useLocalCollections } from '../offline/hooks/useLocalReference';
import { getOutfitImageUrl, handleImgError } from '../utils/media';
import { findDuplicateOutfit } from '../offline/repositories/outfitsRepository';
import type { Collection, FeedList, Outfit } from '../types/api';
import { readFeedSessionCache, writeFeedSessionCache } from '../utils/feedSessionCache';
import PageHeader from '../components/layout/PageHeader';
import FilterChip from '../components/ui/FilterChip';
import ActionIconButton from '../components/ui/ActionIconButton';
import EmptyState from '../components/ui/EmptyState';
import PrimaryButton from '../components/ui/PrimaryButton';
import SkeletonCard from '../components/ui/SkeletonCard';
import OfflineUnavailable from '../components/ui/OfflineUnavailable';
import { useNetworkStatus } from '../offline/network/useNetworkStatus';
import s from './FeedPage.module.css';
const BATCH = 20;
// Конфигурация коллекций (тематические подборки)
const COLLECTION_COPY: Record<string, { title: string; chipLabel: string }> = {
university: { title: 'Для универа', chipLabel: 'Универ' },
office: { title: 'Для офиса', chipLabel: 'Офис' },
daily_mix: { title: 'На каждый день', chipLabel: 'День' },
date: { title: 'Для свидания', chipLabel: 'Свидание' },
sport: { title: 'Для активности', chipLabel: 'Спорт' },
};
export default function FeedPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const online = useNetworkStatus();
const weather = useWeather({ passive: true });
const [searchParams] = useSearchParams();
const sentinelRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const [savedIds, setSavedIds] = useState<Set<string>>(new Set());
const [persistedOutfitIdByFeedId, setPersistedOutfitIdByFeedId] = useState<Record<string, string>>({});
const [currentIndex, setCurrentIndex] = useState(0);
const collectionCode = searchParams.get('collection') ?? undefined;
const { data: collections } = useLocalCollections();
// Ключ запроса включает коллекцию и контекст погоды
const feedQueryKey = useMemo(
() => ['feed-infinite', collectionCode ?? null, weather.feedContext ?? 'none'] as const,
[collectionCode, weather.feedContext],
);
// Восстановление из session cache для мгновенного отображения
const feedSessionBootstrap = useMemo(
() => readFeedSessionCache(feedQueryKey), [feedQueryKey],
);
// Infinite query с cursor-пагинацией
const { data, isLoading, isFetching, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: feedQueryKey,
queryFn: ({ pageParam }) =>
feedApi.list({
collection: collectionCode,
weather: weather.feedContext,
limit: BATCH,
cursor: pageParam as number | undefined,
}),
initialPageParam: undefined as number | undefined,
getNextPageParam: (last) => last.next_offset,
initialData: feedSessionBootstrap?.data,
initialDataUpdatedAt: feedSessionBootstrap?.dataUpdatedAt,
staleTime: 3 * 60 * 1000,
gcTime: 45 * 60 * 1000,
refetchOnMount: true,
});
// Запись в session cache при получении данных
useEffect(() => {
if (!data?.pages.length) return;
writeFeedSessionCache(feedQueryKey, data as InfiniteData<FeedList>);
}, [data, feedQueryKey]);
const outfits: Outfit[] = data?.pages.flatMap((page) => page.outfits) ?? [];
// Материализация виртуального образа (лента генерирует «виртуальные» образы без id в БД)
const materializeOutfit = async (outfit: Outfit): Promise<string> => {
const created = await outfitsApi.create({
outfit_items: outfit.outfit_items.map((oi) => ({
item_id: oi.item_id, slot_id: oi.slot_id, layer_index: oi.layer_index,
})),
});
return created.id;
};
// Сохранение образа из ленты
const saveMutation = useMutation({
mutationFn: async (outfit: Outfit) => {
// Проверка на дубликат
const items = outfit.outfit_items.map((oi) => ({ slot_id: oi.slot_id, item_id: oi.item_id }));
const dup = await findDuplicateOutfit(items);
if (dup) throw new Error(`DUPLICATE:${dup}`);
const realId = await materializeOutfit(outfit);
await outfitsApi.save(realId);
return { realId, feedId: outfit.id };
},
onSuccess: ({ realId, feedId }) => {
setPersistedOutfitIdByFeedId((prev) => (feedId === realId ? prev : { ...prev, [feedId]: realId }));
setSavedIds((prev) => { const next = new Set(prev); next.add(realId); next.add(feedId); return next; });
message.success('Образ сохранён');
queryClient.invalidateQueries({ queryKey: ['outfits'] });
},
onError: (err) => {
const msg = err instanceof Error ? err.message : '';
if (msg.startsWith('DUPLICATE:')) {
message.warning(`Образ «${msg.slice(10)}» уже содержит эти вещи`);
} else {
message.error('Не удалось сохранить');
}
},
});
// Удаление из сохранённых
const unsaveMutation = useMutation({
mutationFn: async ({ outfit, persistedId }: { outfit: Outfit; persistedId: string }) => {
await outfitsApi.unsave(persistedId);
return { outfit, persistedId };
},
onSuccess: ({ outfit, persistedId }) => {
setSavedIds((prev) => { const next = new Set(prev); next.delete(outfit.id); next.delete(persistedId); return next; });
message.success('Убрано из сохранённых');
queryClient.invalidateQueries({ queryKey: ['outfits'] });
},
});
// IntersectionObserver для подгрузки следующей страницы
useEffect(() => {
const node = sentinelRef.current;
const root = scrollRef.current;
if (!node || !root || !hasNextPage || isFetchingNextPage) return undefined;
const observer = new IntersectionObserver(
(entries) => { if (entries[0]?.isIntersecting) fetchNextPage(); },
{ root, rootMargin: '220px 0px' },
);
observer.observe(node);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
// Отслеживание текущего видимого образа по scroll position
useEffect(() => {
const container = scrollRef.current;
if (!container) return undefined;
const handleScroll = () => {
const idx = Math.min(Math.round(container.scrollTop / container.clientHeight), outfits.length - 1);
setCurrentIndex((prev) => (prev === idx ? prev : idx));
};
container.addEventListener('scroll', handleScroll, { passive: true });
return () => container.removeEventListener('scroll', handleScroll);
}, [outfits.length]);
if (!online) {
return <OfflineUnavailable description="Лента генерируется на сервере." />;
}
return (
<section className={s.feedPageScreen}>
{/* Фиксированный заголовок + теги текущего образа */}
<div className={s.fixedTop}>
<div className={s.header}>
<h1>{feedCopy.title}</h1>
<div className={s.meta}>{feedCopy.meta}</div>
</div>
<div className={s.filters}>
{feedChips.map((chip) => (
<FilterChip key={chip.label} dotTone={chip.dotTone}>{chip.label}</FilterChip>
))}
</div>
</div>
{/* Snap-scroll контейнер */}
<div ref={scrollRef} className={s.snapList}>
{outfits.map((outfit, index) => {
const isSaved = savedIds.has(outfit.id) || Boolean(outfit.is_saved);
return (
<article key={`${outfit.id}-${index}`} className={s.heroCard}>
{/* Карточка-кнопка: клик → материализация → детали */}
<button type="button" className={s.heroCardTap}
onClick={async () => {
const realId = await materializeOutfit(outfit);
navigate(`/outfits/${realId}`);
}}>
{/* Изображение: cover или stack из item_media_urls */}
{outfit.cover_media_id || outfit.cover_media_url ? (
<img src={getOutfitImageUrl(outfit.cover_media_id, outfit.cover_media_url)}
alt={outfit.title || 'Образ'} loading={index === 0 ? 'eager' : 'lazy'} />
) : outfit.item_media_urls?.length > 0 ? (
<div className={s.heroCardItemsStack}>
{outfit.item_media_urls.map((url, i) => (
<img key={i} src={url} alt="Вещь" loading="lazy" />
))}
</div>
) : null}
</button>
{/* Действия: сохранить + поделиться */}
<div className={s.heroCardActions}>
<ActionIconButton
icon={<Bookmark size={18} fill={isSaved ? 'currentColor' : 'none'} />}
tone={isSaved ? 'accent' : 'surface'}
onClick={() => {
if (isSaved) {
const persistedId = persistedOutfitIdByFeedId[outfit.id] ?? outfit.id;
unsaveMutation.mutate({ outfit, persistedId });
} else {
saveMutation.mutate(outfit);
}
}} />
<ActionIconButton
icon={<Share2 size={18} />}
onClick={async () => {
const realId = await materializeOutfit(outfit);
navigate(`/share/${realId}`);
}} />
</div>
</article>
);
})}
{/* Sentinel для infinite scroll */}
<div ref={sentinelRef} className={s.sentinel} />
</div>
</section>
);
}
feedApi¶
Файл: frontend/src/api/feed.ts
import { apiClient } from './client';
import type { FeedList, FeedParams } from '../types/api';
export const feedApi = {
list: (params?: FeedParams) =>
apiClient.get<FeedList>('/feed', { params }).then((r) => r.data),
};
Архитектурные решения¶
| Решение | Обоснование |
|---|---|
| Cursor-based пагинация | Стабильность при добавлении/удалении; offset-пагинация нестабильна для динамических списков |
| useInfiniteQuery | Нативная поддержка бесконечных списков в TanStack Query |
| Session cache (sessionStorage) | Мгновенное отображение при повторном входе на страницу (staleTime: 3 мин) |
| Материализация виртуальных образов | Лента генерирует комбинации без сохранения в БД; при действии (сохранить/открыть/поделиться) образ создаётся в БД |
| Snap-scroll | Полноэкранные карточки для мобильного UX (аналог TikTok/Reels) |
| IntersectionObserver (rootMargin: 220px) | Предзагрузка следующей страницы до того, как пользователь долистает |
| Weather context | Персонализация ленты с учётом текущей погоды |
| Коллекции | Тематические фильтры (универ, офис, свидание и т.д.) |