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

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>
  );
}

CSS (SimilarOutfitsGrid.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;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--pl-space-3); }
.cell { min-width: 0; }