Шеринг образов — полный код¶
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),
};