feat(web): align the web UI with the design prototype
- Ambient background in the app shell: three drifting blurred blobs,
the third one tinted with the current track's cover; can be turned off
in settings and stays still with reduced motion.
- Island player variant (floating glass panel) next to the bar, chosen
in settings; player source line ("Album · …", "My Wave"), reactions
around the transport controls, queue drawer.
- Sidebar: like / dislike counters, pending uploads badge on Queue,
admin Settings page holding the upload queue limits.
- Collection: header tile with kicker, underline tabs, time / number
lead column with an equalizer for the current track, album column,
dimmed dislikes with undo, Play all / Shuffle pills.
- Album: cover-tinted header and Play / Shuffle; artist: artist wave,
Play and popular tracks (new GET /artists/{id}/top-tracks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3afed02f23
commit
1c4e209e86
29 files changed
+1381
-339
No files matched your search
@@ -6,26 +6,46 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { TrackList, type Track } from '../entities/catalog';
|
||||
import { useCollectionCounts } from '../entities/collection';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { playIcon } from '../shared/ui/icons';
|
||||
import { Button, Dialog, FormError } from '../shared/ui/ui';
|
||||
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
|
||||
import c from './catalog.module.css';
|
||||
import s from './page.module.css';
|
||||
|
||||
export type CollectionTab = 'history' | 'likes' | 'dislikes';
|
||||
const TABS: CollectionTab[] = ['history', 'likes', 'dislikes'];
|
||||
|
||||
const HERO: Record<CollectionTab, { icon: string; tile: string }> = {
|
||||
history: { icon: historyFillIcon, tile: 'linear-gradient(140deg, #35D3FF, #2F80FF 60%, #1B3FA8)' },
|
||||
likes: { icon: likeFillIcon, tile: 'linear-gradient(140deg, #5E9BFF, #2F80FF 50%, #7C5CFF)' },
|
||||
dislikes: { icon: dislikeFillIcon, tile: 'linear-gradient(140deg, #4A5572, #1A2236)' },
|
||||
};
|
||||
|
||||
export function CollectionPage({ tab }: { tab: CollectionTab }) {
|
||||
const { t } = useTranslation();
|
||||
const counts = useCollectionCounts();
|
||||
const hero = HERO[tab];
|
||||
const sub =
|
||||
tab === 'likes'
|
||||
? t('collection.sub_likes', { count: counts.likes ?? 0 })
|
||||
: t(tab === 'history' ? 'collection.sub_history' : 'collection.sub_dislikes');
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<div className={s.head}>
|
||||
<h1>{t('collection.title')}</h1>
|
||||
</div>
|
||||
<div className={s.chips} role="tablist" aria-label={t('collection.title')}>
|
||||
<header className={c.colHero}>
|
||||
<span className={c.tile} style={{ background: hero.tile }} aria-hidden>
|
||||
<Icon src={hero.icon} size={52} />
|
||||
</span>
|
||||
<div className={c.heroText}>
|
||||
<span className={c.kicker}>{t('collection.title')}</span>
|
||||
<h1 className={c.colTitle}>{t(`collection.${tab}`)}</h1>
|
||||
<span className={c.heroSub}>{sub}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className={c.underlineTabs} role="tablist" aria-label={t('collection.title')}>
|
||||
{TABS.map((k) => (
|
||||
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.tab}>
|
||||
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.underlineTab}>
|
||||
{t(`collection.${k}`)}
|
||||
</Link>
|
||||
))}
|
||||
@@ -92,11 +112,12 @@ function History() {
|
||||
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
|
||||
{groups.map((g) => (
|
||||
<section key={g.key} className={c.section}>
|
||||
<h2>{g.title}</h2>
|
||||
<h2 className={c.day}>{g.title}</h2>
|
||||
<TrackList
|
||||
tracks={g.items.map((i) => i.track)}
|
||||
showAlbum
|
||||
context={{ type: 'history' }}
|
||||
rowMeta={(_, i) => time.format(new Date(g.items[i]!.played_at))}
|
||||
lead={(_, i) => time.format(new Date(g.items[i]!.played_at))}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
@@ -154,9 +175,16 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
||||
<>
|
||||
{reaction === 'like' && tracks.length > 0 && (
|
||||
<div className={s.toolbar}>
|
||||
<Button variant="primary" icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
|
||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
|
||||
{t('collection.play_all')}
|
||||
</Button>
|
||||
<Button
|
||||
className={c.pill}
|
||||
icon={shuffleIcon}
|
||||
onClick={() => player.playList([...tracks].sort(() => Math.random() - 0.5), 0, context)}
|
||||
>
|
||||
{t('collection.shuffle')}
|
||||
</Button>
|
||||
<span className={s.note}>{t('catalog.track_count', { count: total })}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -164,7 +192,15 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
||||
{list.isSuccess && tracks.length === 0 && (
|
||||
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
|
||||
)}
|
||||
{tracks.length > 0 && <TrackList tracks={tracks} showAlbum context={context} />}
|
||||
{tracks.length > 0 && (
|
||||
<TrackList
|
||||
tracks={tracks}
|
||||
showAlbum
|
||||
context={context}
|
||||
lead={(_, i) => String(i + 1)}
|
||||
disliked={reaction === 'dislike'}
|
||||
/>
|
||||
)}
|
||||
{list.hasNextPage && (
|
||||
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||
{t('collection.more')}
|
||||
|
||||
Reference in new issue
Block a user