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

+74 -16
View File
@@ -1,7 +1,10 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
import { useCollectionCounts } from '../entities/collection';
import {
auditFillIcon,
auditIcon,
@@ -20,10 +23,11 @@ import {
dislikeIcon,
settingsIcon,
storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon} from '../shared/ui/icons';
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider } from '../player/context';
import { PlayerProvider, usePlayerState } from '../player/context';
import { Ambient } from './Ambient';
import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox';
import s from './AppShell.module.css';
@@ -37,17 +41,34 @@ type NavPath =
| '/admin/uploads'
| '/uploads/new'
| '/uploads/mine'
| '/admin/settings'
| '/collection/history'
| '/collection/likes'
| '/collection/dislikes';
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
function NavLink({
to,
label,
icon,
activeIcon,
count,
badge,
}: {
to: NavPath;
label: string;
icon: string;
activeIcon: string;
count?: number; // счётчик справа (лайки, дизлайки)
badge?: number; // яркий бейдж (ждут решения)
}) {
return (
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
{({ isActive }) => (
<>
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
{label}
<span className={s.navLabel}>{label}</span>
{badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
{!badge && count ? <span className={s.navCount}>{count}</span> : null}
</>
)}
</Link>
@@ -70,9 +91,13 @@ function Shell() {
const navigate = useNavigate();
const name = user.display_name ?? user.username;
const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island';
const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin');
return (
<div className={s.shell}>
<div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />}
<header className={s.top}>
<Link to="/" className={s.logoLink}>
<Logo />
@@ -107,24 +132,36 @@ function Shell() {
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<div className={s.navSection}>{t('nav.collection')}</div>
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink
to="/collection/dislikes"
label={t('nav.dislikes')}
icon={dislikeIcon}
activeIcon={dislikeFillIcon}
count={counts.dislikes}
/>
{staff && (
<>
<div className={s.navSection}>{t('nav.uploads')}</div>
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/uploads" label={t('nav.upload_queue')} icon={inboxIcon} activeIcon={inboxFillIcon} />
)}
<div className={s.navSection}>{t('nav.admin')}</div>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
<>
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
)}
</>
)}
@@ -134,7 +171,28 @@ function Shell() {
<Outlet />
</main>
<PlayerBar />
<PlayerBar variant={island ? 'island' : 'bar'} />
</div>
);
}
/** Третье пятно фона — в цвете обложки текущего трека. */
function ShellAmbient() {
const track = usePlayerState().track;
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} />;
}
/** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */
function usePendingUploads(enabled: boolean) {
const session = useSession();
const q = useQuery({
queryKey: ['admin-uploads-pending'],
enabled,
queryFn: async () =>
unwrap(
await session.client.GET('/api/v1/admin/uploads', { params: { query: { status: ['pending_review'], limit: 100 } } }),
).items.length,
refetchInterval: 60_000,
});
return q.data;
}