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

UI-компоненты — полный справочник

Уровень: L3 (deep-dive) | Вверх: README.md | Раздел: ../README.md

Все компоненты расположены в frontend/src/components/ui/. Каждый экспортирует default-компонент.


MediaCard

Файл: MediaCard.tsx (71 строка)

Универсальная карточка с изображением, заголовком и метаданными. Основа для ItemCard и OutfitCard.

Пропсы

interface MediaCardProps {
  title: string;                          // Заголовок (обрезается 1 строкой)
  meta?: string;                          // Подпись (цвет textMuted)
  imageUrl?: string;                      // URL изображения
  alt: string;                            // Alt-текст
  onClick?: () => void;                   // Клик → рендерит <button> вместо <div>
  badge?: ReactNode;                      // Бейдж в левом верхнем углу поверх изображения
  className?: string;
  aspectRatio?: string;                   // CSS aspect-ratio изображения (default: '1 / 1')
  compact?: boolean;                      // Уменьшенные отступы (8px вместо 10px)
  imageFit?: 'cover' | 'contain';         // object-fit (default: 'cover')
}

Код

import { type ReactNode, useCallback, useState } from 'react';
import { ImageOff } from 'lucide-react';
import s from './MediaCard.module.css';

export default function MediaCard({
  title, meta, imageUrl, alt, onClick, badge,
  className = '', aspectRatio = '1 / 1', compact = false, imageFit = 'cover',
}: MediaCardProps) {
  const [imgError, setImgError] = useState(false);
  const handleImgError = useCallback(() => setImgError(true), []);
  const Tag = onClick ? 'button' : 'div';

  return (
    <Tag className={[s.root, compact ? s.compact : '', onClick ? s.interactive : '', className]
      .filter(Boolean).join(' ')} onClick={onClick} type={Tag === 'button' ? 'button' : undefined}>
      {badge ? <div className={s.badge}>{badge}</div> : null}
      <div className={s.imageWrap} style={{ aspectRatio }}>
        {imageUrl && !imgError ? (
          <img src={imageUrl} alt={alt}
            className={`${s.image} ${imageFit === 'cover' ? s.imageCover : s.imageContain}`}
            loading="lazy" onError={handleImgError} />
        ) : (
          <div className={s.placeholder} aria-hidden="true">
            <ImageOff size={24} strokeWidth={1.5} className={s.placeholderIcon} />
            <span className={s.placeholderText}>Нет фото</span>
          </div>
        )}
      </div>
      <div className={s.body}>
        <div className={s.title}>{title}</div>
        {meta ? <div className={s.meta}>{meta}</div> : null}
      </div>
    </Tag>
  );
}

CSS (MediaCard.module.css)

.root {
  position: relative;
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px 10px 12px;
  border: 1px solid var(--pl-color-border);
  border-radius: 20px;
  background: var(--pl-color-surface);
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.04);
  text-align: left;
}
.interactive { cursor: pointer; }
.compact { padding: 8px; }
.imageWrap { overflow: hidden; border-radius: 12px; background: var(--pl-color-accent-soft); }
.image, .placeholder { width: 100%; height: 100%; display: block; }
.imageCover { object-fit: cover; }
.imageContain { object-fit: contain; }
.placeholder {
  background: var(--pl-color-surface-alt);
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
}
.placeholderIcon { color: var(--pl-color-text-muted); opacity: 0.4; }
.placeholderText { font-size: 11px; font-weight: 500; color: var(--pl-color-text-muted); opacity: 0.5; }
.body { display: flex; flex-direction: column; gap: 2px; }
.title {
  font-size: 12px; font-weight: 700; line-height: 1.15; color: var(--pl-color-text);
  display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
}
.meta { font-size: var(--pl-font-size-meta); font-weight: 500; color: var(--pl-color-text-muted); }
.badge { position: absolute; top: 18px; left: 18px; z-index: 1; }

FilterChip

Файл: FilterChip.tsx (53 строки)

Кнопка-фильтр с тремя состояниями (default, softActive, active) и опциональной цветной точкой.

Пропсы

interface FilterChipProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  active?: boolean;          // Полностью активен (accent background)
  softActive?: boolean;      // Мягко активен (accent-soft background)
  dotTone?: 'accent' | 'sage' | 'warning' | 'muted';  // Предустановленный цвет точки
  dotColor?: string;         // Произвольный цвет точки (CSS color)
  icon?: ReactNode;          // Иконка перед текстом
}

Код

export default function FilterChip({
  active = false, softActive = false, dotTone, dotColor, icon,
  className = '', children, ...props
}: FilterChipProps) {
  const resolvedClassName = [
    s.chip, softActive ? s.softActive : '', !softActive && active ? s.active : '', className,
  ].filter(Boolean).join(' ');

  return (
    <button type="button" className={resolvedClassName} {...props}>
      {dotTone || dotColor ? (
        <span className={`${s.dot} ${dotTone ? dotToneClass[dotTone] : ''}`}
          style={dotColor ? { background: dotColor } : undefined} aria-hidden="true" />
      ) : null}
      {icon ? <span className={s.icon} aria-hidden="true">{icon}</span> : null}
      <span>{children}</span>
    </button>
  );
}

CSS (FilterChip.module.css)

.chip {
  display: inline-flex; align-items: center; gap: 8px;
  min-height: var(--pl-control-height-sm); padding: 0 14px;
  border-radius: var(--pl-radius-pill);
  border: 1px solid var(--pl-color-border);
  background: var(--pl-color-surface-alt);
  font-size: var(--pl-font-size-caption); font-weight: 600; white-space: nowrap; cursor: pointer;
}
.active {
  background: var(--pl-color-accent); border-color: var(--pl-color-accent);
  color: var(--pl-color-text-inverse);
}
.softActive {
  background: var(--pl-color-accent-soft); border-color: rgba(209, 138, 98, 0.22);
}
.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dotAccent { background: var(--pl-color-accent); }
.dotSage { background: var(--pl-color-chip-dot); }
.dotWarning { background: var(--pl-color-warning); }
.dotMuted { background: rgba(31, 27, 22, 0.3); }
.active .dot { background: rgba(255, 253, 252, 0.72); }

BottomSheet

Файл: BottomSheet.tsx (26 строк)

Обёртка над Ant Design Drawer для отображения модального окна снизу экрана.

Пропсы

Принимает все DrawerProps из Ant Design. Дефолты: - placement: 'bottom' - height: 'auto' - closeIcon: null

Код

import { Drawer } from 'antd';
import type { DrawerProps } from 'antd';

export default function BottomSheet({
  className = '', rootClassName = '', placement, height, closeIcon, ...props
}: DrawerProps) {
  return (
    <Drawer
      placement={placement ?? 'bottom'}
      height={height ?? 'auto'}
      closeIcon={closeIcon ?? null}
      className={['bottom-sheet', className].filter(Boolean).join(' ')}
      rootClassName={['bottom-sheet-root', rootClassName].filter(Boolean).join(' ')}
      {...props}
    />
  );
}

EmptyState

Файл: EmptyState.tsx (31 строка)

Компонент пустого состояния: иконка, заголовок, описание, опциональное действие.

Пропсы

interface EmptyStateProps {
  icon?: ReactNode;      // Иконка (обычно Lucide)
  title: string;         // Заголовок
  description?: string;  // Описание
  action?: ReactNode;    // Кнопка действия
  className?: string;
}

Код

export default function EmptyState({ icon, title, description, action, className = '' }: EmptyStateProps) {
  return (
    <div className={[s.root, className].filter(Boolean).join(' ')}>
      {icon ? <div className={s.iconWrap}>{icon}</div> : null}
      <div className={s.copy}>
        <h2 className={s.title}>{title}</h2>
        {description ? <p className={s.description}>{description}</p> : null}
      </div>
      {action ? <div className={s.action}>{action}</div> : null}
    </div>
  );
}

AuthShell

Файл: AuthShell.tsx (45 строк)

Каркас страниц авторизации с hero-секцией, карточкой формы и футером.

Пропсы

interface AuthShellProps {
  icon: ReactNode;          // Иконка в badge
  title: string;            // Заголовок (display шрифт)
  description: string;      // Описание под заголовком
  tone?: 'warm' | 'sage';  // Цветовая тема hero (warm=оранжевый, sage=зелёный)
  children: ReactNode;      // Содержимое карточки (форма)
  footer?: ReactNode;       // Футер (ссылка на регистрацию/логин)
}

Код

export default function AuthShell({ icon, title, description, tone = 'warm', children, footer }: AuthShellProps) {
  return (
    <div className={s.page}>
      <div className={`app-layout ${s.layout}`}>
        <div className={`${s.shell} ${toneClass[tone]}`}>
          <section className={s.hero}>
            <div className={s.badge} aria-hidden="true">{icon}</div>
            <h1 className={s.title}>{title}</h1>
            <p className={s.description}>{description}</p>
          </section>
          <section className={`${s.card} auth-shell__card`}>{children}</section>
          {footer ? <div className={s.footer}>{footer}</div> : null}
        </div>
      </div>
    </div>
  );
}

CSS (AuthShell.module.css)

.page { min-height: 100dvh; background: var(--pl-color-bg); }
.shell {
  display: flex; flex-direction: column; gap: var(--pl-space-4);
  min-height: 100dvh; padding: calc(30px + env(safe-area-inset-top, 0px)) 24px 24px;
}
.hero { border-radius: 32px; padding: 18px; display: flex; flex-direction: column; gap: 10px; }
.warm .hero { background: linear-gradient(180deg, #fff4ea 0%, #f0e5db 100%); }
.sage .hero { background: linear-gradient(180deg, #f3f8ee 0%, #e7eee0 100%); }
.badge {
  width: 48px; height: 48px; border-radius: 50%;
  background: #FFF9F3; color: var(--pl-color-accent);
}
.title { font-family: var(--pl-font-display); font-size: var(--pl-font-size-display); font-weight: 700; }
.card {
  border-radius: var(--pl-radius-sheet); background: var(--pl-color-surface);
  box-shadow: var(--pl-shadow-floating); padding: 18px;
}
.footer { margin-top: auto; padding-bottom: env(safe-area-inset-bottom, 0px); }

ActionIconButton

Файл: ActionIconButton.tsx (33 строки)

Круглая кнопка 40x40 с иконкой. Два тона: accent (залитый) и surface (полупрозрачный с blur).

Пропсы

interface ActionIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  icon: ReactNode;                        // Иконка (Lucide)
  tone?: 'accent' | 'surface';           // default: 'surface'
}

Код

export default function ActionIconButton({ icon, tone = 'surface', className = '', children, ...props }) {
  return (
    <button type="button" className={[s.btn, toneClass[tone], className].filter(Boolean).join(' ')} {...props}>
      <span className={s.icon} aria-hidden="true">{icon}</span>
      {children ? <span className="sr-only">{children}</span> : null}
    </button>
  );
}

CSS

.btn {
  width: 40px; height: 40px; border: 1px solid transparent; border-radius: var(--pl-radius-pill);
  box-shadow: var(--pl-shadow-floating); transition: transform 0.15s;
}
.btn:active { transform: scale(0.96); }
.btnAccent { background: var(--pl-color-accent); color: var(--pl-color-text-inverse); }
.btnSurface { background: rgba(255, 253, 252, 0.85); backdrop-filter: blur(12px); }

TextField

Файл: TextField.tsx (55 строк)

Обёртка над Ant Design Form.Item + Input с единообразным стилем.

Пропсы

interface TextFieldProps {
  name: NamePath;
  label: string;
  rules?: Rule[];
  placeholder?: string;
  autoComplete?: string;
  disabled?: boolean;
  password?: boolean;       // true → рендерит Input.Password
  type?: InputProps['type'];
  formItemProps?: Omit<FormItemProps, 'name' | 'label' | 'rules' | 'children'>;
}

SelectField

Файл: SelectField.tsx (32 строки)

Обёртка над Ant Design Form.Item + Select.

Пропсы

interface SelectFieldProps {
  name: NamePath;
  label: string;
  rules?: Rule[];
  formItemProps?: Omit<FormItemProps, 'name' | 'label' | 'rules' | 'children'>;
  selectProps?: SelectProps;   // Все стандартные пропсы Ant Select
}

PrimaryButton / SecondaryButton / DangerButton

Файлы: PrimaryButton.tsx, SecondaryButton.tsx, DangerButton.tsx (по 9 строк)

Тонкие обёртки над Ant Design Button с предустановленными CSS-классами.

// PrimaryButton
export default function PrimaryButton({ className = '', ...props }: ButtonProps) {
  return <Button type="primary" size="large" className={['ui-button', 'ui-button--primary', className].filter(Boolean).join(' ')} {...props} />;
}

// SecondaryButton — аналогично без type="primary"
// DangerButton — аналогично с danger={true}

SkeletonCard

Файл: SkeletonCard.tsx (42 строки)

Skeleton-лоадер карточки с анимацией shimmer.

Пропсы

interface SkeletonCardProps {
  imageHeight?: number | string;           // Высота зоны изображения
  lines?: number;                          // Количество текстовых строк (default: 2)
  className?: string;
  mediaLayout?: 'square' | 'fixed-height'; // square → aspect-ratio 1:1
}

CSS (ключевая анимация)

.media, .line {
  background: linear-gradient(90deg, rgba(240,220,206,0.7) 0%, rgba(255,253,252,0.88) 50%, rgba(240,220,206,0.7) 100%);
  background-size: 200% 100%;
  animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

SharedAccessRow

Файл: SharedAccessRow.tsx (65 строк)

Строка с thumbnail, текстом и кнопкой действия. Используется в SharedOutfitCard.

Пропсы

interface SharedAccessRowProps {
  title: string;
  stats: string;               // "5 просмотров · 2 забрали"
  imageUrl?: string;
  imageAlt?: string;
  onOpen?: () => void;         // Клик по thumbnail (navigate)
  onAction?: () => void;       // Клик по кнопке действия (revoke)
  actionIcon: ReactNode;       // Иконка кнопки (Link2Off)
  actionLabel: string;         // aria-label для кнопки
  actionDisabled?: boolean;
}

OfflineBanner

Файл: OfflineBanner.tsx (47 строк)

Баннер в верхней части экрана при отсутствии сети. Показывает статус и количество ожидающих синхронизации операций.

Логика

export default function OfflineBanner() {
  const online = useNetworkStatus();
  const pendingCount = usePendingCount();

  // Сдвигает Ant message компонент вниз на 52px при появлении баннера
  useEffect(() => {
    const el = document.documentElement;
    if (!online) el.style.setProperty('--pl-message-top', '52px');
    else el.style.removeProperty('--pl-message-top');
    return () => { el.style.removeProperty('--pl-message-top'); };
  }, [online]);

  if (online) return null;

  return (
    <div className={s.bar} role="status" aria-live="polite">
      <WifiOff size={16} />
      <span className={s.label}>Офлайн</span>
      {pendingCount > 0 && <span className={s.sync}>{pendingCount} ожидают синхронизации</span>}
    </div>
  );
}

OfflineUnavailable

Файл: OfflineUnavailable.tsx (22 строки)

Полноэкранный placeholder «Нет подключения» с иконкой WifiOff.

Пропсы

interface OfflineUnavailableProps {
  description?: string;  // default: 'Подключитесь к интернету, чтобы увидеть этот раздел.'
}

OfflineErrorBoundary

Файл: OfflineErrorBoundary.tsx (57 строк)

Error Boundary (class component), показывающий OfflineUnavailable при ошибке рендера в offline. Автоматически сбрасывается при восстановлении сети.

Код

export default class OfflineErrorBoundary extends Component<Props, State> {
  private _unsubscribe: (() => void) | null = null;
  state: State = { hasError: false };

  static getDerivedStateFromError(): State { return { hasError: true }; }

  componentDidMount() {
    // Подписка на изменение сетевого статуса
    this._unsubscribe = networkStatus.subscribe((online) => {
      if (online && this.state.hasError) this.setState({ hasError: false });
    });
  }

  render() {
    if (this.state.hasError) {
      return <OfflineUnavailable description={
        networkStatus.online
          ? 'Произошла ошибка. Попробуйте обновить страницу.'
          : this.props.fallbackDescription ?? 'Подключитесь к интернету, чтобы загрузить данные.'
      } />;
    }
    return this.props.children;
  }
}

UpdateAvailableToast

Файл: UpdateAvailableToast.tsx (16 строк)

Уведомление о доступном обновлении PWA. Fixed-позиция внизу экрана, появляется с анимацией slideUp.

Пропсы

interface UpdateAvailableToastProps {
  onUpdate: () => void;  // Колбек при нажатии «Обновить»
}

PendingChangesBadge

Файл: PendingChangesBadge.tsx (25 строк)

Бейдж с количеством операций в очереди синхронизации. Скрывается при count === 0.

Код

export default function PendingChangesBadge() {
  const count = usePendingCount();
  if (count === 0) return null;
  return (
    <span className={s.badge}>
      <CloudUpload size={14} strokeWidth={2.2} />
      {count} {pluralize(count)}
    </span>
  );
}