384 lines
15 KiB
TypeScript
384 lines
15 KiB
TypeScript
import { queueIcon } from '../shared/ui/icons';
|
|
import { unwrap } from '@pipu/core';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
|
|
import { Suspense, useCallback, useEffect, useId, useState, type ReactNode } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useAuthState, useSession, useUser } from '../app/session';
|
|
import { useDesktop } from '../desktop';
|
|
import { useCollectionCounts } from '../entities/collection';
|
|
import {
|
|
moreIcon,
|
|
auditFillIcon,
|
|
auditIcon,
|
|
groupFillIcon,
|
|
groupIcon,
|
|
homeFillIcon,
|
|
homeIcon,
|
|
libraryFillIcon,
|
|
libraryIcon,
|
|
logoutIcon,
|
|
historyFillIcon,
|
|
historyIcon,
|
|
likeFillIcon,
|
|
likeIcon,
|
|
dislikeFillIcon,
|
|
dislikeIcon,
|
|
settingsIcon,
|
|
downloadIcon,
|
|
chevronRightIcon,
|
|
storageFillIcon,
|
|
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
|
import { Avatar, cx, Dialog, Icon, Logo, Menu, PageSkeleton } from '../shared/ui/ui';
|
|
import { LanguageSwitch } from './LanguageSwitch';
|
|
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
|
|
import { Ambient } from './Ambient';
|
|
import { PlayerBar } from './PlayerBar';
|
|
import { SearchBox } from './SearchBox';
|
|
import s from './AppShell.module.css';
|
|
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
|
|
import { useSocialUnread, useSocialProfileMenu, SocialProvider } from '../features/social/Social';
|
|
import { MiniSocial } from '../features/social/MiniSocial';
|
|
import { ListeningBanner } from '../features/social/Listening';
|
|
|
|
type NavPath =
|
|
| '/friends'
|
|
| '/'
|
|
| '/admin/users'
|
|
| '/admin/audit'
|
|
| '/admin/sources'
|
|
| '/admin/library'
|
|
| '/admin/uploads'
|
|
| '/uploads/new'
|
|
| '/uploads/mine'
|
|
| '/admin/updates'
|
|
| '/admin/config'
|
|
| '/admin/settings'
|
|
| '/collection/playlists'
|
|
| '/collection/downloads'
|
|
| '/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} aria-label={label} title={label} 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>
|
|
);
|
|
}
|
|
|
|
const FOLDED_KEY = 'pipu.nav.folded';
|
|
const TOUCHED_KEY = 'pipu.nav.touched';
|
|
|
|
function readIds(key: string): string[] {
|
|
try {
|
|
const v: unknown = JSON.parse(localStorage.getItem(key) ?? '[]');
|
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Раздел меню: заголовок сворачивает пункты; явный выбор помнит браузер. Свёрнутый раздел показывает сумму бейджей. */
|
|
function NavSection({
|
|
id,
|
|
title,
|
|
badge,
|
|
children,
|
|
defaultFolded = false,
|
|
mobile = false,
|
|
nested = false,
|
|
paths,
|
|
}: {
|
|
id: string;
|
|
title: string;
|
|
badge?: number;
|
|
children: ReactNode;
|
|
defaultFolded?: boolean;
|
|
mobile?: boolean;
|
|
nested?: boolean;
|
|
paths?: readonly string[];
|
|
}) {
|
|
const groupId = useId();
|
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
const active = paths?.some((path) => pathname === path || pathname.startsWith(`${path}/`)) ?? false;
|
|
const [folded, setFolded] = useState(() => {
|
|
if (active) return false;
|
|
if (readIds(FOLDED_KEY).includes(id)) return true;
|
|
if (readIds(TOUCHED_KEY).includes(id)) return false;
|
|
return defaultFolded;
|
|
});
|
|
useEffect(() => {
|
|
if (active) setFolded(false);
|
|
}, [active, pathname]);
|
|
const toggle = () => {
|
|
const next = !folded;
|
|
setFolded(next);
|
|
try {
|
|
const rest = readIds(FOLDED_KEY).filter((x) => x !== id);
|
|
localStorage.setItem(FOLDED_KEY, JSON.stringify(next ? [...rest, id] : rest));
|
|
const touched = readIds(TOUCHED_KEY);
|
|
if (!touched.includes(id)) localStorage.setItem(TOUCHED_KEY, JSON.stringify([...touched, id]));
|
|
} catch {
|
|
// Без хранилища раздел просто не запомнит состояние.
|
|
}
|
|
};
|
|
return (
|
|
<>
|
|
{mobile ? (
|
|
<h3 className={cx(s.navSection, nested && s.navSubsection)}><span className={s.navSectionTitle}>{title}</span></h3>
|
|
) : (
|
|
<button type="button" className={cx(s.navSection, nested && s.navSubsection)} aria-labelledby={`${groupId}-title`} aria-describedby={folded && badge ? `${groupId}-badge` : undefined} aria-expanded={!folded} aria-controls={groupId} onClick={toggle}>
|
|
<span id={`${groupId}-title`} className={s.navSectionTitle}>{title}</span>
|
|
{folded && badge ? <span id={`${groupId}-badge`} className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
|
|
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
|
|
</button>
|
|
)}
|
|
<div id={groupId} role="group" aria-label={title} className={cx(s.navGroup, nested && s.navSubgroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
|
|
export function AppShell() {
|
|
return useAuthState().status === 'signed_in' ? (
|
|
<SocialProvider><PlayerProvider>
|
|
<DesktopUpdateDialog />
|
|
<Shell />
|
|
</PlayerProvider></SocialProvider>
|
|
) : null;
|
|
}
|
|
|
|
function Shell() {
|
|
const { t } = useTranslation();
|
|
const user = useUser();
|
|
const socialUnread = useSocialUnread();
|
|
const socialProfile = useSocialProfileMenu();
|
|
const session = useSession();
|
|
const desktop = useDesktop();
|
|
const navigate = useNavigate();
|
|
const name = user.display_name ?? user.username;
|
|
const staff = user.role !== 'user';
|
|
const island = user.ui_prefs.web_player === 'island';
|
|
const searchAt = user.ui_prefs.search_position ?? 'center';
|
|
const search = <SearchBox align={searchAt} collapsible={user.ui_prefs.search_collapse === true} />;
|
|
const counts = useCollectionCounts();
|
|
const reviews = user.reviews_uploads;
|
|
const pending = usePendingUploads(reviews);
|
|
const unseen = useUnseenUploads(staff);
|
|
const uploadBadge = (reviews ? pending ?? 0 : 0) + (unseen ?? 0);
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
|
const navigation = (mobile = false) => (
|
|
<>
|
|
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
|
<NavLink to="/friends" label={t('social.title')} icon={groupIcon} activeIcon={groupFillIcon} badge={socialUnread} />
|
|
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
|
|
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
|
|
{desktop && <NavLink to="/collection/downloads" label={t('desktop.offline')} icon={downloadIcon} activeIcon={downloadIcon} />}
|
|
<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}
|
|
/>
|
|
</NavSection>
|
|
{!staff && reviews && (
|
|
<NavLink
|
|
to="/admin/uploads"
|
|
label={t('nav.upload_queue')}
|
|
icon={inboxIcon}
|
|
activeIcon={inboxFillIcon}
|
|
badge={pending}
|
|
/>
|
|
)}
|
|
{!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
|
|
{staff && (
|
|
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={uploadBadge} paths={['/admin', '/uploads']} defaultFolded>
|
|
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
|
<NavSection mobile={mobile} nested id="admin-media" title={t('nav.admin_media')} paths={['/admin/library', '/admin/sources']} defaultFolded>
|
|
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
|
{user.role === 'admin' && (
|
|
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
|
)}
|
|
</NavSection>
|
|
<NavSection mobile={mobile} nested id="admin-uploads" title={t('nav.admin_uploads')} badge={uploadBadge} paths={['/uploads', '/admin/uploads']} defaultFolded>
|
|
<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} badge={unseen} />
|
|
{reviews && (
|
|
<NavLink
|
|
to="/admin/uploads"
|
|
label={t('nav.upload_queue')}
|
|
icon={inboxIcon}
|
|
activeIcon={inboxFillIcon}
|
|
badge={pending}
|
|
/>
|
|
)}
|
|
</NavSection>
|
|
{(user.role === 'admin' || user.manages_updates) && (
|
|
<NavSection mobile={mobile} nested id="admin-system" title={t('nav.admin_system')} paths={['/admin/updates', '/admin/config', '/admin/settings', '/admin/audit']} defaultFolded>
|
|
<NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />
|
|
{user.role === 'admin' && (
|
|
<>
|
|
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
|
<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} />
|
|
</>
|
|
)}
|
|
</NavSection>
|
|
)}
|
|
</NavSection>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
|
|
return (
|
|
<div className={cx(s.shell, island && s.island)}>
|
|
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
|
<a href="#content" className={s.skip}>
|
|
{t('nav.skip')}
|
|
</a>
|
|
<header className={s.top}>
|
|
<div className={s.topLeft}>
|
|
<Link to="/" className={s.logoLink}>
|
|
<Logo />
|
|
</Link>
|
|
{searchAt === 'left' && search}
|
|
</div>
|
|
<div className={s.topCenter}>{searchAt === 'center' && search}</div>
|
|
<div className={s.topRight}>
|
|
{searchAt === 'right' && search}
|
|
<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={[
|
|
...socialProfile.items,
|
|
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
|
...(!desktop
|
|
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
|
|
: []),
|
|
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
|
]}
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
<nav className={s.side} aria-label={t('nav.main')}>{navigation()}</nav>
|
|
<nav className={s.mobileBottom} aria-label={t('nav.main')}>
|
|
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
|
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
|
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
|
|
<button type="button" className={s.navItem} aria-label={t('nav.more')}
|
|
aria-haspopup="dialog" aria-expanded={mobileOpen} onClick={() => setMobileOpen(true)}>
|
|
<Icon src={moreIcon} className={s.navIcon} />
|
|
<span className={s.navLabel}>{t('nav.more')}</span>
|
|
{(pending ?? 0) + (unseen ?? 0) > 0 && <span className={s.mobileDot} />}
|
|
</button>
|
|
</nav>
|
|
{mobileOpen && (
|
|
<Dialog title={t('nav.main')} onClose={closeMobile}>
|
|
<nav className={s.mobileMenu} aria-label={t('nav.main')}
|
|
onClick={(e) => { if ((e.target as HTMLElement).closest('a')) closeMobile(); }}>
|
|
{navigation(true)}
|
|
</nav>
|
|
</Dialog>
|
|
)}
|
|
|
|
{socialProfile.dialogs}
|
|
<ShellMain />
|
|
<MiniSocial />
|
|
|
|
<PlayerBar variant={island ? 'island' : 'bar'} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ShellMain() {
|
|
const key = useRouterState({ select: (st) => st.location.pathname });
|
|
return (
|
|
<main id="content" className={`${s.content} ${key === '/friends' ? s.socialContent : ''}`} tabIndex={-1}>
|
|
<DesktopOfflineBanner />
|
|
<ListeningBanner />
|
|
<div key={key} className={s.routeIn}>
|
|
<Suspense fallback={<PageSkeleton />}>
|
|
<Outlet />
|
|
</Suspense>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
/** Третье пятно фона — в цвете обложки текущего трека. */
|
|
function ShellAmbient() {
|
|
const player = usePlayer();
|
|
const track = usePlayerState().track;
|
|
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} readAudio={player.readAudio} />;
|
|
}
|
|
|
|
/** Файлов на рассмотрении — бейдж «Очереди» у админа (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;
|
|
}
|
|
|
|
/** Новые решения по своим загрузкам — бейдж «Мои предложения» у персонала. */
|
|
function useUnseenUploads(enabled: boolean) {
|
|
const session = useSession();
|
|
const q = useQuery({
|
|
queryKey: ['uploads-unseen'],
|
|
enabled,
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/unseen')).count,
|
|
refetchInterval: 60_000,
|
|
});
|
|
return q.data || undefined;
|
|
}
|