feat(web): align the web UI with the design prototype
- Ambient background in the app shell: three drifting blurred blobs,
the third one tinted with the current track's cover; can be turned off
in settings and stays still with reduced motion.
- Island player variant (floating glass panel) next to the bar, chosen
in settings; player source line ("Album · …", "My Wave"), reactions
around the transport controls, queue drawer.
- Sidebar: like / dislike counters, pending uploads badge on Queue,
admin Settings page holding the upload queue limits.
- Collection: header tile with kicker, underline tabs, time / number
lead column with an equalizer for the current track, album column,
dimmed dislikes with undo, Play all / Shuffle pills.
- Album: cover-tinted header and Play / Shuffle; artist: artist wave,
Play and popular tracks (new GET /artists/{id}/top-tracks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3afed02f23
commit
1c4e209e86
29 files changed
+1381
-339
No files matched your search
@@ -110,6 +110,56 @@
|
||||
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.track:has(.lead) {
|
||||
grid-template-columns: 44px 44px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.track:has(.lead):has(.trackAlbum) {
|
||||
grid-template-columns: 44px 44px minmax(0, 1.4fr) minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.lead {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dimmed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.eq {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: flex-end;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.eq span {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent2);
|
||||
transform-origin: bottom;
|
||||
animation: pipuEq 0.55s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.eq span:nth-child(2) {
|
||||
animation-duration: 0.72s;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.eq span:nth-child(3) {
|
||||
animation-duration: 0.89s;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.eqPaused span {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.track:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 } from '../shared/ui/icons';
|
||||
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
import s from './catalog.module.css';
|
||||
|
||||
@@ -112,17 +112,23 @@ export function TrackList({
|
||||
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 current = usePlayerState().track;
|
||||
const { track: current, playing } = usePlayerState();
|
||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||
let lastDisc: number | null = null;
|
||||
return (
|
||||
@@ -151,15 +157,16 @@ export function TrackList({
|
||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||
}}
|
||||
>
|
||||
<span className={s.num}>
|
||||
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}
|
||||
{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={s.trackMain}>
|
||||
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
||||
<span className={s.trackTitle}>{tr.title}</span>
|
||||
<span className={s.trackArtists}>
|
||||
<ArtistLinks artists={tr.artists} />
|
||||
@@ -172,15 +179,26 @@ export function TrackList({
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.end}>
|
||||
<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>
|
||||
{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>
|
||||
@@ -191,6 +209,17 @@ export function TrackList({
|
||||
);
|
||||
}
|
||||
|
||||
/** Эквалайзер текущего трека (`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 (
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
|
||||
/** Сколько лайков и дизлайков — счётчики в меню (total из первой страницы списка). */
|
||||
export function useCollectionCounts() {
|
||||
const session = useSession();
|
||||
const total = (path: '/api/v1/me/likes' | '/api/v1/me/dislikes') => ({
|
||||
queryKey: ['collection-count', path],
|
||||
queryFn: async () => unwrap(await session.client.GET(path, { params: { query: { limit: 1 } } })).total,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const likes = useQuery(total('/api/v1/me/likes'));
|
||||
const dislikes = useQuery(total('/api/v1/me/dislikes'));
|
||||
return { likes: likes.data, dislikes: dislikes.data };
|
||||
}
|
||||
Reference in new issue
Block a user