From 976ffb574b1dd32eff9d41d31bf62c4255d3e925 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:11:12 +0300 Subject: [PATCH] feat(web): player bar, playable track lists and Collection (M3) - player bar with cover, like and dislike, previous / play / next, shuffle, repeat, seek bar and volume; PlayerProvider lives with the signed-in shell and restores the last track and position paused - clicking a row (or Enter) plays the list from that track with its context (album, search, history, collection); the current track is highlighted and rows have a like button; a reaction refreshes lists - Collection: history grouped by day with clearing, likes with "play all" and dislikes, all with paging; sidebar section - events and playback state are flushed when the page is hidden; player timers survive StrictMode remounts Co-Authored-By: Claude Opus 5.5 --- apps/web/src/App.test.tsx | 40 +++++ apps/web/src/app/router.tsx | 22 +++ apps/web/src/entities/catalog.module.css | 58 +++++++ apps/web/src/entities/catalog.tsx | 64 ++++++- apps/web/src/pages/AlbumPage.tsx | 2 +- apps/web/src/pages/CollectionPage.tsx | 175 +++++++++++++++++++ apps/web/src/pages/SearchPage.tsx | 2 +- apps/web/src/player/context.tsx | 4 + apps/web/src/player/player.ts | 11 +- apps/web/src/test/fakeApi.ts | 35 ++++ apps/web/src/test/setup.ts | 18 ++ apps/web/src/widgets/AppShell.module.css | 29 ---- apps/web/src/widgets/AppShell.tsx | 36 +++- apps/web/src/widgets/PlayerBar.module.css | 194 ++++++++++++++++++++++ apps/web/src/widgets/PlayerBar.tsx | 162 ++++++++++++++++++ 15 files changed, 803 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/pages/CollectionPage.tsx create mode 100644 apps/web/src/widgets/PlayerBar.module.css create mode 100644 apps/web/src/widgets/PlayerBar.tsx diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 7fe0f97..306eb78 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -245,6 +245,46 @@ describe('search box', () => { }); }); +describe('player', () => { + it('plays an album from a clicked row, likes and pauses with the keyboard', async () => { + const alice = makeUser('alice', 'user'); + const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice }); + + const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!; + await user.click(row); + const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); + await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument()); + expect(row).toHaveAttribute('aria-current', 'true'); + expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument(); + + await user.click(within(bar).getByRole('button', { name: 'Нравится' })); + await waitFor(() => + expect(api.calls).toContainEqual({ + method: 'PUT', + path: '/api/v1/me/reactions/tracks/00000000-0000-4000-8000-000000000101', + body: { reaction: 'like' }, + }), + ); + expect(within(bar).getByRole('button', { name: 'Убрать из лайков' })).toHaveAttribute('aria-pressed', 'true'); + + // Пробел на кнопке нажимает её саму; горячая клавиша работает, когда фокус не на элементе управления. + (document.activeElement as HTMLElement).blur(); + await user.keyboard(' '); + expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument(); + + // Уход со страницы досылает события и сохраняет состояние плеера. + window.dispatchEvent(new Event('pagehide')); + await waitFor(() => { + const sent = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events); + expect(sent.find((e) => e.type === 'play_start')).toMatchObject({ context_type: 'album' }); + }); + expect(api.calls.find((c) => c.method === 'PUT' && c.path === '/api/v1/me/playback-state')?.body).toMatchObject({ + track_id: '00000000-0000-4000-8000-000000000101', + queue_index: 1, + }); + }); +}); + describe('safeRedirect', () => { it.each([ ['/settings', '/settings'], diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index d523675..8b721e2 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -15,6 +15,7 @@ import { SourcesPage } from '../pages/admin/SourcesPage'; import { UsersPage } from '../pages/admin/UsersPage'; import { AlbumPage } from '../pages/AlbumPage'; import { ArtistPage } from '../pages/ArtistPage'; +import { CollectionPage } from '../pages/CollectionPage'; import { HomePage } from '../pages/HomePage'; import { LoginPage } from '../pages/LoginPage'; import { SearchPage, type SearchType } from '../pages/SearchPage'; @@ -126,6 +127,24 @@ const auditRoute = createRoute({ component: AuditPage, }); +const historyRoute = createRoute({ + getParentRoute: () => appRoute, + path: '/collection/history', + component: () => , +}); + +const likesRoute = createRoute({ + getParentRoute: () => appRoute, + path: '/collection/likes', + component: () => , +}); + +const dislikesRoute = createRoute({ + getParentRoute: () => appRoute, + path: '/collection/dislikes', + component: () => , +}); + const searchRoute = createRoute({ getParentRoute: () => appRoute, path: '/search', @@ -194,6 +213,9 @@ const routeTree = rootRoute.addChildren([ homeRoute, settingsRoute, searchRoute, + historyRoute, + likesRoute, + dislikesRoute, artistRoute, albumRoute, usersRoute, diff --git a/apps/web/src/entities/catalog.module.css b/apps/web/src/entities/catalog.module.css index 03cb3ac..b8ab073 100644 --- a/apps/web/src/entities/catalog.module.css +++ b/apps/web/src/entities/catalog.module.css @@ -119,12 +119,70 @@ } .num { + position: relative; display: grid; place-items: center; color: var(--text3); font-variant-numeric: tabular-nums; } +.playHint { + position: absolute; + inset: 0; + display: none; + place-items: center; + border-radius: 8px; + background: rgba(5, 7, 13, 0.55); + color: #fff; +} + +.track:not(.unavailable) { + cursor: pointer; +} + +.track:hover .playHint, +.track:focus-visible .playHint { + display: grid; +} + +.current { + background: var(--accent-soft); +} + +.current .trackTitle { + color: var(--accent2); +} + +.end { + display: flex; + align-items: center; + gap: 10px; +} + +.like { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + color: var(--text3); + opacity: 0; +} + +.track:hover .like, +.track:focus-within .like, +.liked { + opacity: 1; +} + +.liked { + color: var(--accent2); +} + +.rowMeta { + color: var(--text3); +} + .trackMain { display: flex; flex-direction: column; diff --git a/apps/web/src/entities/catalog.tsx b/apps/web/src/entities/catalog.tsx index f5b75cd..a414507 100644 --- a/apps/web/src/entities/catalog.tsx +++ b/apps/web/src/entities/catalog.tsx @@ -4,7 +4,9 @@ import { Fragment } from 'react'; import { useTranslation } from 'react-i18next'; import { formatDuration } from '../shared/lib/format'; -import { albumIcon, personIcon } from '../shared/ui/icons'; +import { usePlayer, usePlayerState } from '../player/context'; +import type { PlayContext } from '../player/player'; +import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon } from '../shared/ui/icons'; import { cx, Icon } from '../shared/ui/ui'; import s from './catalog.module.css'; @@ -101,11 +103,26 @@ export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) { } /** - * Список треков. showAlbum — колонка альбома (поиск); иначе номер трека (страница альбома). - * Воспроизведение появится в M3: пока строка только показывает трек. + * Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома). + * Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры. */ -export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; showAlbum?: boolean; numbered?: boolean }) { +export function TrackList({ + tracks, + showAlbum, + numbered, + context, + rowMeta, +}: { + tracks: Track[]; + showAlbum?: boolean; + numbered?: boolean; + context: PlayContext; + /** Дополнительный текст под исполнителями (например, время прослушивания в истории). */ + rowMeta?: (t: Track, i: number) => string | undefined; +}) { const { t } = useTranslation(); + const player = usePlayer(); + const current = usePlayerState().track; const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1)); let lastDisc: number | null = null; return ( @@ -114,19 +131,39 @@ export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; sh const disc = tr.disc_no ?? 1; const header = numbered && discs.size > 1 && disc !== lastDisc; lastDisc = disc; + const isCurrent = current?.id === tr.id; + const play = () => tr.available && player.playList(tracks, i, context); + const liked = tr.reaction === 'like'; + const meta = rowMeta?.(tr, i); return ( - + {header &&
{t('catalog.disc', { n: disc })}
}
{ + if (!(e.target as HTMLElement).closest('a, button')) play(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && e.target === e.currentTarget) play(); + }} > - {numbered ? (tr.track_no ?? i + 1) : } + + {numbered ? (tr.track_no ?? i + 1) : } + {tr.available && ( + + + + )} + {tr.title} + {meta && · {meta}} {showAlbum && ( @@ -134,7 +171,18 @@ export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; sh {tr.album.title} )} - {formatDuration(tr.duration_ms)} + + + {formatDuration(tr.duration_ms)} +
); diff --git a/apps/web/src/pages/AlbumPage.tsx b/apps/web/src/pages/AlbumPage.tsx index 7c92e76..3f2db59 100644 --- a/apps/web/src/pages/AlbumPage.tsx +++ b/apps/web/src/pages/AlbumPage.tsx @@ -47,7 +47,7 @@ export function AlbumPage({ id }: { id: string }) { )} - {a && } + {a && } ); } diff --git a/apps/web/src/pages/CollectionPage.tsx b/apps/web/src/pages/CollectionPage.tsx new file mode 100644 index 0000000..77bc48b --- /dev/null +++ b/apps/web/src/pages/CollectionPage.tsx @@ -0,0 +1,175 @@ +import { unwrap } from '@pipu/core'; +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useSession } from '../app/session'; +import { TrackList, type Track } from '../entities/catalog'; +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 c from './catalog.module.css'; +import s from './page.module.css'; + +export type CollectionTab = 'history' | 'likes' | 'dislikes'; +const TABS: CollectionTab[] = ['history', 'likes', 'dislikes']; + +export function CollectionPage({ tab }: { tab: CollectionTab }) { + const { t } = useTranslation(); + return ( +
+
+

{t('collection.title')}

+
+
+ {TABS.map((k) => ( + + {t(`collection.${k}`)} + + ))} +
+ {tab === 'history' ? : } +
+ ); +} + +/** Ключ дня в локальном времени: история группируется на клиенте. */ +function dayKey(iso: string): string { + const d = new Date(iso); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; +} + +function dayTitle(iso: string, locale: string, t: (k: 'collection.today' | 'collection.yesterday') => string): string { + const d = new Date(iso); + const today = new Date(); + const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1); + if (dayKey(iso) === dayKey(today.toISOString())) return t('collection.today'); + if (dayKey(iso) === dayKey(yesterday.toISOString())) return t('collection.yesterday'); + return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }).format(d); +} + +function History() { + const { t, i18n } = useTranslation(); + const session = useSession(); + const queryClient = useQueryClient(); + const errorText = useErrorText(); + const [confirm, setConfirm] = useState(false); + const history = useInfiniteQuery({ + queryKey: ['history'], + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => + unwrap(await session.client.GET('/api/v1/me/history', { params: { query: { cursor: pageParam, limit: 100 } } })), + getNextPageParam: (p) => p.next_cursor ?? undefined, + }); + const clear = useMutation({ + mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/history')), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history'] }), + }); + const items = history.data?.pages.flatMap((p) => p.items) ?? []; + + const groups: { key: string; title: string; items: typeof items }[] = []; + for (const it of items) { + const key = dayKey(it.played_at); + const last = groups.at(-1); + if (last?.key === key) last.items.push(it); + else groups.push({ key, title: dayTitle(it.played_at, i18n.language, t), items: [it] }); + } + const time = new Intl.DateTimeFormat(i18n.language, { hour: '2-digit', minute: '2-digit' }); + + return ( + <> + {items.length > 0 && ( +
+ + +
+ )} + {history.error || clear.error ? errorText(history.error ?? clear.error) : null} + {history.isSuccess && items.length === 0 &&
{t('collection.empty_history')}
} + {groups.map((g) => ( +
+

{g.title}

+ i.track)} + context={{ type: 'history' }} + rowMeta={(_, i) => time.format(new Date(g.items[i]!.played_at))} + /> +
+ ))} + {history.hasNextPage && ( + + )} + {confirm && ( + setConfirm(false)} + footer={ + <> + + + + } + > +

{t('collection.clear_confirm')}

+
+ )} + + ); +} + +function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) { + const { t } = useTranslation(); + const session = useSession(); + const player = usePlayer(); + const errorText = useErrorText(); + const path = reaction === 'like' ? '/api/v1/me/likes' : '/api/v1/me/dislikes'; + const list = useInfiniteQuery({ + queryKey: [reaction === 'like' ? 'likes' : 'dislikes'], + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => + unwrap(await session.client.GET(path, { params: { query: { cursor: pageParam, limit: 100 } } })), + getNextPageParam: (p) => p.next_cursor ?? undefined, + }); + const tracks: Track[] = list.data?.pages.flatMap((p) => p.items.map((i) => i.track)) ?? []; + const total = list.data?.pages[0]?.total ?? 0; + const context = { type: 'collection' as const, id: reaction === 'like' ? 'likes' : 'dislikes' }; + + return ( + <> + {reaction === 'like' && tracks.length > 0 && ( +
+ + {t('catalog.track_count', { count: total })} +
+ )} + {list.error ? errorText(list.error) : null} + {list.isSuccess && tracks.length === 0 && ( +
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
+ )} + {tracks.length > 0 && } + {list.hasNextPage && ( + + )} + + ); +} diff --git a/apps/web/src/pages/SearchPage.tsx b/apps/web/src/pages/SearchPage.tsx index 6a93e6f..d7018c4 100644 --- a/apps/web/src/pages/SearchPage.tsx +++ b/apps/web/src/pages/SearchPage.tsx @@ -89,7 +89,7 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) { {tracks.length > 0 && (
{type === 'all' &&

{t('search.tracks')}

} - +
)} {results.hasNextPage && ( diff --git a/apps/web/src/player/context.tsx b/apps/web/src/player/context.tsx index 36c034a..c060e41 100644 --- a/apps/web/src/player/context.tsx +++ b/apps/web/src/player/context.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query'; import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react'; import { useSession } from '../app/session'; @@ -8,9 +9,12 @@ const PlayerContext = createContext(null); /** Плеер живёт, пока пользователь вошёл; горячие клавиши и сохранение при уходе со страницы. */ export function PlayerProvider({ children }: { children: ReactNode }) { const session = useSession(); + const queryClient = useQueryClient(); const [player] = useState(() => new Player(session)); + player.onReaction = () => void queryClient.invalidateQueries(); useEffect(() => { + player.start(); void player.restore().catch(() => undefined); const onHide = () => player.onHide(); const onVisibility = () => document.visibilityState === 'hidden' && onHide(); diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts index 8538bdc..9c7dbf8 100644 --- a/apps/web/src/player/player.ts +++ b/apps/web/src/player/player.ts @@ -75,6 +75,8 @@ export class Player { private preloaded: string | null = null; private refreshedFor: string | null = null; private timers: ReturnType[] = []; + /** Реакция сохранена на сервере — списки на экране нужно перечитать. */ + onReaction: (() => void) | null = null; constructor( private readonly session: AuthSession, @@ -93,6 +95,10 @@ export class Player { }; this.bindAudio(); this.bindMediaSession(); + } + + /** Запуск фоновой отправки событий (парный вызов — dispose). */ + start() { this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS)); } @@ -146,7 +152,8 @@ export class Player { void this.load(true, this.state.positionMs); return; } - void this.audio.play().catch(() => this.update({ playing: false })); + // play() в старых браузерах и jsdom может вернуть undefined вместо промиса. + void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false })); } pause() { @@ -238,6 +245,7 @@ export class Player { apply(prev); throw e; } + this.onReaction?.(); if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next(); } @@ -521,6 +529,7 @@ export class Player { this.audio.pause(); this.audio.removeAttribute('src'); for (const t of this.timers) clearInterval(t); + this.timers = []; void this.events.flush(); } } diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index cfd9eed..9187b8d 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -18,6 +18,20 @@ const problem = (status: number, code: string, extra: object = {}) => json({ sta let seq = 0; const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' }; +const albumRef = { id: '00000000-0000-4000-8000-0000000000bb', title: 'Группа крови', year: 1988, cover_url: null, cover_colors: [] }; +export const albumTracks = ['Группа крови', 'Закрой за мной дверь'].map((title, i) => ({ + id: `00000000-0000-4000-8000-00000000010${i}`, + title, + duration_ms: 200000, + artists: [{ id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' }], + album: albumRef, + genres: [], + disc_no: 1, + track_no: i + 1, + available: true, + stream_url: `/api/v1/stream/00000000-0000-4000-8000-00000000010${i}?sig=x`, + reaction: null as 'like' | 'dislike' | null, +})); const albumSummary = { id: '00000000-0000-4000-8000-0000000000bb', title: 'Группа крови', @@ -56,6 +70,7 @@ export function fakeApi(initial: FakeUser[]) { let current: string | null = null; // id вошедшего (одна сессия на тест) let token = 0; const calls: { method: string; path: string; body: unknown }[] = []; + const reactions = new Map(); const me = (u: FakeUser): Me => ({ id: u.id, @@ -171,11 +186,31 @@ export function fakeApi(initial: FakeUser[]) { next_cursor: null, }); } + case `GET /api/v1/albums/${albumSummary.id}`: + return json({ + ...albumRef, + artist: kino, + duration_ms: 400000, + tracks: albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null })), + }); + case 'POST /api/v1/events': + return json({ accepted: (body?.events as unknown[]).length, duplicates: 0, rejected: 0 }); + case 'GET /api/v1/me/playback-state': + return json({ track_id: null, position_ms: 0, queue: [], queue_index: 0, context: null, shuffle: false, + repeat_mode: 'off', volume: 1, device_id: null, updated_at: null }); + case 'PUT /api/v1/me/playback-state': + return json({ ...body, updated_at: new Date().toISOString() }); case `GET /api/v1/artists/${kino.id}`: return json({ ...kino, album_count: 1, track_count: 0 }); case `GET /api/v1/artists/${kino.id}/albums`: return json({ items: [albumSummary] }); } + const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path); + if (reactionPath) { + if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike'); + else reactions.delete(reactionPath[1]!); + return new Response(null, { status: 204 }); + } return problem(404, 'common.not_found'); }; diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index bb02c60..fdefaa4 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -1 +1,19 @@ import '@testing-library/jest-dom/vitest'; + +// jsdom не умеет играть звук: play/pause только переключают флаг и шлют события, как браузер. +Object.defineProperty(HTMLMediaElement.prototype, 'play', { + configurable: true, + value(this: HTMLMediaElement) { + Object.defineProperty(this, 'paused', { configurable: true, value: false }); + this.dispatchEvent(new Event('playing')); + return Promise.resolve(); + }, +}); +Object.defineProperty(HTMLMediaElement.prototype, 'pause', { + configurable: true, + value(this: HTMLMediaElement) { + Object.defineProperty(this, 'paused', { configurable: true, value: true }); + this.dispatchEvent(new Event('pause')); + }, +}); +Object.defineProperty(HTMLMediaElement.prototype, 'load', { configurable: true, value() {} }); diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css index 0aea899..db83706 100644 --- a/apps/web/src/widgets/AppShell.module.css +++ b/apps/web/src/widgets/AppShell.module.css @@ -129,32 +129,6 @@ animation: pipuIn 0.2s ease-out; } -.player { - grid-area: player; - display: flex; - align-items: center; - gap: 14px; - padding: 0 20px; - border-top: 1px solid var(--line); - background: var(--bar-a); - backdrop-filter: blur(20px); -} - -.cover { - display: grid; - place-items: center; - width: 52px; - height: 52px; - border-radius: 10px; - background: var(--surface2); - color: var(--text3); -} - -.playerEmpty { - color: var(--text3); - font-size: 14px; -} - @media (max-width: 1023px) { .shell { grid-template-columns: 72px 1fr; @@ -205,7 +179,4 @@ padding: 20px 16px; } - .player { - padding: 0 16px; - } } diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 667e135..3f9122e 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -12,17 +12,32 @@ import { libraryFillIcon, libraryIcon, logoutIcon, - musicIcon, + historyFillIcon, + historyIcon, + likeFillIcon, + likeIcon, + dislikeFillIcon, + dislikeIcon, settingsIcon, storageFillIcon, storageIcon, } from '../shared/ui/icons'; import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui'; import { LanguageSwitch } from './LanguageSwitch'; +import { PlayerProvider } from '../player/context'; +import { PlayerBar } from './PlayerBar'; import { SearchBox } from './SearchBox'; import s from './AppShell.module.css'; -type NavPath = '/' | '/admin/users' | '/admin/audit' | '/admin/sources' | '/admin/library'; +type NavPath = + | '/' + | '/admin/users' + | '/admin/audit' + | '/admin/sources' + | '/admin/library' + | '/collection/history' + | '/collection/likes' + | '/collection/dislikes'; function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) { return ( @@ -39,7 +54,11 @@ function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; /** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */ export function AppShell() { - return useAuthState().status === 'signed_in' ? : null; + return useAuthState().status === 'signed_in' ? ( + + + + ) : null; } function Shell() { @@ -84,6 +103,10 @@ function Shell() {