feat(web): align the web UI with the design prototype
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Successful in 1m39s

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 18:13:24 +03:00
1 parent 3afed02f23
commit 1c4e209e86
29 files changed
+1381 -339

No files matched your search

+28 -10
View File
@@ -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>
);
}
+36 -3
View File
@@ -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>
+47 -11
View File
@@ -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')}
+24
View File
@@ -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>
);
}
+1 -3
View File
@@ -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();
+104
View File
@@ -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);
}