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

+9
View File
@@ -11,6 +11,7 @@ import {
import { AuditPage } from '../pages/admin/AuditPage';
import { LibraryPage } from '../pages/admin/LibraryPage';
import { PatternsPage } from '../pages/admin/PatternsPage';
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
import { SuggestPage } from '../pages/uploads/SuggestPage';
@@ -224,6 +225,13 @@ const uploadQueueRoute = createRoute({
component: UploadQueuePage,
});
const adminSettingsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/settings',
beforeLoad: adminOnly,
component: AdminSettingsPage,
});
const routeTree = rootRoute.addChildren([
loginRoute,
setupAdminRoute,
@@ -245,6 +253,7 @@ const routeTree = rootRoute.addChildren([
suggestRoute,
myUploadsRoute,
uploadQueueRoute,
adminSettingsRoute,
]),
]);
+50
View File
@@ -110,6 +110,56 @@
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
}
.track:has(.lead) {
grid-template-columns: 44px 44px minmax(0, 1fr) auto;
}
.track:has(.lead):has(.trackAlbum) {
grid-template-columns: 44px 44px minmax(0, 1.4fr) minmax(0, 1fr) auto;
}
.lead {
display: flex;
justify-content: center;
font-size: 13px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.dimmed {
opacity: 0.6;
}
.eq {
display: flex;
gap: 2px;
align-items: flex-end;
height: 14px;
}
.eq span {
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--accent2);
transform-origin: bottom;
animation: pipuEq 0.55s ease-in-out infinite alternate;
}
.eq span:nth-child(2) {
animation-duration: 0.72s;
animation-delay: 0.1s;
}
.eq span:nth-child(3) {
animation-duration: 0.89s;
animation-delay: 0.2s;
}
.eqPaused span {
animation-play-state: paused;
}
.track:hover {
background: var(--hover);
}
+44 -15
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon } from '../shared/ui/icons';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css';
@@ -112,17 +112,23 @@ export function TrackList({
numbered,
context,
rowMeta,
lead,
disliked,
}: {
tracks: Track[];
showAlbum?: boolean;
numbered?: boolean;
context: PlayContext;
/** Дополнительный текст под исполнителями (например, время прослушивания в истории). */
/** Дополнительный текст под исполнителями. */
rowMeta?: (t: Track, i: number) => string | undefined;
/** Первая колонка перед обложкой (docs/design: время в истории, номер в лайках); у текущего трека — эквалайзер. */
lead?: (t: Track, i: number) => string;
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
disliked?: boolean;
}) {
const { t } = useTranslation();
const player = usePlayer();
const current = usePlayerState().track;
const { track: current, playing } = usePlayerState();
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null;
return (
@@ -151,15 +157,16 @@ export function TrackList({
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
>
<span className={s.num}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
<span className={cx(s.num, disliked && s.dimmed)}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={s.trackMain}>
<span className={cx(s.trackMain, disliked && s.dimmed)}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
@@ -172,15 +179,26 @@ export function TrackList({
</Link>
)}
<span className={s.end}>
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
{disliked ? (
<button
type="button"
className={cx(s.like, s.liked)}
aria-label={t('player.undislike')}
onClick={() => void player.react(tr, null).catch(() => undefined)}
>
<Icon src={undoIcon} size={18} />
</button>
) : (
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
</div>
@@ -191,6 +209,17 @@ export function TrackList({
);
}
/** Эквалайзер текущего трека (`pipuEq`, docs/design.md#2): три столбика, на паузе замирают. */
export function Equalizer({ playing }: { playing: boolean }) {
return (
<span className={cx(s.eq, !playing && s.eqPaused)} aria-hidden>
<span />
<span />
<span />
</span>
);
}
export function ArtistChip({ artist }: { artist: ArtistRef }) {
const { t } = useTranslation();
return (
+17
View File
@@ -0,0 +1,17 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useSession } from '../app/session';
/** Сколько лайков и дизлайков — счётчики в меню (total из первой страницы списка). */
export function useCollectionCounts() {
const session = useSession();
const total = (path: '/api/v1/me/likes' | '/api/v1/me/dislikes') => ({
queryKey: ['collection-count', path],
queryFn: async () => unwrap(await session.client.GET(path, { params: { query: { limit: 1 } } })).total,
staleTime: 60_000,
});
const likes = useQuery(total('/api/v1/me/likes'));
const dislikes = useQuery(total('/api/v1/me/dislikes'));
return { likes: likes.data, dislikes: dislikes.data };
}
+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);
}
+5 -3
View File
@@ -164,18 +164,20 @@ export class Player {
// --- «Моя волна» ---
/** Запустить волну (или перезапустить с новым контекстом). */
async startWave(activity: string | null, mood: string | null) {
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
this.finishAttempt('skip');
this.leaveWave();
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }),
await this.session.client.POST('/api/v1/wave/sessions', {
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
}),
);
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
if (!tracks.length) {
this.update({ error: 'wave.empty' });
return;
}
this.context = { type: 'wave', id: res.session_id };
this.context = { type: 'wave', id: res.session_id, label: seed?.label };
this.queue.shuffle = false;
this.queue.repeat = 'off';
this.queue.set(tracks, 0);
+5
View File
@@ -63,3 +63,8 @@ export { default as myUploadsIcon } from '@material-symbols/svg-400/rounded/libr
export { default as myUploadsFillIcon } from '@material-symbols/svg-400/rounded/library_add-fill.svg';
export { default as inboxIcon } from '@material-symbols/svg-400/rounded/inbox.svg';
export { default as inboxFillIcon } from '@material-symbols/svg-400/rounded/inbox-fill.svg';
export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_eq.svg';
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
+10
View File
@@ -61,3 +61,13 @@ button {
transition-duration: 0.01ms !important;
}
}
/* Эквалайзер текущего трека (docs/design.md#2). */
@keyframes pipuEq {
from {
transform: scaleY(0.25);
}
to {
transform: scaleY(1);
}
}
+108
View File
@@ -0,0 +1,108 @@
.ambient {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
opacity: var(--amb-op);
animation: pipuHue 30s linear infinite;
}
.ambient span {
position: absolute;
border-radius: 50%;
filter: blur(30px);
will-change: transform;
}
.a {
left: -22vmax;
top: -28vmax;
width: 70vmax;
height: 70vmax;
background: radial-gradient(circle, rgba(47, 128, 255, 0.75) 0%, transparent 62%);
animation: pipuDrift1 16s ease-in-out infinite alternate;
}
.b {
right: -20vmax;
top: 8vh;
width: 60vmax;
height: 60vmax;
background: radial-gradient(circle, rgba(124, 92, 255, 0.6) 0%, transparent 62%);
animation: pipuDrift2 21s ease-in-out infinite alternate;
}
.c {
left: 22vw;
bottom: -32vmax;
width: 55vmax;
height: 55vmax;
background: radial-gradient(circle, var(--amb-c3, rgba(53, 211, 255, 0.6)) 0%, transparent 62%);
animation: pipuDrift3 26s ease-in-out infinite alternate;
transition: background 1.2s;
}
@keyframes pipuDrift1 {
0% {
transform: translate(-6%, -4%) scale(1);
}
35% {
transform: translate(45%, 28%) scale(1.2);
}
70% {
transform: translate(15%, 60%) scale(0.9);
}
100% {
transform: translate(60%, -5%) scale(1.1);
}
}
@keyframes pipuDrift2 {
0% {
transform: translate(4%, 0) scale(1.05);
}
40% {
transform: translate(-50%, 40%) scale(0.9);
}
75% {
transform: translate(-20%, -25%) scale(1.2);
}
100% {
transform: translate(-60%, 15%) scale(1);
}
}
@keyframes pipuDrift3 {
0% {
transform: translate(0, 0) scale(0.95);
}
30% {
transform: translate(-40%, -45%) scale(1.15);
}
65% {
transform: translate(45%, -25%) scale(1);
}
100% {
transform: translate(10%, -55%) scale(1.2);
}
}
@keyframes pipuHue {
0% {
filter: hue-rotate(0deg);
}
50% {
filter: hue-rotate(-35deg);
}
100% {
filter: hue-rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ambient,
.ambient span {
animation: none;
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { CSSProperties } from 'react';
import s from './Ambient.module.css';
/**
* Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека.
* Отключается в настройках; при prefers-reduced-motion пятна неподвижны.
*/
export function Ambient({ color }: { color: string }) {
return (
<div className={s.ambient} aria-hidden>
<span className={s.a} />
<span className={s.b} />
<span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} />
</div>
);
}
+51
View File
@@ -1,4 +1,5 @@
.shell {
position: relative;
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 60px 1fr 88px;
@@ -9,6 +10,23 @@
height: 100%;
}
.shell > :where(:not([aria-hidden])) {
position: relative;
z-index: 1;
}
/* Плеер-остров: плавает над контентом, контенту — запас снизу (docs/design.md#4). */
.island {
grid-template-rows: 60px 1fr;
grid-template-areas:
'top top'
'side content';
}
.island .content {
padding-bottom: 132px;
}
.top {
grid-area: top;
display: flex;
@@ -118,6 +136,33 @@
color: var(--text3);
}
.navLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navCount {
font-size: 12px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.navBadge {
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: var(--radius-pill);
background: var(--accent);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 20px;
text-align: center;
}
.navItem[data-status='active'] .navIcon {
color: var(--accent2);
}
@@ -140,6 +185,12 @@
font-size: 0;
}
.navLabel,
.navCount,
.navBadge {
display: none;
}
.navSection {
margin: 14px 0 6px;
text-align: center;
+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;
}
+97
View File
@@ -10,6 +10,45 @@
backdrop-filter: blur(20px);
}
.bar.island {
position: fixed;
left: 50%;
bottom: 16px;
z-index: 5;
width: min(1040px, calc(100% - 32px));
height: 84px;
transform: translateX(-50%);
border: 1px solid var(--line2);
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
box-shadow: var(--shadow);
}
.source {
display: flex;
align-items: center;
gap: 5px;
margin-top: 2px;
font-size: 12px;
color: var(--text3);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
a.source {
text-decoration: none;
}
a.source:hover {
color: var(--text);
}
.sourceWave {
color: var(--accent2);
}
.empty {
display: flex;
align-items: center;
@@ -192,3 +231,61 @@
display: none;
}
}
.drawer {
position: fixed;
right: 16px;
bottom: 100px;
z-index: 6;
display: flex;
flex-direction: column;
width: min(380px, calc(100% - 32px));
max-height: min(560px, calc(100% - 180px));
border: 1px solid var(--line2);
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
box-shadow: var(--shadow);
animation: pipuIn 0.2s ease-out;
}
.drawerIsland {
bottom: 112px;
}
.drawerHead {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 12px 10px 18px;
font-weight: 600;
font-size: 16px;
}
.drawerList {
overflow-y: auto;
padding: 0 8px 10px;
}
.queueRow {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 6px 10px;
border-radius: 12px;
text-align: left;
}
.queueRow:hover {
background: var(--hover);
}
.queueCurrent {
background: var(--accent-soft);
}
.queueCurrent .title {
color: var(--accent2);
}
+111 -35
View File
@@ -1,10 +1,14 @@
import type { CSSProperties } from 'react';
import { useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import {
albumIcon,
closeIcon,
dislikeFillIcon,
dislikeIcon,
likeFillIcon,
@@ -14,25 +18,28 @@ import {
pauseIcon,
playIcon,
prevIcon,
queueIcon,
repeatIcon,
repeatOneIcon,
shuffleIcon,
volumeIcon,
volumeOffIcon,
waveIcon,
} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './PlayerBar.module.css';
/** Плеер внизу экрана (вид `bar`, docs/design.md). */
export function PlayerBar() {
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const [queueOpen, setQueueOpen] = useState(false);
const track = st.track;
if (!track) {
return (
<footer className={s.bar} aria-label={t('player.label')}>
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<span className={s.empty}>
<span className={s.placeholder}>
<Icon src={musicIcon} size={22} />
@@ -43,12 +50,22 @@ export function PlayerBar() {
);
}
const react = (r: 'like' | 'dislike') =>
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const repeatLabel = t(`player.repeat_${st.repeat}`);
const ctx = st.context;
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
const source = !ctx
? null
: ctx.type === 'wave'
? ctx.label
? t('player.source_wave_seed', { label: ctx.label })
: t('player.source_wave')
: ctx.type === 'collection'
? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
return (
<footer className={s.bar} aria-label={t('player.label')}>
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<div className={s.now}>
<Cover album={track.album} size={52} />
<div className={s.meta}>
@@ -56,37 +73,31 @@ export function PlayerBar() {
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
</span>
{source &&
(ctx?.type === 'album' && ctx.id ? (
<Link to="/album/$id" params={{ id: ctx.id }} className={s.source}>
<Icon src={albumIcon} size={14} />
{source}
</Link>
) : (
<span className={cx(s.source, ctx?.type === 'wave' && s.sourceWave)}>
<Icon src={ctx?.type === 'wave' ? waveIcon : albumIcon} size={14} />
{source}
</span>
))}
</div>
<button
type="button"
className={cx(s.icon, track.reaction === 'dislike' && s.danger)}
aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}
aria-pressed={track.reaction === 'dislike'}
onClick={() => react('dislike')}
>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button
type="button"
className={cx(s.icon, track.reaction === 'like' && s.accent)}
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
aria-pressed={track.reaction === 'like'}
onClick={() => react('like')}
>
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button>
</div>
<div className={s.center}>
<div className={s.controls}>
<button
type="button"
className={cx(s.icon, st.shuffle && s.accent)}
aria-label={t('player.shuffle')}
aria-pressed={st.shuffle}
onClick={() => player.toggleShuffle()}
className={cx(s.icon, track.reaction === 'dislike' && s.danger)}
aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}
aria-pressed={track.reaction === 'dislike'}
onClick={() => react('dislike')}
>
<Icon src={shuffleIcon} size={20} />
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<Icon src={prevIcon} size={26} />
@@ -105,12 +116,12 @@ export function PlayerBar() {
</button>
<button
type="button"
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
aria-label={repeatLabel}
title={repeatLabel}
onClick={() => player.cycleRepeat()}
className={cx(s.icon, track.reaction === 'like' && s.accent)}
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
aria-pressed={track.reaction === 'like'}
onClick={() => react('like')}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button>
</div>
<div className={s.progress}>
@@ -137,6 +148,33 @@ export function PlayerBar() {
</div>
<div className={s.right}>
<button
type="button"
className={cx(s.icon, st.shuffle && s.accent)}
aria-label={t('player.shuffle')}
aria-pressed={st.shuffle}
onClick={() => player.toggleShuffle()}
>
<Icon src={shuffleIcon} size={20} />
</button>
<button
type="button"
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
aria-label={repeatLabel}
title={repeatLabel}
onClick={() => player.cycleRepeat()}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
<button
type="button"
className={cx(s.icon, queueOpen && s.accent)}
aria-label={t('player.queue')}
aria-expanded={queueOpen}
onClick={() => setQueueOpen((v) => !v)}
>
<Icon src={queueIcon} size={20} />
</button>
<button
type="button"
className={s.icon}
@@ -157,6 +195,44 @@ export function PlayerBar() {
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
{queueOpen && <QueueDrawer variant={variant} onClose={() => setQueueOpen(false)} />}
</footer>
);
}
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose: () => void }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const upcoming = st.queue.slice(st.index, st.index + 40);
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
return createPortal(
<aside className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<div className={s.drawerHead}>
<span>{t('player.queue')}</span>
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} />
</button>
</div>
<div className={s.drawerList}>
{upcoming.map((tr, k) => (
<button
key={`${tr.id}-${k}`}
type="button"
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
onClick={() => player.jump(st.index + k)}
>
<Cover album={tr.album} size={40} />
<span className={s.meta}>
<span className={s.title}>{tr.title}</span>
<span className={s.artists}>{tr.artists.map((a) => a.name).join(', ')}</span>
</span>
<span className={s.time}>{formatDuration(tr.duration_ms)}</span>
</button>
))}
</div>
</aside>,
document.body,
);
}