- 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>
199 lines
6.8 KiB
TypeScript
199 lines
6.8 KiB
TypeScript
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,
|
|
groupFillIcon,
|
|
groupIcon,
|
|
homeFillIcon,
|
|
homeIcon,
|
|
libraryFillIcon,
|
|
libraryIcon,
|
|
logoutIcon,
|
|
historyFillIcon,
|
|
historyIcon,
|
|
likeFillIcon,
|
|
likeIcon,
|
|
dislikeFillIcon,
|
|
dislikeIcon,
|
|
settingsIcon,
|
|
storageFillIcon,
|
|
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, usePlayerState } from '../player/context';
|
|
import { Ambient } from './Ambient';
|
|
import { PlayerBar } from './PlayerBar';
|
|
import { SearchBox } from './SearchBox';
|
|
import s from './AppShell.module.css';
|
|
|
|
type NavPath =
|
|
| '/'
|
|
| '/admin/users'
|
|
| '/admin/audit'
|
|
| '/admin/sources'
|
|
| '/admin/library'
|
|
| '/admin/uploads'
|
|
| '/uploads/new'
|
|
| '/uploads/mine'
|
|
| '/admin/settings'
|
|
| '/collection/history'
|
|
| '/collection/likes'
|
|
| '/collection/dislikes';
|
|
|
|
function NavLink({
|
|
to,
|
|
label,
|
|
icon,
|
|
activeIcon,
|
|
count,
|
|
badge,
|
|
}: {
|
|
to: NavPath;
|
|
label: string;
|
|
icon: string;
|
|
activeIcon: string;
|
|
count?: number; // счётчик справа (лайки, дизлайки)
|
|
badge?: number; // яркий бейдж (ждут решения)
|
|
}) {
|
|
return (
|
|
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
|
|
{({ isActive }) => (
|
|
<>
|
|
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
|
|
<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>
|
|
);
|
|
}
|
|
|
|
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
|
|
export function AppShell() {
|
|
return useAuthState().status === 'signed_in' ? (
|
|
<PlayerProvider>
|
|
<Shell />
|
|
</PlayerProvider>
|
|
) : null;
|
|
}
|
|
|
|
function Shell() {
|
|
const { t } = useTranslation();
|
|
const user = useUser();
|
|
const session = useSession();
|
|
const navigate = useNavigate();
|
|
const name = user.display_name ?? user.username;
|
|
const staff = user.role !== 'user';
|
|
const island = user.ui_prefs.web_player === 'island';
|
|
const counts = useCollectionCounts();
|
|
const pending = usePendingUploads(user.role === 'admin');
|
|
|
|
return (
|
|
<div className={cx(s.shell, island && s.island)}>
|
|
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
|
<header className={s.top}>
|
|
<Link to="/" className={s.logoLink}>
|
|
<Logo />
|
|
</Link>
|
|
<SearchBox />
|
|
<div className={s.topRight}>
|
|
<Menu
|
|
label={t('profile.menu')}
|
|
triggerClassName={s.profileButton}
|
|
trigger={<Avatar name={name} />}
|
|
header={
|
|
<div className={s.profileHead}>
|
|
<Avatar name={name} size={40} />
|
|
<div>
|
|
<div className={s.profileName}>{name}</div>
|
|
<div className={s.profileMeta}>
|
|
@{user.username} · {t(`roles.${user.role}`)}
|
|
</div>
|
|
</div>
|
|
<LanguageSwitch />
|
|
</div>
|
|
}
|
|
items={[
|
|
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
|
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
|
]}
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
<nav className={s.side} aria-label={t('nav.main')}>
|
|
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
|
<div className={s.navSection}>{t('nav.collection')}</div>
|
|
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
|
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
|
<NavLink
|
|
to="/collection/dislikes"
|
|
label={t('nav.dislikes')}
|
|
icon={dislikeIcon}
|
|
activeIcon={dislikeFillIcon}
|
|
count={counts.dislikes}
|
|
/>
|
|
{staff && (
|
|
<>
|
|
<div className={s.navSection}>{t('nav.admin')}</div>
|
|
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
|
{user.role === 'admin' && (
|
|
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
|
)}
|
|
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
|
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
|
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
|
|
{user.role === 'admin' && (
|
|
<>
|
|
<NavLink
|
|
to="/admin/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} />
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</nav>
|
|
|
|
<main className={s.content}>
|
|
<Outlet />
|
|
</main>
|
|
|
|
<PlayerBar variant={island ? 'island' : 'bar'} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Третье пятно фона — в цвете обложки текущего трека. */
|
|
function ShellAmbient() {
|
|
const track = usePlayerState().track;
|
|
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} />;
|
|
}
|
|
|
|
/** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */
|
|
function usePendingUploads(enabled: boolean) {
|
|
const session = useSession();
|
|
const q = useQuery({
|
|
queryKey: ['admin-uploads-pending'],
|
|
enabled,
|
|
queryFn: async () =>
|
|
unwrap(
|
|
await session.client.GET('/api/v1/admin/uploads', { params: { query: { status: ['pending_review'], limit: 100 } } }),
|
|
).items.length,
|
|
refetchInterval: 60_000,
|
|
});
|
|
return q.data;
|
|
}
|