Common-компоненты — полный справочник¶
Уровень: L3 (deep-dive) | Вверх: README.md | Раздел: ../README.md
Все компоненты расположены в frontend/src/components/common/. Бизнес-компоненты, связанные с доменными сущностями (Item, Outfit, User).
ItemCard¶
Файл: ItemCard.tsx (25 строк)
Карточка вещи гардероба. Обёртка над MediaCard, маппящая доменную модель Item на пропсы карточки.
Пропсы¶
interface ItemCardProps {
item: Item; // Доменная модель вещи
onClick?: (item: Item) => void; // Клик по карточке
meta?: string; // Подпись (переопределяет дефолт)
compact?: boolean; // Компактный режим (для рейлов)
}
Полный код¶
import type { Item } from '../../types/api';
import MediaCard from '../ui/MediaCard';
import { getItemImageUrl } from '../../utils/media';
interface ItemCardProps {
item: Item;
onClick?: (item: Item) => void;
meta?: string;
compact?: boolean;
}
export default function ItemCard({ item, onClick, meta, compact }: ItemCardProps) {
// Получение URL изображения с приоритетом: media_id → media_url → placeholder
const imageUrl = getItemImageUrl(item.primary_media_id, item.primary_media_url);
return (
<MediaCard
title={item.title || 'Без названия'}
meta={meta}
imageUrl={imageUrl}
alt={item.title || 'Вещь'}
onClick={() => onClick?.(item)}
compact={compact}
/>
);
}
Использование¶
// WardrobePage.tsx — сетка вещей
<ItemCard item={item} onClick={(i) => navigate(`/wardrobe/${i.id}`)} />
// SimilarItemsRail.tsx — компактные карточки в горизонтальном скролле
<ItemCard item={item} onClick={() => onItemClick(item.id)} compact />
OutfitCard¶
Файл: OutfitCard.tsx (40 строк)
Карточка образа. Автоматически вычисляет метаданные (внешний/свой, количество вещей) и выбирает imageFit.
Пропсы¶
interface OutfitCardProps {
outfit: Outfit; // Доменная модель образа
onClick?: (outfit: Outfit) => void; // Клик по карточке
compact?: boolean;
meta?: string; // Переопределение auto-meta
}
Полный код¶
import type { Outfit } from '../../types/api';
import MediaCard from '../ui/MediaCard';
import { getOutfitImageUrl } from '../../utils/media';
interface OutfitCardProps {
outfit: Outfit;
onClick?: (outfit: Outfit) => void;
compact?: boolean;
meta?: string;
}
export default function OutfitCard({ outfit, onClick, compact, meta }: OutfitCardProps) {
const imageUrl = getOutfitImageUrl(outfit.cover_media_id, outfit.cover_media_url);
const itemCount = outfit.outfit_item_snapshots.length || outfit.outfit_items.length;
// Автоматическое определение meta:
// - «внешний образ» для imported/shared/generated
// - «свой образ» для собственных
const resolvedMeta = meta ?? (
outfit.is_external
? 'внешний образ'
: itemCount
? 'свой образ'
: undefined
);
return (
<MediaCard
title={outfit.title || 'Без названия'}
meta={resolvedMeta}
imageUrl={imageUrl}
alt={outfit.title || 'Образ'}
onClick={() => onClick?.(outfit)}
compact={compact}
// Если есть cover_media — contain (полное изображение), иначе cover
imageFit={(outfit.cover_media_id || outfit.cover_media_url) ? 'contain' : 'cover'}
/>
);
}
Использование¶
// OutfitsPage.tsx — сетка образов
<OutfitCard outfit={outfit} onClick={(o) => navigate(`/outfits/${o.id}`)} />
// FeedPage.tsx — карточка в ленте
<OutfitCard outfit={outfit} />
SharedOutfitCard¶
Файл: SharedOutfitCard.tsx (50 строк)
Карточка shared-образа с метриками (просмотры, забрали) и кнопкой отзыва ссылки.
Пропсы¶
interface SharedOutfitCardProps {
item: SharedOutfitItem; // { outfit, token, view_count, claim_count }
onRevoke: (token: string) => void; // Отзыв ссылки
revoking?: boolean; // Блокировка кнопки во время запроса
}
Полный код¶
import { useNavigate } from 'react-router-dom';
import { Link2Off } from 'lucide-react';
import { getOutfitImageUrl } from '../../utils/media';
import type { SharedOutfitItem } from '../../types/api';
import SharedAccessRow from '../ui/SharedAccessRow';
interface SharedOutfitCardProps {
item: SharedOutfitItem;
onRevoke: (token: string) => void;
revoking?: boolean;
}
export default function SharedOutfitCard({ item, onRevoke, revoking }: SharedOutfitCardProps) {
const navigate = useNavigate();
const { outfit, token, view_count, claim_count } = item;
const imageUrl = getOutfitImageUrl(outfit.cover_media_id, outfit.cover_media_url);
return (
<SharedAccessRow
title={outfit.title || 'Без названия'}
stats={`${view_count} ${pluralizeViews(view_count)} · ${claim_count} ${pluralizeClaims(claim_count)}`}
imageUrl={imageUrl}
imageAlt={outfit.title || 'Образ'}
onOpen={() => navigate(`/outfits/${outfit.id}`)}
onAction={() => onRevoke(token)}
actionDisabled={revoking}
actionLabel="Закрыть ссылку"
actionIcon={<Link2Off size={18} strokeWidth={2.1} />}
/>
);
}
// Русская плюрализация для «просмотр/просмотра/просмотров»
function pluralizeViews(count: number) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return 'просмотр';
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'просмотра';
return 'просмотров';
}
function pluralizeClaims(count: number) {
return count === 1 ? 'забрал' : 'забрали';
}
ProfileAvatarButton¶
Файл: ProfileAvatarButton.tsx (34 строки)
Круглая кнопка-аватар в шапке главной страницы. Показывает аватар пользователя или монограмму.
Пропсы¶
Нет внешних пропсов. Данные берёт из useAuthStore.
Полный код¶
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/auth';
import { getAvatarUrl } from '../../utils/media';
import s from './ProfileAvatarButton.module.css';
// Генерация монограммы из имени пользователя
// "Дмитрий Шишминцев" → "ДШ", "Дмитрий" → "ДМ", undefined → "PL"
function getMonogram(name?: string): string {
if (!name) return 'PL';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
export default function ProfileAvatarButton() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const avatarUrl = user?.avatar_media_id ? getAvatarUrl(user.avatar_media_id) : undefined;
const monogram = getMonogram(user?.name);
return (
<button className={s.btn} onClick={() => navigate('/profile')} aria-label="Профиль">
{avatarUrl ? (
<img src={avatarUrl} alt="" className={s.img} />
) : (
<span className={s.monogram}>{monogram}</span>
)}
</button>
);
}
CSS (ProfileAvatarButton.module.css)¶
.btn {
width: 40px; height: 40px; border: none; background: var(--pl-color-accent);
cursor: pointer; padding: 0; border-radius: 50%; overflow: hidden;
}
.btn:active { opacity: 0.7; }
.monogram { font-size: 14px; font-weight: 700; color: #fff; }
.img { width: 100%; height: 100%; object-fit: cover; }
SimilarItemsRail¶
Файл: SimilarItemsRail.tsx (41 строка)
Горизонтальная лента похожих вещей с scroll-snap. Показывает skeleton-лоадеры во время загрузки.
Пропсы¶
interface SimilarItemsRailProps {
items: Item[]; // Массив похожих вещей
isLoading: boolean; // Показывать skeleton
onItemClick: (itemId: string) => void; // Клик по вещи
}
Полный код¶
import type { Item } from '../../types/api';
import ItemCard from './ItemCard';
import SkeletonCard from '../ui/SkeletonCard';
import s from './SimilarItemsRail.module.css';
export default function SimilarItemsRail({ items, isLoading, onItemClick }: SimilarItemsRailProps) {
if (!isLoading && items.length === 0) return null;
return (
<section className={s.section}>
<h2 className={s.sectionTitle}>Похожие вещи</h2>
<div className={s.rail}>
{isLoading
? Array.from({ length: 4 }).map((_, i) => (
<div className={s.card} key={`skel-${i}`}>
<SkeletonCard imageHeight={100} lines={2} />
</div>
))
: items.map((item) => (
<div className={s.card} key={item.id}>
<ItemCard item={item} onClick={() => onItemClick(item.id)} compact />
</div>
))}
</div>
</section>
);
}
CSS (SimilarItemsRail.module.css)¶
.section { display: flex; flex-direction: column; gap: var(--pl-space-3); }
.sectionTitle {
font-family: var(--pl-font-display); font-size: var(--pl-font-size-section-title); font-weight: 700;
}
.rail {
display: flex; gap: var(--pl-space-3);
overflow-x: auto; scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch; scrollbar-width: none;
}
.rail::-webkit-scrollbar { display: none; }
.card { flex: 0 0 140px; scroll-snap-align: start; }
SimilarOutfitsGrid¶
Файл: SimilarOutfitsGrid.tsx (42 строки)
Сетка похожих образов (2 колонки, максимум 6 элементов). Показывает skeleton-лоадеры во время загрузки.
Пропсы¶
interface SimilarOutfitsGridProps {
outfits: Outfit[]; // Массив похожих образов
isLoading: boolean; // Показывать skeleton
onOutfitClick: (outfitId: string) => void; // Клик по образу
}
Полный код¶
import type { Outfit } from '../../types/api';
import OutfitCard from './OutfitCard';
import SkeletonCard from '../ui/SkeletonCard';
import s from './SimilarOutfitsGrid.module.css';
const MAX_VISIBLE = 6;
export default function SimilarOutfitsGrid({ outfits, isLoading, onOutfitClick }: SimilarOutfitsGridProps) {
if (!isLoading && outfits.length === 0) return null;
return (
<section className={s.section}>
<h2 className={s.sectionTitle}>Похожие образы</h2>
<div className={s.grid}>
{isLoading
? Array.from({ length: 4 }).map((_, i) => (
<div className={s.cell} key={`skel-${i}`}>
<SkeletonCard imageHeight={130} lines={2} />
</div>
))
: outfits.slice(0, MAX_VISIBLE).map((outfit) => (
<div className={s.cell} key={outfit.id}>
<OutfitCard outfit={outfit} onClick={() => onOutfitClick(outfit.id)} />
</div>
))}
</div>
</section>
);
}