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
@@ -894,6 +894,30 @@ paths:
|
|||||||
$ref: "#/components/schemas/AlbumSummaryList"
|
$ref: "#/components/schemas/AlbumSummaryList"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/artists/{id}/top-tracks:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/EntityId"
|
||||||
|
get:
|
||||||
|
operationId: listArtistTopTracks
|
||||||
|
tags: [catalog]
|
||||||
|
summary: Популярные треки исполнителя (по прослушиваниям всех пользователей)
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 50
|
||||||
|
default: 10
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Треки, самые слушаемые первыми
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TrackList"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
/api/v1/albums/{id}:
|
/api/v1/albums/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/EntityId"
|
- $ref: "#/components/parameters/EntityId"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { AuditPage } from '../pages/admin/AuditPage';
|
import { AuditPage } from '../pages/admin/AuditPage';
|
||||||
import { LibraryPage } from '../pages/admin/LibraryPage';
|
import { LibraryPage } from '../pages/admin/LibraryPage';
|
||||||
import { PatternsPage } from '../pages/admin/PatternsPage';
|
import { PatternsPage } from '../pages/admin/PatternsPage';
|
||||||
|
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
|
||||||
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
|
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
|
||||||
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
|
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
|
||||||
import { SuggestPage } from '../pages/uploads/SuggestPage';
|
import { SuggestPage } from '../pages/uploads/SuggestPage';
|
||||||
@@ -224,6 +225,13 @@ const uploadQueueRoute = createRoute({
|
|||||||
component: UploadQueuePage,
|
component: UploadQueuePage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const adminSettingsRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/settings',
|
||||||
|
beforeLoad: adminOnly,
|
||||||
|
component: AdminSettingsPage,
|
||||||
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
loginRoute,
|
loginRoute,
|
||||||
setupAdminRoute,
|
setupAdminRoute,
|
||||||
@@ -245,6 +253,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
suggestRoute,
|
suggestRoute,
|
||||||
myUploadsRoute,
|
myUploadsRoute,
|
||||||
uploadQueueRoute,
|
uploadQueueRoute,
|
||||||
|
adminSettingsRoute,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,56 @@
|
|||||||
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
|
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 {
|
.track:hover {
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { usePlayer, usePlayerState } from '../player/context';
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
import type { PlayContext } from '../player/player';
|
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 { cx, Icon } from '../shared/ui/ui';
|
||||||
import s from './catalog.module.css';
|
import s from './catalog.module.css';
|
||||||
|
|
||||||
@@ -112,17 +112,23 @@ export function TrackList({
|
|||||||
numbered,
|
numbered,
|
||||||
context,
|
context,
|
||||||
rowMeta,
|
rowMeta,
|
||||||
|
lead,
|
||||||
|
disliked,
|
||||||
}: {
|
}: {
|
||||||
tracks: Track[];
|
tracks: Track[];
|
||||||
showAlbum?: boolean;
|
showAlbum?: boolean;
|
||||||
numbered?: boolean;
|
numbered?: boolean;
|
||||||
context: PlayContext;
|
context: PlayContext;
|
||||||
/** Дополнительный текст под исполнителями (например, время прослушивания в истории). */
|
/** Дополнительный текст под исполнителями. */
|
||||||
rowMeta?: (t: Track, i: number) => string | undefined;
|
rowMeta?: (t: Track, i: number) => string | undefined;
|
||||||
|
/** Первая колонка перед обложкой (docs/design: время в истории, номер в лайках); у текущего трека — эквалайзер. */
|
||||||
|
lead?: (t: Track, i: number) => string;
|
||||||
|
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
||||||
|
disliked?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const current = usePlayerState().track;
|
const { track: current, playing } = usePlayerState();
|
||||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||||
let lastDisc: number | null = null;
|
let lastDisc: number | null = null;
|
||||||
return (
|
return (
|
||||||
@@ -151,15 +157,16 @@ export function TrackList({
|
|||||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={s.num}>
|
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
|
||||||
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}
|
<span className={cx(s.num, disliked && s.dimmed)}>
|
||||||
|
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
|
||||||
{tr.available && (
|
{tr.available && (
|
||||||
<span className={s.playHint} aria-hidden>
|
<span className={s.playHint} aria-hidden>
|
||||||
<Icon src={playIcon} size={20} />
|
<Icon src={playIcon} size={20} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className={s.trackMain}>
|
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
||||||
<span className={s.trackTitle}>{tr.title}</span>
|
<span className={s.trackTitle}>{tr.title}</span>
|
||||||
<span className={s.trackArtists}>
|
<span className={s.trackArtists}>
|
||||||
<ArtistLinks artists={tr.artists} />
|
<ArtistLinks artists={tr.artists} />
|
||||||
@@ -172,15 +179,26 @@ export function TrackList({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<span className={s.end}>
|
<span className={s.end}>
|
||||||
<button
|
{disliked ? (
|
||||||
type="button"
|
<button
|
||||||
className={cx(s.like, liked && s.liked)}
|
type="button"
|
||||||
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
className={cx(s.like, s.liked)}
|
||||||
aria-pressed={liked}
|
aria-label={t('player.undislike')}
|
||||||
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
onClick={() => void player.react(tr, null).catch(() => undefined)}
|
||||||
>
|
>
|
||||||
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
<Icon src={undoIcon} size={18} />
|
||||||
</button>
|
</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 className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 }) {
|
export function ArtistChip({ artist }: { artist: ArtistRef }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { Cover, TrackList } from '../entities/catalog';
|
import { Cover, TrackList } from '../entities/catalog';
|
||||||
|
import { usePlayer } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
|
import { Button } from '../shared/ui/ui';
|
||||||
import { NotFound } from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
@@ -13,6 +16,7 @@ import c from './catalog.module.css';
|
|||||||
export function AlbumPage({ id }: { id: string }) {
|
export function AlbumPage({ id }: { id: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
const album = useQuery({
|
const album = useQuery({
|
||||||
queryKey: ['album', id],
|
queryKey: ['album', id],
|
||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { 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} />;
|
if (album.isError) return <NotFound error={album.error} />;
|
||||||
const a = album.data;
|
const a = album.data;
|
||||||
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
|
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
|
||||||
|
const ctx = { type: 'album' as const, id, label: a?.title };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.page}>
|
<div className={s.page}>
|
||||||
<header className={c.hero}>
|
<header className={c.hero}>
|
||||||
{colors && (
|
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
||||||
<span
|
|
||||||
className={c.heroBg}
|
|
||||||
style={{ background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})` }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Cover album={a ?? { id }} size={200} />
|
<Cover album={a ?? { id }} size={200} />
|
||||||
<div className={c.heroBody}>
|
<div className={c.heroBody}>
|
||||||
<span className={c.kind}>{t('catalog.album')}</span>
|
<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 }}>
|
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
||||||
{a.artist.name}
|
{a.artist.name}
|
||||||
</Link>
|
</Link>
|
||||||
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} ·{' '}
|
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
||||||
{formatDuration(a.duration_ms)}
|
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</header>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
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 { NotFound } from './NotFound';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
@@ -11,18 +14,25 @@ import c from './catalog.module.css';
|
|||||||
export function ArtistPage({ id }: { id: string }) {
|
export function ArtistPage({ id }: { id: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
const artist = useQuery({
|
const artist = useQuery({
|
||||||
queryKey: ['artist', id],
|
queryKey: ['artist', id],
|
||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { 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({
|
const albums = useQuery({
|
||||||
queryKey: ['artist-albums', id],
|
queryKey: ['artist-albums', id],
|
||||||
queryFn: async () =>
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||||
unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (artist.isError) return <NotFound error={artist.error} />;
|
if (artist.isError) return <NotFound error={artist.error} />;
|
||||||
const a = artist.data;
|
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 own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
||||||
const appears = 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 })}
|
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</header>
|
</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 && (
|
{own.length > 0 && (
|
||||||
<section className={c.section}>
|
<section className={c.section}>
|
||||||
<h2>{t('catalog.albums')}</h2>
|
<h2>{t('catalog.albums')}</h2>
|
||||||
|
|||||||
@@ -6,26 +6,46 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { TrackList, type Track } from '../entities/catalog';
|
import { TrackList, type Track } from '../entities/catalog';
|
||||||
|
import { useCollectionCounts } from '../entities/collection';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { playIcon } from '../shared/ui/icons';
|
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
import { Button, Dialog, FormError } from '../shared/ui/ui';
|
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
|
|
||||||
export type CollectionTab = 'history' | 'likes' | 'dislikes';
|
export type CollectionTab = 'history' | 'likes' | 'dislikes';
|
||||||
const TABS: 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 }) {
|
export function CollectionPage({ tab }: { tab: CollectionTab }) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className={s.page}>
|
<div className={s.page}>
|
||||||
<div className={s.head}>
|
<header className={c.colHero}>
|
||||||
<h1>{t('collection.title')}</h1>
|
<span className={c.tile} style={{ background: hero.tile }} aria-hidden>
|
||||||
</div>
|
<Icon src={hero.icon} size={52} />
|
||||||
<div className={s.chips} role="tablist" aria-label={t('collection.title')}>
|
</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) => (
|
{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}`)}
|
{t(`collection.${k}`)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -92,11 +112,12 @@ function History() {
|
|||||||
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
|
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<section key={g.key} className={c.section}>
|
<section key={g.key} className={c.section}>
|
||||||
<h2>{g.title}</h2>
|
<h2 className={c.day}>{g.title}</h2>
|
||||||
<TrackList
|
<TrackList
|
||||||
tracks={g.items.map((i) => i.track)}
|
tracks={g.items.map((i) => i.track)}
|
||||||
|
showAlbum
|
||||||
context={{ type: 'history' }}
|
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>
|
</section>
|
||||||
))}
|
))}
|
||||||
@@ -154,9 +175,16 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
|||||||
<>
|
<>
|
||||||
{reaction === 'like' && tracks.length > 0 && (
|
{reaction === 'like' && tracks.length > 0 && (
|
||||||
<div className={s.toolbar}>
|
<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')}
|
{t('collection.play_all')}
|
||||||
</Button>
|
</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>
|
<span className={s.note}>{t('catalog.track_count', { count: total })}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -164,7 +192,15 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
|||||||
{list.isSuccess && tracks.length === 0 && (
|
{list.isSuccess && tracks.length === 0 && (
|
||||||
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
|
<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 && (
|
{list.hasNextPage && (
|
||||||
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
{t('collection.more')}
|
{t('collection.more')}
|
||||||
|
|||||||
@@ -114,6 +114,30 @@ function AppearanceSection() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<div className={s.settingRow}>
|
||||||
<span>{t('language.label')}</span>
|
<span>{t('language.label')}</span>
|
||||||
<LanguageSwitch />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<UploadSettingsSection />
|
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<EditDialog
|
<EditDialog
|
||||||
file={editing}
|
file={editing}
|
||||||
@@ -441,7 +439,7 @@ function RejectDialog({
|
|||||||
const GB = 1024 ** 3;
|
const GB = 1024 ** 3;
|
||||||
|
|
||||||
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
|
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
|
||||||
function UploadSettingsSection() {
|
export function UploadSettingsSection() {
|
||||||
const { t } = useTranslation(['admin', 'common']);
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
@@ -66,6 +66,19 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.heroBody {
|
.heroBody {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -114,3 +127,94 @@
|
|||||||
padding: 20px 16px;
|
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);
|
||||||
|
}
|
||||||
@@ -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.finishAttempt('skip');
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
const res = unwrap(
|
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);
|
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
|
||||||
if (!tracks.length) {
|
if (!tracks.length) {
|
||||||
this.update({ error: 'wave.empty' });
|
this.update({ error: 'wave.empty' });
|
||||||
return;
|
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.shuffle = false;
|
||||||
this.queue.repeat = 'off';
|
this.queue.repeat = 'off';
|
||||||
this.queue.set(tracks, 0);
|
this.queue.set(tracks, 0);
|
||||||
|
|||||||
@@ -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 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 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 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';
|
||||||
@@ -61,3 +61,13 @@ button {
|
|||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Эквалайзер текущего трека (docs/design.md#2). */
|
||||||
|
@keyframes pipuEq {
|
||||||
|
from {
|
||||||
|
transform: scaleY(0.25);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
.shell {
|
.shell {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 240px 1fr;
|
grid-template-columns: 240px 1fr;
|
||||||
grid-template-rows: 60px 1fr 88px;
|
grid-template-rows: 60px 1fr 88px;
|
||||||
@@ -9,6 +10,23 @@
|
|||||||
height: 100%;
|
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 {
|
.top {
|
||||||
grid-area: top;
|
grid-area: top;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -118,6 +136,33 @@
|
|||||||
color: var(--text3);
|
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 {
|
.navItem[data-status='active'] .navIcon {
|
||||||
color: var(--accent2);
|
color: var(--accent2);
|
||||||
}
|
}
|
||||||
@@ -140,6 +185,12 @@
|
|||||||
font-size: 0;
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navLabel,
|
||||||
|
.navCount,
|
||||||
|
.navBadge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.navSection {
|
.navSection {
|
||||||
margin: 14px 0 6px;
|
margin: 14px 0 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuthState, useSession, useUser } from '../app/session';
|
import { useAuthState, useSession, useUser } from '../app/session';
|
||||||
|
import { useCollectionCounts } from '../entities/collection';
|
||||||
import {
|
import {
|
||||||
auditFillIcon,
|
auditFillIcon,
|
||||||
auditIcon,
|
auditIcon,
|
||||||
@@ -20,10 +23,11 @@ import {
|
|||||||
dislikeIcon,
|
dislikeIcon,
|
||||||
settingsIcon,
|
settingsIcon,
|
||||||
storageFillIcon,
|
storageFillIcon,
|
||||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon} from '../shared/ui/icons';
|
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||||
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
|
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||||
import { LanguageSwitch } from './LanguageSwitch';
|
import { LanguageSwitch } from './LanguageSwitch';
|
||||||
import { PlayerProvider } from '../player/context';
|
import { PlayerProvider, usePlayerState } from '../player/context';
|
||||||
|
import { Ambient } from './Ambient';
|
||||||
import { PlayerBar } from './PlayerBar';
|
import { PlayerBar } from './PlayerBar';
|
||||||
import { SearchBox } from './SearchBox';
|
import { SearchBox } from './SearchBox';
|
||||||
import s from './AppShell.module.css';
|
import s from './AppShell.module.css';
|
||||||
@@ -37,17 +41,34 @@ type NavPath =
|
|||||||
| '/admin/uploads'
|
| '/admin/uploads'
|
||||||
| '/uploads/new'
|
| '/uploads/new'
|
||||||
| '/uploads/mine'
|
| '/uploads/mine'
|
||||||
|
| '/admin/settings'
|
||||||
| '/collection/history'
|
| '/collection/history'
|
||||||
| '/collection/likes'
|
| '/collection/likes'
|
||||||
| '/collection/dislikes';
|
| '/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 (
|
return (
|
||||||
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
|
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
|
<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>
|
</Link>
|
||||||
@@ -70,9 +91,13 @@ function Shell() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const name = user.display_name ?? user.username;
|
const name = user.display_name ?? user.username;
|
||||||
const staff = user.role !== 'user';
|
const staff = user.role !== 'user';
|
||||||
|
const island = user.ui_prefs.web_player === 'island';
|
||||||
|
const counts = useCollectionCounts();
|
||||||
|
const pending = usePendingUploads(user.role === 'admin');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.shell}>
|
<div className={cx(s.shell, island && s.island)}>
|
||||||
|
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
||||||
<header className={s.top}>
|
<header className={s.top}>
|
||||||
<Link to="/" className={s.logoLink}>
|
<Link to="/" className={s.logoLink}>
|
||||||
<Logo />
|
<Logo />
|
||||||
@@ -107,24 +132,36 @@ function Shell() {
|
|||||||
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||||
<div className={s.navSection}>{t('nav.collection')}</div>
|
<div className={s.navSection}>{t('nav.collection')}</div>
|
||||||
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
<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/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
||||||
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} />
|
<NavLink
|
||||||
|
to="/collection/dislikes"
|
||||||
|
label={t('nav.dislikes')}
|
||||||
|
icon={dislikeIcon}
|
||||||
|
activeIcon={dislikeFillIcon}
|
||||||
|
count={counts.dislikes}
|
||||||
|
/>
|
||||||
{staff && (
|
{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>
|
<div className={s.navSection}>{t('nav.admin')}</div>
|
||||||
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||||
{user.role === 'admin' && (
|
{user.role === 'admin' && (
|
||||||
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
<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="/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' && (
|
{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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<PlayerBar />
|
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
@@ -10,6 +10,45 @@
|
|||||||
backdrop-filter: blur(20px);
|
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 {
|
.empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -192,3 +231,61 @@
|
|||||||
display: none;
|
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);
|
||||||
|
}
|
||||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||||
import { usePlayer, usePlayerState } from '../player/context';
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import {
|
import {
|
||||||
|
albumIcon,
|
||||||
|
closeIcon,
|
||||||
dislikeFillIcon,
|
dislikeFillIcon,
|
||||||
dislikeIcon,
|
dislikeIcon,
|
||||||
likeFillIcon,
|
likeFillIcon,
|
||||||
@@ -14,25 +18,28 @@ import {
|
|||||||
pauseIcon,
|
pauseIcon,
|
||||||
playIcon,
|
playIcon,
|
||||||
prevIcon,
|
prevIcon,
|
||||||
|
queueIcon,
|
||||||
repeatIcon,
|
repeatIcon,
|
||||||
repeatOneIcon,
|
repeatOneIcon,
|
||||||
shuffleIcon,
|
shuffleIcon,
|
||||||
volumeIcon,
|
volumeIcon,
|
||||||
volumeOffIcon,
|
volumeOffIcon,
|
||||||
|
waveIcon,
|
||||||
} from '../shared/ui/icons';
|
} from '../shared/ui/icons';
|
||||||
import { cx, Icon } from '../shared/ui/ui';
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
import s from './PlayerBar.module.css';
|
import s from './PlayerBar.module.css';
|
||||||
|
|
||||||
/** Плеер внизу экрана (вид `bar`, docs/design.md). */
|
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
|
||||||
export function PlayerBar() {
|
export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
const track = st.track;
|
const track = st.track;
|
||||||
|
|
||||||
if (!track) {
|
if (!track) {
|
||||||
return (
|
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.empty}>
|
||||||
<span className={s.placeholder}>
|
<span className={s.placeholder}>
|
||||||
<Icon src={musicIcon} size={22} />
|
<Icon src={musicIcon} size={22} />
|
||||||
@@ -43,12 +50,22 @@ export function PlayerBar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const react = (r: 'like' | 'dislike') =>
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
|
||||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
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 (
|
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}>
|
<div className={s.now}>
|
||||||
<Cover album={track.album} size={52} />
|
<Cover album={track.album} size={52} />
|
||||||
<div className={s.meta}>
|
<div className={s.meta}>
|
||||||
@@ -56,37 +73,31 @@ export function PlayerBar() {
|
|||||||
<span className={s.artists}>
|
<span className={s.artists}>
|
||||||
<ArtistLinks artists={track.artists} />
|
<ArtistLinks artists={track.artists} />
|
||||||
</span>
|
</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>
|
</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>
|
||||||
|
|
||||||
<div className={s.center}>
|
<div className={s.center}>
|
||||||
<div className={s.controls}>
|
<div className={s.controls}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cx(s.icon, st.shuffle && s.accent)}
|
className={cx(s.icon, track.reaction === 'dislike' && s.danger)}
|
||||||
aria-label={t('player.shuffle')}
|
aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}
|
||||||
aria-pressed={st.shuffle}
|
aria-pressed={track.reaction === 'dislike'}
|
||||||
onClick={() => player.toggleShuffle()}
|
onClick={() => react('dislike')}
|
||||||
>
|
>
|
||||||
<Icon src={shuffleIcon} size={20} />
|
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||||
<Icon src={prevIcon} size={26} />
|
<Icon src={prevIcon} size={26} />
|
||||||
@@ -105,12 +116,12 @@ export function PlayerBar() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
|
className={cx(s.icon, track.reaction === 'like' && s.accent)}
|
||||||
aria-label={repeatLabel}
|
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
|
||||||
title={repeatLabel}
|
aria-pressed={track.reaction === 'like'}
|
||||||
onClick={() => player.cycleRepeat()}
|
onClick={() => react('like')}
|
||||||
>
|
>
|
||||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={s.progress}>
|
<div className={s.progress}>
|
||||||
@@ -137,6 +148,33 @@ export function PlayerBar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.right}>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={s.icon}
|
className={s.icon}
|
||||||
@@ -157,6 +195,44 @@ export function PlayerBar() {
|
|||||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{queueOpen && <QueueDrawer variant={variant} onClose={() => setQueueOpen(false)} />}
|
||||||
</footer>
|
</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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -781,6 +781,25 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/artists/{id}/top-tracks": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: components["parameters"]["EntityId"];
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Популярные треки исполнителя (по прослушиваниям всех пользователей) */
|
||||||
|
get: operations["listArtistTopTracks"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/albums/{id}": {
|
"/api/v1/albums/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3149,6 +3168,31 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
default: components["responses"]["Problem"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listArtistTopTracks: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: components["parameters"]["EntityId"];
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Треки, самые слушаемые первыми */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TrackList"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getAlbum: {
|
getAlbum: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -327,5 +327,9 @@
|
|||||||
"rejected": "Not accepted",
|
"rejected": "Not accepted",
|
||||||
"error": "Error"
|
"error": "Error"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"server_settings": {
|
||||||
|
"title": "Server settings",
|
||||||
|
"subtitle": "Settings that apply to all users"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,8 @@
|
|||||||
"uploads": "Uploads",
|
"uploads": "Uploads",
|
||||||
"suggest": "Suggest tracks",
|
"suggest": "Suggest tracks",
|
||||||
"my_uploads": "My uploads",
|
"my_uploads": "My uploads",
|
||||||
"upload_queue": "Upload queue"
|
"upload_queue": "Queue",
|
||||||
|
"admin_settings": "Settings"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"menu": "Profile menu",
|
"menu": "Profile menu",
|
||||||
@@ -70,7 +71,17 @@
|
|||||||
"repeat_one": "Repeat track",
|
"repeat_one": "Repeat track",
|
||||||
"progress": "Position",
|
"progress": "Position",
|
||||||
"unavailable": "Track is unavailable — skipping",
|
"unavailable": "Track is unavailable — skipping",
|
||||||
"error": "Could not play the track"
|
"error": "Could not play the track",
|
||||||
|
"source_album": "Album · {{label}}",
|
||||||
|
"source_artist": "Artist · {{label}}",
|
||||||
|
"source_search": "Search · {{label}}",
|
||||||
|
"source_history": "History",
|
||||||
|
"source_likes": "Likes",
|
||||||
|
"source_dislikes": "Dislikes",
|
||||||
|
"source_wave": "My Wave",
|
||||||
|
"source_queue": "Queue",
|
||||||
|
"queue": "Queue",
|
||||||
|
"source_wave_seed": "Artist wave · {{label}}"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -96,7 +107,13 @@
|
|||||||
"logout_all": "Sign out everywhere",
|
"logout_all": "Sign out everywhere",
|
||||||
"wave_variant": "Wave style",
|
"wave_variant": "Wave style",
|
||||||
"wave_orb": "Orb",
|
"wave_orb": "Orb",
|
||||||
"wave_liquid": "Liquid"
|
"wave_liquid": "Liquid",
|
||||||
|
"player_view": "Player style",
|
||||||
|
"player_bar": "Bar",
|
||||||
|
"player_island": "Island",
|
||||||
|
"ambient": "Ambient background",
|
||||||
|
"on": "On",
|
||||||
|
"off": "Off"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"show_password": "Show password",
|
"show_password": "Show password",
|
||||||
@@ -146,7 +163,10 @@
|
|||||||
"col_duration": "Time",
|
"col_duration": "Time",
|
||||||
"back_home": "Back home",
|
"back_home": "Back home",
|
||||||
"artist": "Artist",
|
"artist": "Artist",
|
||||||
"album": "Album"
|
"album": "Album",
|
||||||
|
"play": "Play",
|
||||||
|
"artist_wave": "Artist wave",
|
||||||
|
"top_tracks": "Popular tracks"
|
||||||
},
|
},
|
||||||
"collection": {
|
"collection": {
|
||||||
"title": "Collection",
|
"title": "Collection",
|
||||||
@@ -161,7 +181,14 @@
|
|||||||
"empty_likes": "Tap the heart on tracks — they will gather here",
|
"empty_likes": "Tap the heart on tracks — they will gather here",
|
||||||
"empty_dislikes": "Disliked tracks stay out of the wave. None so far",
|
"empty_dislikes": "Disliked tracks stay out of the wave. None so far",
|
||||||
"play_all": "Play all",
|
"play_all": "Play all",
|
||||||
"more": "Show more"
|
"more": "Show more",
|
||||||
|
"sub_history": "Everything you listened to, by day. Click a track to play it.",
|
||||||
|
"sub_likes_one": "{{count}} track · they shape My Wave the most",
|
||||||
|
"sub_likes_few": "{{count}} tracks · they shape My Wave the most",
|
||||||
|
"sub_likes_many": "{{count}} tracks · they shape My Wave the most",
|
||||||
|
"sub_likes_other": "{{count}} tracks · they shape My Wave the most",
|
||||||
|
"sub_dislikes": "These tracks never get into the wave or recommendations. Remove the dislike if you change your mind.",
|
||||||
|
"shuffle": "Shuffle"
|
||||||
},
|
},
|
||||||
"wave": {
|
"wave": {
|
||||||
"title": "My Wave",
|
"title": "My Wave",
|
||||||
|
|||||||
@@ -327,5 +327,9 @@
|
|||||||
"rejected": "Не принят",
|
"rejected": "Не принят",
|
||||||
"error": "Ошибка"
|
"error": "Ошибка"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"server_settings": {
|
||||||
|
"title": "Настройки сервера",
|
||||||
|
"subtitle": "Параметры, которые действуют для всех пользователей"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,8 @@
|
|||||||
"uploads": "Предложения",
|
"uploads": "Предложения",
|
||||||
"suggest": "Предложить треки",
|
"suggest": "Предложить треки",
|
||||||
"my_uploads": "Мои предложения",
|
"my_uploads": "Мои предложения",
|
||||||
"upload_queue": "Очередь на добавление"
|
"upload_queue": "Очередь",
|
||||||
|
"admin_settings": "Настройки"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"menu": "Меню профиля",
|
"menu": "Меню профиля",
|
||||||
@@ -70,7 +71,17 @@
|
|||||||
"repeat_one": "Повторять трек",
|
"repeat_one": "Повторять трек",
|
||||||
"progress": "Позиция",
|
"progress": "Позиция",
|
||||||
"unavailable": "Трек недоступен — пропускаем",
|
"unavailable": "Трек недоступен — пропускаем",
|
||||||
"error": "Не удалось воспроизвести трек"
|
"error": "Не удалось воспроизвести трек",
|
||||||
|
"source_album": "Альбом · {{label}}",
|
||||||
|
"source_artist": "Исполнитель · {{label}}",
|
||||||
|
"source_search": "Поиск · {{label}}",
|
||||||
|
"source_history": "История",
|
||||||
|
"source_likes": "Лайки",
|
||||||
|
"source_dislikes": "Дизлайки",
|
||||||
|
"source_wave": "Моя волна",
|
||||||
|
"source_queue": "Очередь",
|
||||||
|
"queue": "Очередь",
|
||||||
|
"source_wave_seed": "Волна по исполнителю · {{label}}"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
@@ -96,7 +107,13 @@
|
|||||||
"logout_all": "Выйти на всех устройствах",
|
"logout_all": "Выйти на всех устройствах",
|
||||||
"wave_variant": "Вид волны",
|
"wave_variant": "Вид волны",
|
||||||
"wave_orb": "Сфера",
|
"wave_orb": "Сфера",
|
||||||
"wave_liquid": "Жидкая"
|
"wave_liquid": "Жидкая",
|
||||||
|
"player_view": "Вид плеера",
|
||||||
|
"player_bar": "Полоса",
|
||||||
|
"player_island": "Остров",
|
||||||
|
"ambient": "Живой фон",
|
||||||
|
"on": "Вкл.",
|
||||||
|
"off": "Выкл."
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"show_password": "Показать пароль",
|
"show_password": "Показать пароль",
|
||||||
@@ -146,7 +163,10 @@
|
|||||||
"col_duration": "Время",
|
"col_duration": "Время",
|
||||||
"back_home": "На главную",
|
"back_home": "На главную",
|
||||||
"artist": "Исполнитель",
|
"artist": "Исполнитель",
|
||||||
"album": "Альбом"
|
"album": "Альбом",
|
||||||
|
"play": "Слушать",
|
||||||
|
"artist_wave": "Волна по исполнителю",
|
||||||
|
"top_tracks": "Популярные треки"
|
||||||
},
|
},
|
||||||
"collection": {
|
"collection": {
|
||||||
"title": "Коллекция",
|
"title": "Коллекция",
|
||||||
@@ -161,7 +181,14 @@
|
|||||||
"empty_likes": "Отмечайте треки сердечком — они соберутся здесь",
|
"empty_likes": "Отмечайте треки сердечком — они соберутся здесь",
|
||||||
"empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет",
|
"empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет",
|
||||||
"play_all": "Слушать всё",
|
"play_all": "Слушать всё",
|
||||||
"more": "Показать ещё"
|
"more": "Показать ещё",
|
||||||
|
"sub_history": "Всё, что вы слушали, по дням. Нажмите на трек, чтобы включить.",
|
||||||
|
"sub_likes_one": "{{count}} трек · влияют на «Мою волну» сильнее всего",
|
||||||
|
"sub_likes_few": "{{count}} трека · влияют на «Мою волну» сильнее всего",
|
||||||
|
"sub_likes_many": "{{count}} треков · влияют на «Мою волну» сильнее всего",
|
||||||
|
"sub_likes_other": "{{count}} трека · влияют на «Мою волну» сильнее всего",
|
||||||
|
"sub_dislikes": "Эти треки не попадают в волну и рекомендации. Снимите дизлайк, если передумали.",
|
||||||
|
"shuffle": "Перемешать"
|
||||||
},
|
},
|
||||||
"wave": {
|
"wave": {
|
||||||
"title": "Моя волна",
|
"title": "Моя волна",
|
||||||
|
|||||||
@@ -1266,6 +1266,11 @@ type ListUsersParams struct {
|
|||||||
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
|
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListArtistTopTracksParams defines parameters for ListArtistTopTracks.
|
||||||
|
type ListArtistTopTracksParams struct {
|
||||||
|
Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshTokensParams defines parameters for RefreshTokens.
|
// RefreshTokensParams defines parameters for RefreshTokens.
|
||||||
type RefreshTokensParams struct {
|
type RefreshTokensParams struct {
|
||||||
PipuRt *string `form:"pipu_rt,omitempty" json:"pipu_rt,omitempty"`
|
PipuRt *string `form:"pipu_rt,omitempty" json:"pipu_rt,omitempty"`
|
||||||
@@ -1517,6 +1522,9 @@ type ServerInterface interface {
|
|||||||
// Альбомы исполнителя (свои и сборники с его треками)
|
// Альбомы исполнителя (свои и сборники с его треками)
|
||||||
// (GET /api/v1/artists/{id}/albums)
|
// (GET /api/v1/artists/{id}/albums)
|
||||||
ListArtistAlbums(w http.ResponseWriter, r *http.Request, id EntityId)
|
ListArtistAlbums(w http.ResponseWriter, r *http.Request, id EntityId)
|
||||||
|
// Популярные треки исполнителя (по прослушиваниям всех пользователей)
|
||||||
|
// (GET /api/v1/artists/{id}/top-tracks)
|
||||||
|
ListArtistTopTracks(w http.ResponseWriter, r *http.Request, id EntityId, params ListArtistTopTracksParams)
|
||||||
// Вход по логину и паролю
|
// Вход по логину и паролю
|
||||||
// (POST /api/v1/auth/login)
|
// (POST /api/v1/auth/login)
|
||||||
Login(w http.ResponseWriter, r *http.Request)
|
Login(w http.ResponseWriter, r *http.Request)
|
||||||
@@ -1835,6 +1843,12 @@ func (_ Unimplemented) ListArtistAlbums(w http.ResponseWriter, r *http.Request,
|
|||||||
w.WriteHeader(http.StatusNotImplemented)
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Популярные треки исполнителя (по прослушиваниям всех пользователей)
|
||||||
|
// (GET /api/v1/artists/{id}/top-tracks)
|
||||||
|
func (_ Unimplemented) ListArtistTopTracks(w http.ResponseWriter, r *http.Request, id EntityId, params ListArtistTopTracksParams) {
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
// Вход по логину и паролю
|
// Вход по логину и паролю
|
||||||
// (POST /api/v1/auth/login)
|
// (POST /api/v1/auth/login)
|
||||||
func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) {
|
func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -3086,6 +3100,48 @@ func (siw *ServerInterfaceWrapper) ListArtistAlbums(w http.ResponseWriter, r *ht
|
|||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListArtistTopTracks operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) ListArtistTopTracks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// ------------- Path parameter "id" -------------
|
||||||
|
var id EntityId
|
||||||
|
|
||||||
|
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||||
|
if err != nil {
|
||||||
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
// Parameter object where we will unmarshal all parameters from the context
|
||||||
|
var params ListArtistTopTracksParams
|
||||||
|
|
||||||
|
// ------------- Optional query parameter "limit" -------------
|
||||||
|
|
||||||
|
err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit)
|
||||||
|
if err != nil {
|
||||||
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.ListArtistTopTracks(w, r, id, params)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// Login operation middleware
|
// Login operation middleware
|
||||||
func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) {
|
func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
@@ -4399,6 +4455,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Get(options.BaseURL+"/api/v1/artists/{id}/albums", wrapper.ListArtistAlbums)
|
r.Get(options.BaseURL+"/api/v1/artists/{id}/albums", wrapper.ListArtistAlbums)
|
||||||
})
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Get(options.BaseURL+"/api/v1/artists/{id}/top-tracks", wrapper.ListArtistTopTracks)
|
||||||
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Post(options.BaseURL+"/api/v1/auth/login", wrapper.Login)
|
r.Post(options.BaseURL+"/api/v1/auth/login", wrapper.Login)
|
||||||
})
|
})
|
||||||
@@ -5485,6 +5544,36 @@ func (response ListArtistAlbumsdefaultApplicationProblemPlusJSONResponse) VisitL
|
|||||||
return json.NewEncoder(w).Encode(response.Body)
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ListArtistTopTracksRequestObject struct {
|
||||||
|
Id EntityId `json:"id"`
|
||||||
|
Params ListArtistTopTracksParams
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListArtistTopTracksResponseObject interface {
|
||||||
|
VisitListArtistTopTracksResponse(w http.ResponseWriter) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListArtistTopTracks200JSONResponse TrackList
|
||||||
|
|
||||||
|
func (response ListArtistTopTracks200JSONResponse) VisitListArtistTopTracksResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListArtistTopTracksdefaultApplicationProblemPlusJSONResponse struct {
|
||||||
|
Body Problem
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response ListArtistTopTracksdefaultApplicationProblemPlusJSONResponse) VisitListArtistTopTracksResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/problem+json")
|
||||||
|
w.WriteHeader(response.StatusCode)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
|
}
|
||||||
|
|
||||||
type LoginRequestObject struct {
|
type LoginRequestObject struct {
|
||||||
Body *LoginJSONRequestBody
|
Body *LoginJSONRequestBody
|
||||||
}
|
}
|
||||||
@@ -6681,6 +6770,9 @@ type StrictServerInterface interface {
|
|||||||
// Альбомы исполнителя (свои и сборники с его треками)
|
// Альбомы исполнителя (свои и сборники с его треками)
|
||||||
// (GET /api/v1/artists/{id}/albums)
|
// (GET /api/v1/artists/{id}/albums)
|
||||||
ListArtistAlbums(ctx context.Context, request ListArtistAlbumsRequestObject) (ListArtistAlbumsResponseObject, error)
|
ListArtistAlbums(ctx context.Context, request ListArtistAlbumsRequestObject) (ListArtistAlbumsResponseObject, error)
|
||||||
|
// Популярные треки исполнителя (по прослушиваниям всех пользователей)
|
||||||
|
// (GET /api/v1/artists/{id}/top-tracks)
|
||||||
|
ListArtistTopTracks(ctx context.Context, request ListArtistTopTracksRequestObject) (ListArtistTopTracksResponseObject, error)
|
||||||
// Вход по логину и паролю
|
// Вход по логину и паролю
|
||||||
// (POST /api/v1/auth/login)
|
// (POST /api/v1/auth/login)
|
||||||
Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error)
|
Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error)
|
||||||
@@ -7743,6 +7835,33 @@ func (sh *strictHandler) ListArtistAlbums(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListArtistTopTracks operation middleware
|
||||||
|
func (sh *strictHandler) ListArtistTopTracks(w http.ResponseWriter, r *http.Request, id EntityId, params ListArtistTopTracksParams) {
|
||||||
|
var request ListArtistTopTracksRequestObject
|
||||||
|
|
||||||
|
request.Id = id
|
||||||
|
request.Params = params
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||||
|
return sh.ssi.ListArtistTopTracks(ctx, request.(ListArtistTopTracksRequestObject))
|
||||||
|
}
|
||||||
|
for _, middleware := range sh.middlewares {
|
||||||
|
handler = middleware(handler, "ListArtistTopTracks")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := handler(r.Context(), w, r, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
} else if validResponse, ok := response.(ListArtistTopTracksResponseObject); ok {
|
||||||
|
if err := validResponse.VisitListArtistTopTracksResponse(w); err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
}
|
||||||
|
} else if response != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Login operation middleware
|
// Login operation middleware
|
||||||
func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) {
|
func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
var request LoginRequestObject
|
var request LoginRequestObject
|
||||||
@@ -8792,239 +8911,241 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||||
var swaggerSpec = []string{
|
var swaggerSpec = []string{
|
||||||
|
|
||||||
"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",
|
||||||
|
"YSFPodutPb77+P8FAAD//9wKjL6lLQEA",
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSwagger returns the content of the embedded swagger specification file
|
// GetSwagger returns the content of the embedded swagger specification file
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ func (st *Store) TracksByIDs(ctx context.Context, ids []uuid.UUID) ([]TrackRow,
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArtistTopTracks — треки исполнителя (основной или приглашённый), самые слушаемые всеми пользователями первыми.
|
||||||
|
func (st *Store) ArtistTopTracks(ctx context.Context, artistID uuid.UUID, limit int) ([]TrackRow, error) {
|
||||||
|
return st.queryTracks(ctx, `
|
||||||
|
WHERE t.id IN (SELECT track_id FROM track_artists WHERE artist_id = $1)
|
||||||
|
ORDER BY COALESCE((SELECT sum(s.play_count) FROM user_track_stats s WHERE s.track_id = t.id), 0) DESC,
|
||||||
|
t.created_at DESC
|
||||||
|
LIMIT $2`, artistID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
// AlbumTracks — треки альбома по дискам и номерам.
|
// AlbumTracks — треки альбома по дискам и номерам.
|
||||||
func (st *Store) AlbumTracks(ctx context.Context, albumID uuid.UUID) ([]TrackRow, error) {
|
func (st *Store) AlbumTracks(ctx context.Context, albumID uuid.UUID) ([]TrackRow, error) {
|
||||||
return st.queryTracks(ctx, "WHERE t.album_id = $1 ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST, t.title", albumID)
|
return st.queryTracks(ctx, "WHERE t.album_id = $1 ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST, t.title", albumID)
|
||||||
|
|||||||
@@ -469,6 +469,26 @@ func (h *Handler) GetAlbum(ctx context.Context, req gen.GetAlbumRequestObject) (
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListArtistTopTracks реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) ListArtistTopTracks(ctx context.Context, req gen.ListArtistTopTracksRequestObject) (gen.ListArtistTopTracksResponseObject, error) {
|
||||||
|
if _, err := h.store.Artist(ctx, req.Id); err != nil {
|
||||||
|
return nil, catalogErr(err)
|
||||||
|
}
|
||||||
|
limit := 10
|
||||||
|
if req.Params.Limit != nil {
|
||||||
|
limit = min(max(*req.Params.Limit, 1), 50)
|
||||||
|
}
|
||||||
|
rows, err := h.store.ArtistTopTracks(ctx, req.Id, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, err := h.Tracks(ctx, rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.ListArtistTopTracks200JSONResponse{Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetTrack реализует gen.StrictServerInterface.
|
// GetTrack реализует gen.StrictServerInterface.
|
||||||
func (h *Handler) GetTrack(ctx context.Context, req gen.GetTrackRequestObject) (gen.GetTrackResponseObject, error) {
|
func (h *Handler) GetTrack(ctx context.Context, req gen.GetTrackRequestObject) (gen.GetTrackResponseObject, error) {
|
||||||
t, err := h.store.Track(ctx, req.Id)
|
t, err := h.store.Track(ctx, req.Id)
|
||||||
|
|||||||
Reference in new issue
Block a user