From 1c4e209e86dcecea4198d4527efca92d17dff81a Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:13:24 +0300 Subject: [PATCH] feat(web): align the web UI with the design prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/openapi.yaml | 24 + apps/web/src/app/router.tsx | 9 + apps/web/src/entities/catalog.module.css | 50 ++ apps/web/src/entities/catalog.tsx | 59 +- apps/web/src/entities/collection.ts | 17 + apps/web/src/pages/AlbumPage.tsx | 38 +- apps/web/src/pages/ArtistPage.tsx | 39 +- apps/web/src/pages/CollectionPage.tsx | 58 +- apps/web/src/pages/SettingsPage.tsx | 24 + .../web/src/pages/admin/AdminSettingsPage.tsx | 20 + apps/web/src/pages/admin/UploadQueuePage.tsx | 4 +- apps/web/src/pages/catalog.module.css | 104 ++++ apps/web/src/player/player.ts | 8 +- apps/web/src/shared/ui/icons.ts | 5 + apps/web/src/styles.css | 10 + apps/web/src/widgets/Ambient.module.css | 108 ++++ apps/web/src/widgets/Ambient.tsx | 17 + apps/web/src/widgets/AppShell.module.css | 51 ++ apps/web/src/widgets/AppShell.tsx | 90 ++- apps/web/src/widgets/PlayerBar.module.css | 97 +++ apps/web/src/widgets/PlayerBar.tsx | 146 +++-- packages/api-client/src/generated/schema.ts | 44 ++ packages/i18n/locales/en/admin.json | 4 + packages/i18n/locales/en/common.json | 37 +- packages/i18n/locales/ru/admin.json | 4 + packages/i18n/locales/ru/common.json | 37 +- services/api/internal/api/gen/api.gen.go | 587 +++++++++++------- services/api/internal/library/catalog.go | 9 + services/api/internal/library/handler.go | 20 + 29 files changed, 1381 insertions(+), 339 deletions(-) create mode 100644 apps/web/src/entities/collection.ts create mode 100644 apps/web/src/pages/admin/AdminSettingsPage.tsx create mode 100644 apps/web/src/widgets/Ambient.module.css create mode 100644 apps/web/src/widgets/Ambient.tsx diff --git a/api/openapi.yaml b/api/openapi.yaml index abf8747..6feb699 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -894,6 +894,30 @@ paths: $ref: "#/components/schemas/AlbumSummaryList" default: $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}: parameters: - $ref: "#/components/parameters/EntityId" diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index e5007da..4dc245c 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -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, ]), ]); diff --git a/apps/web/src/entities/catalog.module.css b/apps/web/src/entities/catalog.module.css index b8ab073..e545aeb 100644 --- a/apps/web/src/entities/catalog.module.css +++ b/apps/web/src/entities/catalog.module.css @@ -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); } diff --git a/apps/web/src/entities/catalog.tsx b/apps/web/src/entities/catalog.tsx index a414507..fd4168b 100644 --- a/apps/web/src/entities/catalog.tsx +++ b/apps/web/src/entities/catalog.tsx @@ -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(); }} > - - {numbered ? (tr.track_no ?? i + 1) : } + {lead && {isCurrent ? : lead(tr, i)}} + + {numbered ? (tr.track_no ?? i + 1) : } {tr.available && ( )} - + {tr.title} @@ -172,15 +179,26 @@ export function TrackList({ )} - + {disliked ? ( + + ) : ( + + )} {formatDuration(tr.duration_ms)} @@ -191,6 +209,17 @@ export function TrackList({ ); } +/** Эквалайзер текущего трека (`pipuEq`, docs/design.md#2): три столбика, на паузе замирают. */ +export function Equalizer({ playing }: { playing: boolean }) { + return ( + + + + + + ); +} + export function ArtistChip({ artist }: { artist: ArtistRef }) { const { t } = useTranslation(); return ( diff --git a/apps/web/src/entities/collection.ts b/apps/web/src/entities/collection.ts new file mode 100644 index 0000000..54610e7 --- /dev/null +++ b/apps/web/src/entities/collection.ts @@ -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 }; +} diff --git a/apps/web/src/pages/AlbumPage.tsx b/apps/web/src/pages/AlbumPage.tsx index 3f2db59..3425f99 100644 --- a/apps/web/src/pages/AlbumPage.tsx +++ b/apps/web/src/pages/AlbumPage.tsx @@ -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 ; 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 (
- {colors && ( - - )} + {colors && }
{t('catalog.album')} @@ -41,13 +40,32 @@ export function AlbumPage({ id }: { id: string }) { {a.artist.name} - {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)} )} + {a && a.tracks.some((tr) => tr.available) && ( +
+ + +
+ )}
- {a && } + {a && }
); } diff --git a/apps/web/src/pages/ArtistPage.tsx b/apps/web/src/pages/ArtistPage.tsx index 8150a8d..8d00171 100644 --- a/apps/web/src/pages/ArtistPage.tsx +++ b/apps/web/src/pages/ArtistPage.tsx @@ -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 ; 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 })}
)} + {a && ( +
+ + {tracks.length > 0 && ( + + )} +
+ )} + {tracks.length > 0 && ( +
+

{t('catalog.top_tracks')}

+ String(i + 1)} /> +
+ )} {own.length > 0 && (

{t('catalog.albums')}

diff --git a/apps/web/src/pages/CollectionPage.tsx b/apps/web/src/pages/CollectionPage.tsx index 77bc48b..738b1fb 100644 --- a/apps/web/src/pages/CollectionPage.tsx +++ b/apps/web/src/pages/CollectionPage.tsx @@ -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 = { + 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 (
-
-

{t('collection.title')}

-
-
+
+ + + +
+ {t('collection.title')} +

{t(`collection.${tab}`)}

+ {sub} +
+
+
{TABS.map((k) => ( - + {t(`collection.${k}`)} ))} @@ -92,11 +112,12 @@ function History() { {history.isSuccess && items.length === 0 &&
{t('collection.empty_history')}
} {groups.map((g) => (
-

{g.title}

+

{g.title}

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))} />
))} @@ -154,9 +175,16 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) { <> {reaction === 'like' && tracks.length > 0 && (
- + {t('catalog.track_count', { count: total })}
)} @@ -164,7 +192,15 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) { {list.isSuccess && tracks.length === 0 && (
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
)} - {tracks.length > 0 && } + {tracks.length > 0 && ( + String(i + 1)} + disliked={reaction === 'dislike'} + /> + )} {list.hasNextPage && (
+
+ {t('settings.player_view')} + + 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') }, + ]} + /> +
+
+ {t('settings.ambient')} + + 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') }, + ]} + /> +
{t('language.label')} diff --git a/apps/web/src/pages/admin/AdminSettingsPage.tsx b/apps/web/src/pages/admin/AdminSettingsPage.tsx new file mode 100644 index 0000000..5764fbe --- /dev/null +++ b/apps/web/src/pages/admin/AdminSettingsPage.tsx @@ -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 ( +
+
+
+

{t('server_settings.title')}

+

{t('server_settings.subtitle')}

+
+
+ +
+ ); +} diff --git a/apps/web/src/pages/admin/UploadQueuePage.tsx b/apps/web/src/pages/admin/UploadQueuePage.tsx index 7c6f8a9..5fb9375 100644 --- a/apps/web/src/pages/admin/UploadQueuePage.tsx +++ b/apps/web/src/pages/admin/UploadQueuePage.tsx @@ -158,8 +158,6 @@ export function UploadQueuePage() {
)} - - {editing && ( 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); diff --git a/apps/web/src/shared/ui/icons.ts b/apps/web/src/shared/ui/icons.ts index ece61b9..c184889 100644 --- a/apps/web/src/shared/ui/icons.ts +++ b/apps/web/src/shared/ui/icons.ts @@ -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'; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index f8a6a68..4ba565d 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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); + } +} diff --git a/apps/web/src/widgets/Ambient.module.css b/apps/web/src/widgets/Ambient.module.css new file mode 100644 index 0000000..71e4386 --- /dev/null +++ b/apps/web/src/widgets/Ambient.module.css @@ -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; + } +} diff --git a/apps/web/src/widgets/Ambient.tsx b/apps/web/src/widgets/Ambient.tsx new file mode 100644 index 0000000..96965b4 --- /dev/null +++ b/apps/web/src/widgets/Ambient.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css index db83706..17eda87 100644 --- a/apps/web/src/widgets/AppShell.module.css +++ b/apps/web/src/widgets/AppShell.module.css @@ -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; diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 14a00f1..a6d2739 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -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 ( {({ isActive }) => ( <> - {label} + {label} + {badge ? {badge > 99 ? '99+' : badge} : null} + {!badge && count ? {count} : null} )} @@ -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 ( -
+
+ {user.ui_prefs.ambient !== false && }
@@ -107,24 +132,36 @@ function Shell() {
{t('nav.collection')}
- - + + {staff && ( <> -
{t('nav.uploads')}
- - - {user.role === 'admin' && ( - - )}
{t('nav.admin')}
{user.role === 'admin' && ( )} + + {user.role === 'admin' && ( - + <> + + + + )} )} @@ -134,7 +171,28 @@ function Shell() { - +
); } + +/** Третье пятно фона — в цвете обложки текущего трека. */ +function ShellAmbient() { + const track = usePlayerState().track; + return ; +} + +/** Файлов на рассмотрении — бейдж «Очереди» у админа (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; +} diff --git a/apps/web/src/widgets/PlayerBar.module.css b/apps/web/src/widgets/PlayerBar.module.css index c4cf38a..3750c97 100644 --- a/apps/web/src/widgets/PlayerBar.module.css +++ b/apps/web/src/widgets/PlayerBar.module.css @@ -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); +} diff --git a/apps/web/src/widgets/PlayerBar.tsx b/apps/web/src/widgets/PlayerBar.tsx index 5ec5df5..b7c8c2a 100644 --- a/apps/web/src/widgets/PlayerBar.tsx +++ b/apps/web/src/widgets/PlayerBar.tsx @@ -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 ( -