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
@@ -5,7 +5,10 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover, TrackList } from '../entities/catalog';
|
||||
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 { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
@@ -13,6 +16,7 @@ import c from './catalog.module.css';
|
||||
export function AlbumPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const album = useQuery({
|
||||
queryKey: ['album', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
||||
@@ -21,17 +25,12 @@ export function AlbumPage({ id }: { id: string }) {
|
||||
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 };
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
{colors && (
|
||||
<span
|
||||
className={c.heroBg}
|
||||
style={{ background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})` }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
||||
<Cover album={a ?? { id }} size={200} />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('catalog.album')}</span>
|
||||
@@ -41,13 +40,32 @@ export function AlbumPage({ id }: { id: string }) {
|
||||
<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)}
|
||||
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
||||
</span>
|
||||
)}
|
||||
{a && a.tracks.some((tr) => tr.available) && (
|
||||
<div className={c.heroActions}>
|
||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||
{t('catalog.play')}
|
||||
</Button>
|
||||
<Button
|
||||
className={c.pill}
|
||||
icon={shuffleIcon}
|
||||
onClick={() =>
|
||||
player.playList(
|
||||
[...a.tracks].sort(() => Math.random() - 0.5),
|
||||
0,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('collection.shuffle')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{a && <TrackList tracks={a.tracks} numbered context={{ type: 'album', id: a.id, label: a.title }} />}
|
||||
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { AlbumGrid, Cover } from '../entities/catalog';
|
||||
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { playIcon, waveIcon } from '../shared/ui/icons';
|
||||
import { Button } from '../shared/ui/ui';
|
||||
import { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
@@ -11,18 +14,25 @@ import c from './catalog.module.css';
|
||||
export function ArtistPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const artist = useQuery({
|
||||
queryKey: ['artist', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
|
||||
});
|
||||
const top = useQuery({
|
||||
queryKey: ['artist-top', id],
|
||||
queryFn: async () =>
|
||||
unwrap(await session.client.GET('/api/v1/artists/{id}/top-tracks', { params: { path: { id }, query: { limit: 6 } } })).items,
|
||||
});
|
||||
const albums = useQuery({
|
||||
queryKey: ['artist-albums', id],
|
||||
queryFn: async () =>
|
||||
unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||
});
|
||||
|
||||
if (artist.isError) return <NotFound error={artist.error} />;
|
||||
const a = artist.data;
|
||||
const tracks = top.data ?? [];
|
||||
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) ?? [];
|
||||
|
||||
@@ -38,8 +48,31 @@ export function ArtistPage({ id }: { id: string }) {
|
||||
{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')}
|
||||
</Button>
|
||||
{tracks.length > 0 && (
|
||||
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
||||
{t('catalog.play')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{tracks.length > 0 && (
|
||||
<section className={c.section}>
|
||||
<h2>{t('catalog.top_tracks')}</h2>
|
||||
<TrackList tracks={tracks} showAlbum context={ctx} lead={(_, i) => String(i + 1)} />
|
||||
</section>
|
||||
)}
|
||||
{own.length > 0 && (
|
||||
<section className={c.section}>
|
||||
<h2>{t('catalog.albums')}</h2>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -114,6 +114,30 @@ function AppearanceSection() {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.player_view')}</span>
|
||||
<Segmented<'bar' | 'island'>
|
||||
label={t('settings.player_view')}
|
||||
value={user.ui_prefs.web_player ?? 'bar'}
|
||||
onChange={(v) => void setPrefs({ web_player: v })}
|
||||
options={[
|
||||
{ value: 'bar', label: t('settings.player_bar') },
|
||||
{ value: 'island', label: t('settings.player_island') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.ambient')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.ambient')}
|
||||
value={user.ui_prefs.ambient === false ? 'off' : 'on'}
|
||||
onChange={(v) => void setPrefs({ ambient: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
{ value: 'off', label: t('settings.off') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('language.label')}</span>
|
||||
<LanguageSwitch />
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import s from '../page.module.css';
|
||||
import { UploadSettingsSection } from './UploadQueuePage';
|
||||
|
||||
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
|
||||
export function AdminSettingsPage() {
|
||||
const { t } = useTranslation('admin');
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<div className={s.head}>
|
||||
<div>
|
||||
<h1>{t('server_settings.title')}</h1>
|
||||
<p className={s.subtitle}>{t('server_settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<UploadSettingsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -158,8 +158,6 @@ export function UploadQueuePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UploadSettingsSection />
|
||||
|
||||
{editing && (
|
||||
<EditDialog
|
||||
file={editing}
|
||||
@@ -441,7 +439,7 @@ function RejectDialog({
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
|
||||
function UploadSettingsSection() {
|
||||
export function UploadSettingsSection() {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -66,6 +66,19 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heroActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.heroBody {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -114,3 +127,94 @@
|
||||
padding: 20px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Шапка коллекции (макет «Коллекция»): плитка-иконка, надзаголовок, крупный заголовок. */
|
||||
.colHero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.heroText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.colTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.heroSub {
|
||||
font-size: 14px;
|
||||
color: var(--text2);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.underlineTabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.underlineTab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 42px;
|
||||
padding: 0 14px;
|
||||
margin-bottom: -1px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text2);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.underlineTab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.underlineTab[aria-selected='true'] {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.day {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.pill {
|
||||
height: 44px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
Reference in new issue
Block a user