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

Шеринг образов — полный код

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

SharePage

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

Страница создания ссылки для шеринга. Поддерживает два формата: ссылка (копируется в буфер) и сторис (генерация JPEG-картинки).

import { useEffect, useState } from 'react';
import { message } from 'antd';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import PageHeader from '../components/layout/PageHeader';
import { outfitsApi } from '../api/outfits';
import { generateOutfitStoryJpeg, openStoryBlobInNewTab } from '../utils/generateOutfitStoryImage';
import { getOutfitHeroImageUrl, isEmptyPlaceholderImage } from '../utils/media';
import type { ShareFormat } from '../types/api';
import SecondaryButton from '../components/ui/SecondaryButton';
import PrimaryButton from '../components/ui/PrimaryButton';
import EmptyState from '../components/ui/EmptyState';
import SkeletonCard from '../components/ui/SkeletonCard';
import OfflineUnavailable from '../components/ui/OfflineUnavailable';
import { useNetworkStatus } from '../offline/network/useNetworkStatus';
import s from './SharePage.module.css';

export default function SharePage() {
  const { outfitId } = useParams<{ outfitId: string }>();
  const [imageLoaded, setImageLoaded] = useState(false);
  const online = useNetworkStatus();

  // Загрузка образа
  const { data: outfit, isLoading } = useQuery({
    queryKey: ['outfits', outfitId],
    queryFn: () => outfitsApi.get(outfitId!),
    enabled: Boolean(outfitId),
  });

  const imageUrl = outfit ? getOutfitHeroImageUrl(outfit) : '';

  // Мутация: генерация ссылки на бэкенде
  const shareMutation = useMutation({
    mutationFn: (format: ShareFormat) => outfitsApi.share(outfitId!, format),
    onSuccess: async (data) => {
      if (data.format === 'link' && data.url) {
        try {
          await navigator.clipboard.writeText(data.url);
          message.success('Ссылка скопирована');
        } catch {
          message.success('Ссылка готова');
          window.open(data.url, '_blank', 'noopener,noreferrer');
        }
      }
    },
    onError: () => message.error('Не удалось. Попробуйте снова.'),
  });

  // Мутация: генерация JPEG для сторис (client-side)
  const storyImageMutation = useMutation({
    mutationFn: async () => {
      if (!outfit) throw new Error('no_outfit');
      const src = getOutfitHeroImageUrl(outfit);
      if (isEmptyPlaceholderImage(src)) throw new Error('no_image');
      return generateOutfitStoryJpeg({ outfitImageSrc: src });
    },
    onSuccess: (blob) => {
      if (!outfit) return;
      openStoryBlobInNewTab(blob, outfit.title || outfit.id);
      message.success('Сохраните фото из вкладки и выложите в сторис');
    },
    onError: (err: unknown) => {
      const msg = err instanceof Error ? err.message : '';
      if (msg === 'no_image') {
        message.warning('У образа нет фото для картинки');
        return;
      }
      message.error('Не удалось собрать картинку.');
    },
  });

  // Offline: шеринг недоступен
  if (!online) {
    return (
      <>
        <PageHeader title="Поделиться" showBack />
        <OfflineUnavailable description="Для создания ссылки нужен интернет." />
      </>
    );
  }

  if (!outfit) {
    return (
      <>
        <PageHeader title="Поделиться" showBack />
        <EmptyState title="Образ не найден" />
      </>
    );
  }

  return (
    <>
      <PageHeader title="Поделиться" showBack />
      <div className={`${s.pageContent} ${s.sharePage}`}>
        {/* Превью образа */}
        <div className={s.previewWrap}>
          <img src={imageUrl} alt={outfit.title || 'Образ'} className={s.preview}
            style={{ display: imageLoaded ? 'block' : 'none' }}
            onLoad={() => setImageLoaded(true)} />
        </div>

        {/* Кнопки действий */}
        <div className={s.actions}>
          <SecondaryButton block
            onClick={() => shareMutation.mutate('link')}
            loading={shareMutation.isPending && shareMutation.variables === 'link'}>
            Скопировать ссылку
          </SecondaryButton>
          <PrimaryButton block
            onClick={() => storyImageMutation.mutate()}
            loading={storyImageMutation.isPending}>
            Сторис
          </PrimaryButton>
        </div>
      </div>
    </>
  );
}

ShareRecipientPage

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

Публичная страница для получателя ссылки. Показывает образ и позволяет «забрать» его в свою коллекцию.

import { useEffect, useState } from 'react';
import { message } from 'antd';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { shareApi } from '../api/share';
import { useAuthStore } from '../stores/auth';
import { getOutfitImageUrl } from '../utils/media';
import FilterChip from '../components/ui/FilterChip';
import PrimaryButton from '../components/ui/PrimaryButton';
import SecondaryButton from '../components/ui/SecondaryButton';
import EmptyState from '../components/ui/EmptyState';
import OfflineUnavailable from '../components/ui/OfflineUnavailable';
import { useNetworkStatus } from '../offline/network/useNetworkStatus';
import s from './ShareRecipientPage.module.css';

export default function ShareRecipientPage() {
  const { token } = useParams<{ token: string }>();
  const navigate = useNavigate();
  const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
  const [imageLoaded, setImageLoaded] = useState(false);
  const online = useNetworkStatus();

  // Загрузка образа по токену (публичный endpoint)
  const { data: outfit, isLoading, error } = useQuery({
    queryKey: ['share', token],
    queryFn: () => shareApi.getByToken(token!),
    enabled: Boolean(token),
  });

  const imageUrl = getOutfitImageUrl(outfit?.cover_media_id, outfit?.cover_media_url);

  // Claim: добавление образа в свою коллекцию
  const claimMutation = useMutation({
    mutationFn: () => shareApi.claim(token!),
    onSuccess: () => {
      message.success('Образ добавлен в коллекцию');
      navigate('/', { replace: true });
    },
    onError: () => message.error('Не удалось забрать образ'),
  });

  const redirectTarget = token ? `/share/view/${token}` : '/';

  const handleClaim = () => {
    // Если не авторизован — сначала логин, потом вернёмся
    if (!isAuthenticated) {
      navigate(`/login?redirect=${encodeURIComponent(redirectTarget)}`);
      return;
    }
    claimMutation.mutate();
  };

  if (!online) {
    return <OfflineUnavailable description="Для просмотра расшаренного образа нужен интернет." />;
  }

  if (error || !outfit) {
    return (
      <EmptyState
        title="Ссылка не найдена или истекла"
        description="Попросите отправить ссылку заново."
        action={<PrimaryButton block onClick={() => navigate('/')}>Открыть приложение</PrimaryButton>}
      />
    );
  }

  const itemCount = outfit.outfit_item_snapshots.length || outfit.outfit_items.length;

  return (
    <div className={`app-layout ${s.publicShareLayout}`}>
      <div className={s.body}>
        {/* Метка-чип */}
        <FilterChip dotTone="accent">
          {`чужой образ · ${itemCount} ${pluralizeItems(itemCount)}`}
        </FilterChip>

        <h1 className={s.title}>{outfit.title || 'Образ для вас'}</h1>

        {/* Изображение образа */}
        <div className={s.media}>
          <img src={imageUrl} alt={outfit.title || 'Образ'} className={s.image}
            style={{ display: imageLoaded ? 'block' : 'none' }}
            onLoad={() => setImageLoaded(true)} />
        </div>

        {/* Действия */}
        <div className={s.actions}>
          <PrimaryButton block onClick={handleClaim} loading={claimMutation.isPending}>
            Забрать образ
          </PrimaryButton>
          {!isAuthenticated && (
            <SecondaryButton block
              onClick={() => navigate(`/register?redirect=${encodeURIComponent(redirectTarget)}`)}>
              Зарегистрироваться
            </SecondaryButton>
          )}
        </div>
      </div>
    </div>
  );
}

shareApi

Файл: frontend/src/api/share.ts

import { apiClient } from './client';
import type { Outfit, SharedOutfitList } from '../types/api';

export const shareApi = {
  // Получить образ по токену (публичный, без авторизации)
  getByToken: (token: string) =>
    apiClient.get<Outfit>(`/share/${token}`).then((r) => r.data),

  // Забрать образ себе (создаёт копию в коллекции получателя)
  claim: (token: string) =>
    apiClient.post<Outfit>(`/share/${token}`).then((r) => r.data),

  // Список расшаренных образов текущего пользователя
  getMySharedOutfits: (params?: { limit?: number; offset?: number }) =>
    apiClient.get<SharedOutfitList>('/users/me/shared-outfits', { params }).then((r) => r.data),

  // Отзыв ссылки (деактивация токена)
  revoke: (token: string) =>
    apiClient.delete(`/share/${token}`).then((r) => r.data),
};

Жизненный цикл токена

Отправитель: POST /outfits/:id/share → { token, url }
Получатель: GET /share/:token → Outfit (публичный просмотр)
Получатель: POST /share/:token → Outfit (claim — копия в коллекцию)
Отправитель: DELETE /share/:token → отзыв (ссылка перестаёт работать)