feat(web): polish catalog motion, empty states, and mobile now-playing
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
39aaaed969
commit
72567acd28
31 files changed
+939
-131
No files matched your search
@@ -8,12 +8,18 @@
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.cover img {
|
||||
.coverImg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
.coverImgOn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.round {
|
||||
@@ -59,10 +65,52 @@
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
.cardCoverWrap {
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.cardCover {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 4px;
|
||||
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.card:hover .cardCover,
|
||||
.card:focus-within .cardCover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.cardPlay {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #05070d;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.card:hover .cardPlay,
|
||||
.card:focus-within .cardPlay,
|
||||
.cardPlay:focus-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.cardPlay:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
@@ -104,6 +152,12 @@
|
||||
min-height: 56px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
animation: pipuIn 0.2s ease-out both;
|
||||
animation-delay: var(--stagger, 0ms);
|
||||
}
|
||||
|
||||
.trackStatic {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.track:has(.trackAlbum) {
|
||||
@@ -272,6 +326,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.cardPlay { opacity: 1; transform: none; }
|
||||
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
|
||||
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Fragment, useRef, type ReactNode } from 'react';
|
||||
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
|
||||
import { AddToPlaylist } from './playlists';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import type { PlayContext } from '../player/player';
|
||||
@@ -59,7 +61,7 @@ export function Cover({
|
||||
aria-hidden
|
||||
>
|
||||
{album.cover_url ? (
|
||||
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
|
||||
<CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
|
||||
) : (
|
||||
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
||||
)}
|
||||
@@ -83,10 +85,39 @@ export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CoverImg({ src }: { src: string }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className={cx(s.coverImg, ready && s.coverImgOn)}
|
||||
onLoad={() => setReady(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const play = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void (async () => {
|
||||
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
|
||||
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
|
||||
})();
|
||||
};
|
||||
return (
|
||||
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
||||
<Cover album={album} size="100%" className={s.cardCover} />
|
||||
<span className={s.cardCoverWrap}>
|
||||
<Cover album={album} size="100%" className={s.cardCover} />
|
||||
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
|
||||
<Icon src={playIcon} size={22} />
|
||||
</button>
|
||||
</span>
|
||||
<span className={s.cardTitle}>{album.title}</span>
|
||||
<span className={s.cardMeta}>
|
||||
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
||||
@@ -155,7 +186,8 @@ export function TrackList({
|
||||
role="listitem"
|
||||
tabIndex={tr.available ? 0 : -1}
|
||||
aria-current={isCurrent || undefined}
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
|
||||
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
|
||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||
onClick={(e) => {
|
||||
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
||||
|
||||
Reference in new issue
Block a user