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

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

Социальные функции

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 Персонализация ленты с учётом текущей погоды
Коллекции Тематические фильтры (универ, офис, свидание и т.д.)