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.
Пропсы¶
PendingChangesBadge¶
Файл: PendingChangesBadge.tsx (25 строк)
Бейдж с количеством операций в очереди синхронизации. Скрывается при count === 0.