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
@@ -291,7 +291,7 @@ describe('player', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
await user.click(screen.getByRole('listitem'));
|
||||
await user.click(screen.getByRole('button', { name: 'Слушать' }));
|
||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
||||
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,12 +4,12 @@ import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover, TrackList } from '../entities/catalog';
|
||||
import { Cover, TrackList, coverSrc } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import { Button } from '../shared/ui/ui';
|
||||
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||
import { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
@@ -23,30 +23,41 @@ export function AlbumPage({ id }: { id: string }) {
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
||||
});
|
||||
|
||||
if (album.isPending) {
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<PageSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (album.isError) return <NotFound error={album.error} />;
|
||||
const a = album.data;
|
||||
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
|
||||
const ctx = { type: 'album' as const, id, label: a?.title };
|
||||
if (!a) return null;
|
||||
const colors = a.cover_colors.length === 3 ? a.cover_colors : null;
|
||||
const ctx = { type: 'album' as const, id, label: a.title };
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
{a.cover_url && (
|
||||
<span className={c.heroBg} aria-hidden>
|
||||
<img src={coverSrc(a.cover_url, 300)} alt="" />
|
||||
</span>
|
||||
)}
|
||||
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
||||
<Cover album={a ?? { id }} size={200} />
|
||||
<Cover album={a} size={200} className={c.heroCover} />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('catalog.album')}</span>
|
||||
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
|
||||
{a && (
|
||||
<span className={c.heroMeta}>
|
||||
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
||||
{a.artist.name}
|
||||
</Link>
|
||||
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
||||
</span>
|
||||
)}
|
||||
{a && a.tracks.some((tr) => tr.available) && (
|
||||
<h1 className={c.heroTitle}>{a.title}</h1>
|
||||
<span className={c.heroMeta}>
|
||||
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
||||
{a.artist.name}
|
||||
</Link>
|
||||
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
||||
</span>
|
||||
{a.tracks.some((tr) => tr.available) && (
|
||||
<div className={c.heroActions}>
|
||||
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} />
|
||||
<AddToPlaylist trackIds={a.tracks.map((tr) => tr.id)} />
|
||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||
{t('catalog.play')}
|
||||
</Button>
|
||||
@@ -67,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
|
||||
<TrackList tracks={a.tracks} numbered context={ctx} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,11 @@ export function AppDownloadPage() {
|
||||
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
|
||||
<h1>{t('app.title')}</h1>
|
||||
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||
{latest.isPending ? null : data ? (
|
||||
{latest.isPending ? (
|
||||
<p role="status">{t('common:loading')}</p>
|
||||
) : latest.isError ? (
|
||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{primary ? (
|
||||
<>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
|
||||
import { AlbumGrid, Cover, TrackList, coverSrc } from '../entities/catalog';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { playIcon, waveIcon } from '../shared/ui/icons';
|
||||
import { Button } from '../shared/ui/ui';
|
||||
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||
import { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
@@ -29,42 +29,51 @@ export function ArtistPage({ id }: { id: string }) {
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||
});
|
||||
|
||||
if (artist.isPending) {
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<PageSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (artist.isError) return <NotFound error={artist.error} />;
|
||||
const a = artist.data;
|
||||
if (!a) return null;
|
||||
const tracks = top.data ?? [];
|
||||
const ctx = { type: 'artist' as const, id, label: a?.name };
|
||||
const ctx = { type: 'artist' as const, id, label: a.name };
|
||||
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
||||
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
<Cover album={{ id, cover_url: a?.image_url }} size={168} round />
|
||||
{a.image_url && (
|
||||
<span className={c.heroBg} aria-hidden>
|
||||
<img src={coverSrc(a.image_url, 300)} alt="" />
|
||||
</span>
|
||||
)}
|
||||
<Cover album={{ id, cover_url: a.image_url }} size={168} round className={c.heroCover} />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('catalog.artist')}</span>
|
||||
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
||||
{a && (
|
||||
<span className={c.heroMeta}>
|
||||
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
||||
</span>
|
||||
)}
|
||||
{a && (
|
||||
<div className={c.heroActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={c.pill}
|
||||
icon={waveIcon}
|
||||
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
||||
>
|
||||
{t('catalog.artist_wave')}
|
||||
<h1 className={c.heroTitle}>{a.name}</h1>
|
||||
<span className={c.heroMeta}>
|
||||
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
||||
</span>
|
||||
<div className={c.heroActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={c.pill}
|
||||
icon={waveIcon}
|
||||
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
||||
>
|
||||
{t('catalog.artist_wave')}
|
||||
</Button>
|
||||
{tracks.length > 0 && (
|
||||
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
||||
{t('catalog.play')}
|
||||
</Button>
|
||||
{tracks.length > 0 && (
|
||||
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
||||
{t('catalog.play')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{tracks.length > 0 && (
|
||||
|
||||
@@ -2,16 +2,21 @@ import type { ReactNode } from 'react';
|
||||
|
||||
import { Logo } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||
import { WaveButton } from '../widgets/wave/WaveButton';
|
||||
import s from './auth.module.css';
|
||||
|
||||
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
|
||||
/** Экран без каркаса: декоративная волна и стеклянная карточка по центру. */
|
||||
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||
const theme = typeof document !== 'undefined' && document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
||||
return (
|
||||
<div className={s.screen}>
|
||||
<div className={s.backdrop} aria-hidden>
|
||||
<span className={s.blob1} />
|
||||
<span className={s.blob2} />
|
||||
<span className={s.blob3} />
|
||||
<div className={s.waveDecor}>
|
||||
<WaveButton interactive={false} size={620} label="" theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
<header className={s.top}>
|
||||
<Logo />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useCollectionCounts } from '../entities/collection';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
|
||||
import { Button, Dialog, EmptyState, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
||||
import c from './catalog.module.css';
|
||||
import s from './page.module.css';
|
||||
|
||||
@@ -109,7 +109,12 @@ function History() {
|
||||
</div>
|
||||
)}
|
||||
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
|
||||
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
|
||||
{history.isPending && <PageSkeleton />}
|
||||
{history.isSuccess && items.length === 0 && (
|
||||
<EmptyState icon={historyFillIcon} action={<Link to="/">{t('catalog.back_home')}</Link>}>
|
||||
{t('collection.empty_history')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<section key={g.key} className={c.section}>
|
||||
<h2 className={c.day}>{g.title}</h2>
|
||||
@@ -189,8 +194,18 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
||||
</div>
|
||||
)}
|
||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||
{list.isPending && <PageSkeleton />}
|
||||
{list.isSuccess && tracks.length === 0 && (
|
||||
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
|
||||
<EmptyState
|
||||
icon={reaction === 'like' ? likeFillIcon : dislikeFillIcon}
|
||||
action={
|
||||
<Link to="/search" search={{ q: '' }}>
|
||||
{t('playlists.find_tracks')}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{tracks.length > 0 && (
|
||||
<TrackList
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
waveSunIcon,
|
||||
waveWeekendIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { cx, FormError, Icon } from '../shared/ui/ui';
|
||||
import { cx, FormError, Icon, Skeleton } from '../shared/ui/ui';
|
||||
import { WaveButton } from '../widgets/wave/WaveButton';
|
||||
import s from './home.module.css';
|
||||
import { Onboarding } from './Onboarding';
|
||||
@@ -54,6 +54,13 @@ export function HomePage() {
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
if (onboarding.isPending) {
|
||||
return (
|
||||
<div className={s.home}>
|
||||
<Skeleton round style={{ width: 320, height: 320, maxWidth: '70vw', maxHeight: '70vw' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (onboarding.data?.required) {
|
||||
return (
|
||||
<Onboarding
|
||||
@@ -136,6 +143,12 @@ function WaveHome() {
|
||||
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
|
||||
<FormError>{error ? errorText(error) : null}</FormError>
|
||||
|
||||
{options.isPending && (showActivities || showMoods) && (
|
||||
<div className={s.picker}>
|
||||
<Skeleton style={{ width: 280, height: 38, borderRadius: 999 }} />
|
||||
<Skeleton style={{ width: 240, height: 38, borderRadius: 999 }} />
|
||||
</div>
|
||||
)}
|
||||
{options.data && (showActivities || showMoods) && (
|
||||
<div className={s.picker}>
|
||||
{showActivities && (
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover, TrackList } from '../entities/catalog';
|
||||
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
|
||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
||||
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import c from './catalog.module.css';
|
||||
import s from './page.module.css';
|
||||
import p from './playlists.module.css';
|
||||
@@ -27,13 +28,16 @@ export function PlaylistsPage() {
|
||||
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
|
||||
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
|
||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
|
||||
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||||
<Cover album={item} size="100%" />
|
||||
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||||
{item.description && <span className={p.description}>{item.description}</span>}
|
||||
</Link>)}</div>
|
||||
{list.isPending && <PageSkeleton />}
|
||||
{list.isSuccess && !items.length && (
|
||||
<EmptyState
|
||||
icon={search ? searchIcon : playlistAddIcon}
|
||||
action={!search ? <Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button> : undefined}
|
||||
>
|
||||
{t(search ? 'playlists.no_results' : 'playlists.empty')}
|
||||
</EmptyState>
|
||||
)}
|
||||
<div className={p.grid}>{items.map(item => <PlaylistCard key={item.id} item={item} />)}</div>
|
||||
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -45,7 +49,7 @@ export function PlaylistPage({ id }: { id: string }) {
|
||||
return <div className={s.page}>
|
||||
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{query.isPending && <PageSkeleton />}
|
||||
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -93,7 +97,11 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
||||
</div>
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
|
||||
{!playlist.tracks.length && (
|
||||
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
|
||||
{t('playlists.empty_tracks')}
|
||||
</EmptyState>
|
||||
)}
|
||||
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||||
actions={manage ? (track, i) => <span className={p.actions}>
|
||||
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
||||
@@ -101,9 +109,52 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
||||
</span> : undefined} />
|
||||
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
|
||||
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||||
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||||
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
|
||||
{remove && (
|
||||
<Dialog
|
||||
title={t('playlists.delete')}
|
||||
onClose={() => {
|
||||
if (!deletion.isPending) setRemove(false);
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||||
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>{t('playlists.delete_confirm', { name: playlist.name })}</p>
|
||||
<FormError>{deletion.error ? errorText(deletion.error) : null}</FormError>
|
||||
</Dialog>
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
|
||||
function PlaylistCard({ item }: { item: components['schemas']['PlaylistSummary'] }) {
|
||||
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/me/playlists/{id}', { params: { path: { id: item.id } } }));
|
||||
const tracks = full.tracks.filter((x) => x.available);
|
||||
if (tracks.length) player.playList(tracks, 0, { type: 'collection', id: item.id, label: item.name });
|
||||
})();
|
||||
};
|
||||
return (
|
||||
<Link to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||||
<span className={p.coverWrap}>
|
||||
<Cover album={item} size="100%" />
|
||||
{item.track_count > 0 && (
|
||||
<button type="button" className={p.play} aria-label={t('catalog.play')} onClick={play}>
|
||||
<Icon src={playIcon} size={22} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||||
{item.description && <span className={p.description}>{item.description}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, FormError } from '../shared/ui/ui';
|
||||
import { searchIcon } from '../shared/ui/icons';
|
||||
import { Button, EmptyState, FormError, PageSkeleton } from '../shared/ui/ui';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
|
||||
@@ -68,7 +69,11 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) {
|
||||
</div>
|
||||
|
||||
<FormError>{results.error ? errorText(results.error) : null}</FormError>
|
||||
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
|
||||
{query === '' && <EmptyState icon={searchIcon}>{t('search.type_query')}</EmptyState>}
|
||||
{query !== '' && results.isPending && <PageSkeleton />}
|
||||
{empty && (
|
||||
<EmptyState icon={searchIcon}>{t('search.nothing')}</EmptyState>
|
||||
)}
|
||||
|
||||
{artists.length > 0 && (
|
||||
<section className={c.section}>
|
||||
|
||||
@@ -4,8 +4,12 @@ import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
|
||||
import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { pauseIcon, playIcon } from '../shared/ui/icons';
|
||||
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||
import { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
@@ -14,27 +18,63 @@ import { TrackGenius } from './TrackGenius';
|
||||
export function TrackPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const track = useQuery({
|
||||
queryKey: ['track', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
|
||||
});
|
||||
if (track.isPending) {
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<PageSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (track.isError) return <NotFound error={track.error} />;
|
||||
const tr = track.data;
|
||||
if (!tr) return null;
|
||||
const ctx = { type: 'queue' as const };
|
||||
const current = st.track?.id === tr.id;
|
||||
const playing = current && st.playing;
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
{tr && <Cover album={tr.album} size={200} />}
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('player.track_page')}</span>
|
||||
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
|
||||
{tr && <>
|
||||
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
|
||||
</>}
|
||||
<div className={c.listen}>
|
||||
<header className={c.hero}>
|
||||
{tr.album.cover_url && (
|
||||
<span className={c.heroBg} aria-hidden>
|
||||
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
|
||||
</span>
|
||||
)}
|
||||
<Cover album={tr.album} size={280} className={c.heroCover} />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('player.track_page')}</span>
|
||||
<h1 className={c.heroTitle}>{tr.title}</h1>
|
||||
<span className={c.heroMeta}>
|
||||
<ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}
|
||||
</span>
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }}>
|
||||
{tr.album.title}
|
||||
</Link>
|
||||
<div className={c.heroActions}>
|
||||
{tr.available && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className={c.pill}
|
||||
icon={playing ? pauseIcon : playIcon}
|
||||
onClick={() => (current ? player.toggle() : player.playList([tr], 0, ctx))}
|
||||
>
|
||||
{t(playing ? 'player.pause' : 'catalog.play')}
|
||||
</Button>
|
||||
)}
|
||||
<AddToPlaylist trackIds={[tr.id]} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className={c.listenLyrics}>
|
||||
<TrackGenius id={id} />
|
||||
</div>
|
||||
</header>
|
||||
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
|
||||
{tr && <TrackGenius id={id} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { formatDateTime } from '../../shared/lib/format';
|
||||
import { Button, FormError } from '../../shared/ui/ui';
|
||||
import { Button, EmptyState, FormError, PageSkeleton } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
|
||||
type Entry = components['schemas']['AuditEntry'];
|
||||
@@ -67,8 +67,8 @@ export function AuditPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>}
|
||||
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
|
||||
{list.isSuccess && entries.length === 0 && <EmptyState>{t('audit.empty')}</EmptyState>}
|
||||
{list.isPending && <PageSkeleton />}
|
||||
</div>
|
||||
{list.hasNextPage && (
|
||||
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSession, useUser } from '../../app/session';
|
||||
import { GeniusPanel } from './GeniusPanel';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
|
||||
import { Button, Dialog, EmptyState, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import l from './library.module.css';
|
||||
import { ScanLine, StatusBadge } from './ScanStatus';
|
||||
@@ -46,7 +46,11 @@ export function SourcesPage() {
|
||||
</div>
|
||||
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
|
||||
{user.role === 'admin' && <GeniusPanel />}
|
||||
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
|
||||
{sources.isSuccess && sources.data.length === 0 && (
|
||||
<EmptyState icon={folderIcon} action={<Button variant="primary" onClick={() => setEditing('new')}>{t('sources.add')}</Button>}>
|
||||
{t('sources.empty')}
|
||||
</EmptyState>
|
||||
)}
|
||||
<div className={l.list}>
|
||||
{sources.data?.map((src) => (
|
||||
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useSession } from '../../app/session';
|
||||
import { needsFormatAttention } from '../../features/uploads/files';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
|
||||
import { checkIcon, closeIcon, editIcon, inboxIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
import { Button, cx, Dialog, EmptyState, Field, FormError, Icon } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
|
||||
import u from '../uploads/uploads.module.css';
|
||||
@@ -123,7 +123,7 @@ export function UploadQueuePage() {
|
||||
{failed.length > 0 && (
|
||||
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
||||
)}
|
||||
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
|
||||
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
|
||||
|
||||
{batches.map((files) => (
|
||||
<BatchCard
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatRelative } from '../../shared/lib/format';
|
||||
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
||||
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
|
||||
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
|
||||
import { Avatar, Button, cx, Dialog, EmptyState, Field, FormError, Icon, Menu, PageSkeleton, type MenuItem } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
|
||||
type AdminUser = components['schemas']['AdminUser'];
|
||||
@@ -232,8 +232,8 @@ export function UsersPage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
|
||||
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
|
||||
{list.isSuccess && users.length === 0 && <EmptyState icon={searchIcon}>{t('users.empty')}</EmptyState>}
|
||||
{list.isPending && <PageSkeleton />}
|
||||
</div>
|
||||
{list.hasNextPage && (
|
||||
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
filter: blur(30px);
|
||||
}
|
||||
|
||||
.waveDecor {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 46%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.55;
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
.blob1 {
|
||||
width: 62vmax;
|
||||
height: 62vmax;
|
||||
|
||||
@@ -60,12 +60,23 @@
|
||||
|
||||
.heroBg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.35;
|
||||
inset: -20%;
|
||||
opacity: 0.4;
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heroBg img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.heroCover {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -81,12 +92,40 @@
|
||||
|
||||
.heroBody {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listen {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.listenLyrics {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.listen {
|
||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.listen .hero {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listen .heroTitle {
|
||||
font-size: clamp(28px, 3vw, 40px);
|
||||
}
|
||||
}
|
||||
|
||||
.kind {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -98,6 +98,10 @@
|
||||
background: var(--surface3);
|
||||
}
|
||||
|
||||
.chip:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.chipOn,
|
||||
.chipOn:hover {
|
||||
background: var(--accent-fill);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
|
||||
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
|
||||
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
|
||||
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface); animation: pipuIn 0.2s ease-out both; }
|
||||
.coverWrap { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; }
|
||||
.coverWrap > :first-child { width: 100%; height: 100%; }
|
||||
.card strong { overflow-wrap: anywhere; }
|
||||
.card span { font-size: 13px; }
|
||||
.card:hover { background: #ffffff10; }
|
||||
.card:hover { background: var(--hover); }
|
||||
.play {
|
||||
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 .play, .card:focus-within .play, .play:focus-visible { opacity: 1; transform: none; }
|
||||
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.actions { display: flex; align-items: center; }
|
||||
@@ -11,4 +18,4 @@
|
||||
.actions button:disabled { opacity: .3; cursor: default; }
|
||||
.hero { overflow-wrap: anywhere; }
|
||||
@media (max-width: 600px) { .hero { flex-direction: column; align-items: flex-start; gap: 16px; } .hero > div { width: 100%; } }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } .play { opacity: 1; transform: none; } }
|
||||
@@ -8,7 +8,7 @@ import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { formatBytes, formatDateTime } from '../../shared/lib/format';
|
||||
import { addIcon } from '../../shared/ui/icons';
|
||||
import { Button, cx, FormError } from '../../shared/ui/ui';
|
||||
import { Button, cx, EmptyState, FormError } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
|
||||
import u from './uploads.module.css';
|
||||
@@ -86,7 +86,11 @@ export function MyUploadsPage() {
|
||||
</Link>
|
||||
</div>
|
||||
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
|
||||
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.mine_empty')}</div>}
|
||||
{list.isSuccess && items.length === 0 && (
|
||||
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
|
||||
{t('uploads.mine_empty')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<div className={s.tableWrap}>
|
||||
<table className={s.table}>
|
||||
|
||||
@@ -41,7 +41,12 @@
|
||||
transition:
|
||||
background 0.15s,
|
||||
box-shadow 0.15s,
|
||||
opacity 0.15s;
|
||||
opacity 0.15s,
|
||||
transform 0.12s;
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
@@ -53,6 +58,115 @@
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: pipuSpin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pipuSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
color: var(--text3);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
margin: 0;
|
||||
max-width: 28rem;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.empty a,
|
||||
.empty button {
|
||||
color: var(--accent2);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.empty a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: block;
|
||||
background: var(--surface3);
|
||||
border-radius: 8px;
|
||||
animation: pipuShimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeletonRound {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes pipuShimmer {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pageSkel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.skelHero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skelCover {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 12px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.skelCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.skelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--accent-fill);
|
||||
color: #fff;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
type CSSProperties,
|
||||
type InputHTMLAttributes,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
@@ -52,7 +53,7 @@ export function Button({ variant = 'secondary', icon, busy, className, children,
|
||||
aria-busy={busy || undefined}
|
||||
className={cx(s.button, s[variant], className)}
|
||||
>
|
||||
{icon && <Icon src={icon} size={18} />}
|
||||
{busy ? <span className={s.spinner} aria-hidden /> : icon ? <Icon src={icon} size={18} /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -283,6 +284,61 @@ export function Menu({
|
||||
);
|
||||
}
|
||||
|
||||
/** Пустой список: иконка, текст и действие (docs/design.md#5). */
|
||||
export function EmptyState({
|
||||
icon,
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
icon?: string;
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.empty} role="status">
|
||||
{icon && <Icon src={icon} size={40} className={s.emptyIcon} />}
|
||||
<p className={s.emptyText}>{children}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
className,
|
||||
style,
|
||||
round,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
round?: boolean;
|
||||
}) {
|
||||
return <span className={cx(s.skeleton, round && s.skeletonRound, className)} style={style} aria-hidden />;
|
||||
}
|
||||
|
||||
/** Каркас героя и строк списка, пока страница ещё грузится. */
|
||||
export function PageSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={s.pageSkel} role="status" aria-busy="true" aria-label={t('loading')}>
|
||||
<div className={s.skelHero}>
|
||||
<Skeleton className={s.skelCover} />
|
||||
<div className={s.skelCopy}>
|
||||
<Skeleton style={{ width: 72, height: 12 }} />
|
||||
<Skeleton style={{ width: '55%', height: 28 }} />
|
||||
<Skeleton style={{ width: '38%', height: 14 }} />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className={s.skelRow}>
|
||||
<Skeleton style={{ width: 40, height: 40 }} />
|
||||
<Skeleton style={{ flex: 1, height: 14 }} />
|
||||
<Skeleton style={{ width: 36, height: 12 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
|
||||
const initials = name
|
||||
.split(/[\s._-]+/)
|
||||
|
||||
@@ -227,12 +227,33 @@
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 8px;
|
||||
z-index: 50;
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--elev);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transform: translateY(-120%);
|
||||
}
|
||||
|
||||
.skip:focus {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 32px 36px;
|
||||
}
|
||||
|
||||
.routeIn {
|
||||
animation: pipuIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queueIcon } from '../shared/ui/icons';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
chevronRightIcon,
|
||||
storageFillIcon,
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||
import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
import { Avatar, cx, Dialog, Icon, Logo, Menu, PageSkeleton } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from './LanguageSwitch';
|
||||
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
|
||||
import { Ambient } from './Ambient';
|
||||
@@ -226,6 +226,9 @@ function Shell() {
|
||||
return (
|
||||
<div className={cx(s.shell, island && s.island)}>
|
||||
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
||||
<a href="#content" className={s.skip}>
|
||||
{t('nav.skip')}
|
||||
</a>
|
||||
<header className={s.top}>
|
||||
<div className={s.topLeft}>
|
||||
<Link to="/" className={s.logoLink}>
|
||||
@@ -284,17 +287,26 @@ function Shell() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<main className={s.content}>
|
||||
<Suspense fallback={null}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
<ShellMain />
|
||||
|
||||
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShellMain() {
|
||||
const key = useRouterState({ select: (st) => st.location.pathname });
|
||||
return (
|
||||
<main id="content" className={s.content} tabIndex={-1}>
|
||||
<div key={key} className={s.routeIn}>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Третье пятно фона — в цвете обложки текущего трека. */
|
||||
function ShellAmbient() {
|
||||
const player = usePlayer();
|
||||
|
||||
@@ -149,6 +149,115 @@ a.source:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.play:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.nowArt {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.nowEq {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.coverLive {
|
||||
animation: pipuCoverPulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pipuCoverPulse {
|
||||
50% {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
.queueScrim,
|
||||
.sheetScrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 15;
|
||||
background: var(--overlay);
|
||||
animation: pipuIn 0.18s ease-out;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 16;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
max-height: min(92dvh, 760px);
|
||||
padding: 12px 20px calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 24px 24px 0 0;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(28px) saturate(1.3);
|
||||
animation: pipuIn 0.22s ease-out;
|
||||
}
|
||||
|
||||
.sheetHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sheetCover {
|
||||
width: min(240px, 56vw) !important;
|
||||
height: min(240px, 56vw) !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.sheetMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sheetTitle {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sheetControls,
|
||||
.sheetExtra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sheet .progress {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.sheet .volume {
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.play[aria-busy='true'] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -218,6 +327,10 @@ a.source:hover {
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.now {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 0 12px;
|
||||
@@ -228,7 +341,7 @@ a.source:hover {
|
||||
.right,
|
||||
.controls > :first-child,
|
||||
.controls > :last-child,
|
||||
.now > button {
|
||||
.now > button:not(.nowArt) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -237,7 +350,7 @@ a.source:hover {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 100px;
|
||||
z-index: 6;
|
||||
z-index: 16;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100% - 32px));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||
import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
@@ -37,6 +37,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const narrow = useNarrow();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
@@ -97,8 +99,24 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
|
||||
return (
|
||||
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
||||
<div className={s.now}>
|
||||
<Cover album={track.album} size={52} />
|
||||
<div
|
||||
className={s.now}
|
||||
onClick={
|
||||
narrow
|
||||
? (e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{narrow ? (
|
||||
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
|
||||
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
|
||||
</button>
|
||||
) : (
|
||||
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||
)}
|
||||
<div className={s.meta}>
|
||||
<span className={s.title}>{track.title}</span>
|
||||
<span className={s.artists}>
|
||||
@@ -232,12 +250,150 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
{sheetOpen && narrow && (
|
||||
<NowPlayingSheet
|
||||
variant={variant}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
share={share}
|
||||
sharing={sharing}
|
||||
/>
|
||||
)}
|
||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function useNarrow() {
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.('(max-width: 767px)');
|
||||
if (!mq) return;
|
||||
const sync = () => setNarrow(mq.matches);
|
||||
sync();
|
||||
mq.addEventListener?.('change', sync);
|
||||
return () => mq.removeEventListener?.('change', sync);
|
||||
}, []);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
function NowPlayingSheet({
|
||||
variant,
|
||||
onClose,
|
||||
share,
|
||||
sharing,
|
||||
}: {
|
||||
variant: 'bar' | 'island';
|
||||
onClose: () => void;
|
||||
share: () => Promise<void>;
|
||||
sharing: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const track = st.track;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [onClose]);
|
||||
if (!track) return null;
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||
return createPortal(
|
||||
<div className={s.sheetScrim} onClick={onClose}>
|
||||
<div
|
||||
className={cx(s.sheet, variant === 'island' && s.sheetIsland)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.now_playing')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={s.sheetHead}>
|
||||
<span>{t('player.now_playing')}</span>
|
||||
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||
<Icon src={closeIcon} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<Cover album={track.album} size={240} className={cx(s.sheetCover, st.playing && s.coverLive)} />
|
||||
<div className={s.sheetMeta}>
|
||||
<Link to="/track/$id" params={{ id: track.id }} className={s.sheetTitle} onClick={onClose}>
|
||||
{track.title}
|
||||
</Link>
|
||||
<span className={s.artists}>
|
||||
<ArtistLinks artists={track.artists} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.progress}>
|
||||
<span className={s.time}>{formatDuration(st.positionMs)}</span>
|
||||
<input
|
||||
type="range"
|
||||
className={s.range}
|
||||
min={0}
|
||||
max={st.durationMs || track.duration_ms}
|
||||
step={1000}
|
||||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||
aria-label={t('player.progress')}
|
||||
onChange={(e) => player.seek(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||
</div>
|
||||
<div className={s.sheetControls}>
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
|
||||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
|
||||
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.sheetExtra}>
|
||||
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
|
||||
<Icon src={shuffleIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||
<Icon src={shareIcon} size={20} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
|
||||
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={22} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className={cx(s.range, s.volume)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={st.muted ? 0 : st.volume}
|
||||
aria-label={t('player.volume')}
|
||||
onChange={(e) => player.setVolume(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
||||
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -256,7 +412,9 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
}, [onClose]);
|
||||
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
|
||||
return createPortal(
|
||||
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||||
<>
|
||||
<div className={s.queueScrim} onClick={onClose} />
|
||||
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||||
<div className={s.drawerHead}>
|
||||
<span>{t('player.queue')}</span>
|
||||
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||
@@ -282,7 +440,8 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
))}
|
||||
</div>
|
||||
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
|
||||
</aside>,
|
||||
</aside>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover } from '../entities/catalog';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { searchIcon } from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
@@ -36,6 +37,7 @@ export function SearchBox({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const navigate = useNavigate();
|
||||
const listId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -118,8 +120,7 @@ export function SearchBox({
|
||||
title: tr.title,
|
||||
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
|
||||
cover: tr.album,
|
||||
// Воспроизведение — M3; пока трек открывает свой альбом.
|
||||
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
|
||||
go: () => player.playList([tr], 0, { type: 'search', id: debounced, label: debounced }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,7 +150,8 @@ export function SearchBox({
|
||||
}
|
||||
};
|
||||
|
||||
const showList = open && debounced !== '' && data !== undefined;
|
||||
const showPopover = open && debounced !== '';
|
||||
const showList = showPopover && data !== undefined;
|
||||
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
|
||||
const optionId = (i: number) => `${listId}-${i}`;
|
||||
|
||||
@@ -188,12 +190,17 @@ export function SearchBox({
|
||||
<kbd className={s.kbd} aria-hidden>
|
||||
/
|
||||
</kbd>
|
||||
{showList && (
|
||||
{showPopover && (
|
||||
<div className={s.popover}>
|
||||
{data.corrected_query && (
|
||||
{showPopover && !showList && (
|
||||
<div className={s.nothing} role="status">
|
||||
{t('search.searching')}
|
||||
</div>
|
||||
)}
|
||||
{data?.corrected_query && (
|
||||
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
|
||||
)}
|
||||
<div id={listId} role="listbox" aria-label={t('search.label')}>
|
||||
{showList && <div id={listId} role="listbox" aria-label={t('search.label')}>
|
||||
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
|
||||
{groups.map((g) => {
|
||||
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
|
||||
@@ -221,7 +228,7 @@ export function SearchBox({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>}
|
||||
<button
|
||||
type="button"
|
||||
className={s.all}
|
||||
|
||||
@@ -129,6 +129,15 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
aria-pressed={interactive ? !!props.playing : undefined}
|
||||
aria-hidden={interactive ? undefined : true}
|
||||
onMouseMove={interactive ? onMove : undefined}
|
||||
onFocus={interactive ? () => (s.hoverT = 1) : undefined}
|
||||
onBlur={
|
||||
interactive
|
||||
? () => {
|
||||
s.hoverT = 0;
|
||||
s.pressT = 0;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
|
||||
onMouseLeave={
|
||||
interactive
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"en": "English"
|
||||
},
|
||||
"nav": {
|
||||
"skip": "Skip to content",
|
||||
"more": "More",
|
||||
"main": "Navigation",
|
||||
"home": "Home",
|
||||
@@ -96,7 +97,9 @@
|
||||
"source_queue": "Queue",
|
||||
"queue": "Queue",
|
||||
"source_wave_seed": "Artist wave · {{label}}",
|
||||
"queue_wave": "The wave never ends: next tracks are picked as you listen"
|
||||
"queue_wave": "The wave never ends: next tracks are picked as you listen",
|
||||
"now_playing": "Now playing",
|
||||
"expand": "Open player"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -161,6 +164,8 @@
|
||||
"tracks": "Tracks",
|
||||
"all": "All",
|
||||
"nothing": "Nothing found",
|
||||
"searching": "Searching…",
|
||||
"type_query": "Type a query in the search field above",
|
||||
"corrected": "Showing results for “{{query}}”",
|
||||
"show_all": "All results",
|
||||
"title": "Search: {{query}}",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"en": "English"
|
||||
},
|
||||
"nav": {
|
||||
"skip": "К содержимому",
|
||||
"more": "Ещё",
|
||||
"main": "Навигация",
|
||||
"home": "Главная",
|
||||
@@ -96,7 +97,9 @@
|
||||
"source_queue": "Очередь",
|
||||
"queue": "Очередь",
|
||||
"source_wave_seed": "Волна по исполнителю · {{label}}",
|
||||
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
|
||||
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
|
||||
"now_playing": "Сейчас играет",
|
||||
"expand": "Открыть плеер"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
@@ -161,6 +164,8 @@
|
||||
"tracks": "Треки",
|
||||
"all": "Всё",
|
||||
"nothing": "Ничего не нашли",
|
||||
"searching": "Ищем…",
|
||||
"type_query": "Введите запрос в поиске сверху",
|
||||
"corrected": "Показаны результаты для «{{query}}»",
|
||||
"show_all": "Все результаты",
|
||||
"title": "Поиск: {{query}}",
|
||||
|
||||
Reference in new issue
Block a user