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
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user