Files
pipu-music/apps/web/src/entities/catalog.tsx
T
mrleo1nidandClaude Opus 5.5 de4e59d9f8
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 31s
ci / ts / ts (packages + web) (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 44s
fix(web): accessibility pass to WCAG 2.1 AA (M7)
An axe-core audit of every screen in both themes found low contrast on white
text over the accent and on caption text, track rows using row roles without
cells, and unlabelled progress bars. Accent fills now use a darker token,
caption text meets 4.5:1, track lists are lists and progress bars are labelled.
The queue drawer slides in from the right as designed, takes focus, closes on
Escape and notes that the wave never ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:15:03 +03:00

233 lines
9.0 KiB
TypeScript

import type { components } from '@pipu/api-client';
import { Link } from '@tanstack/react-router';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css';
export type Track = components['schemas']['Track'];
export type AlbumSummary = components['schemas']['AlbumSummary'];
export type ArtistRef = components['schemas']['ArtistRef'];
const WAVE = ['#2F80FF', '#7C5CFF', '#35D3FF'];
/** Детерминированная палитра по id, пока у альбома нет своей обложки. */
function fallbackColors(id: string): string[] {
let h = 0;
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
const base = h % 360;
return [`hsl(${base} 70% 55%)`, `hsl(${(base + 50) % 360} 65% 45%)`, `hsl(${(base + 110) % 360} 70% 60%)`];
}
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
export function coverSrc(url: string, cssPx: number): string {
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`;
}
/** Обложка альбома; без картинки — градиент из палитры обложки или из id. */
export function Cover({
album,
size = 48,
round,
className,
}: {
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
size?: number | string;
round?: boolean;
className?: string;
}) {
const colors = album.cover_colors?.length === 3 ? album.cover_colors : album.id ? fallbackColors(album.id) : WAVE;
return (
<span
className={cx(s.cover, round && s.round, className)}
style={{
width: size,
// Резиновая обложка (size="100%") — высоту задаёт aspect-ratio карточки.
height: typeof size === 'number' ? size : undefined,
background: `radial-gradient(circle at 30% 25%, ${colors[2]}, transparent 60%), linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
}}
aria-hidden
>
{album.cover_url ? (
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
) : (
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
)}
</span>
);
}
/** Исполнители через запятую, каждый — ссылка. */
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
return (
<>
{artists.map((a, i) => (
<Fragment key={a.id}>
{i > 0 && ', '}
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
{a.name}
</Link>
</Fragment>
))}
</>
);
}
export function AlbumCard({ album }: { album: AlbumSummary }) {
return (
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
<Cover album={album} size="100%" className={s.cardCover} />
<span className={s.cardTitle}>{album.title}</span>
<span className={s.cardMeta}>
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
</span>
</Link>
);
}
export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
return (
<div className={s.grid}>
{albums.map((a) => (
<AlbumCard key={a.id} album={a} />
))}
</div>
);
}
/**
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры.
*/
export function TrackList({
tracks,
showAlbum,
numbered,
context,
rowMeta,
lead,
disliked,
}: {
tracks: Track[];
showAlbum?: boolean;
numbered?: boolean;
context: PlayContext;
/** Дополнительный текст под исполнителями. */
rowMeta?: (t: Track, i: number) => string | undefined;
/** Первая колонка перед обложкой (docs/design: время в истории, номер в лайках); у текущего трека — эквалайзер. */
lead?: (t: Track, i: number) => string;
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
disliked?: boolean;
}) {
const { t } = useTranslation();
const player = usePlayer();
const { track: current, playing } = usePlayerState();
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null;
return (
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc;
lastDisc = disc;
const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context);
const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i);
return (
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (!(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
>
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
<span className={cx(s.num, disliked && s.dimmed)}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={cx(s.trackMain, disliked && s.dimmed)}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
{meta && <span className={s.rowMeta}> · {meta}</span>}
</span>
</span>
{showAlbum && (
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
{tr.album.title}
</Link>
)}
<span className={s.end}>
{disliked ? (
<button
type="button"
className={cx(s.like, s.liked)}
aria-label={t('player.undislike')}
onClick={() => void player.react(tr, null).catch(() => undefined)}
>
<Icon src={undoIcon} size={18} />
</button>
) : (
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
</div>
</Fragment>
);
})}
</div>
);
}
/** Эквалайзер текущего трека (`pipuEq`, docs/design.md#2): три столбика, на паузе замирают. */
export function Equalizer({ playing }: { playing: boolean }) {
return (
<span className={cx(s.eq, !playing && s.eqPaused)} aria-hidden>
<span />
<span />
<span />
</span>
);
}
export function ArtistChip({ artist }: { artist: ArtistRef }) {
const { t } = useTranslation();
return (
<Link to="/artist/$id" params={{ id: artist.id }} className={s.artistChip}>
<Cover album={{ id: artist.id }} size={88} round />
<span className={s.cardTitle}>{artist.name}</span>
<span className={s.cardMeta}>{t('search.artist_kind')}</span>
</Link>
);
}