Files
pipu-music/apps/web/src/widgets/AppShell.tsx
T
mrleo1nidandCursor 597067689a
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m10s
ci / db / db (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / desktop / rust (desktop proxy) (push) Successful in 5m37s
ci / ts / ts (packages + web) (push) Successful in 4m10s
release / release (версия и заметки) (push) Successful in 11s
release / android (APK в релиз) (push) Successful in 4m22s
feat: add a platform-aware apps download page
Serve desktop and Android builds from the latest Gitea release on /download, and fix the desktop compile and CI flakes that blocked v1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:10:03 +03:00

331 lines
12 KiB
TypeScript

import { queueIcon } from '../shared/ui/icons';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { Suspense, useCallback, 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 } 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';
type NavPath =
| '/'
| '/admin/users'
| '/admin/audit'
| '/admin/sources'
| '/admin/library'
| '/admin/uploads'
| '/uploads/new'
| '/uploads/mine'
| '/admin/settings'
| '/collection/playlists'
| '/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,
}: {
id: string;
title: string;
badge?: number;
children: ReactNode;
defaultFolded?: boolean;
mobile?: boolean;
}) {
const [folded, setFolded] = useState(() => {
if (readIds(FOLDED_KEY).includes(id)) return true;
if (readIds(TOUCHED_KEY).includes(id)) return false;
return defaultFolded;
});
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={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3>
) : (
<button type="button" className={s.navSection} aria-expanded={!folded} onClick={toggle}>
<span className={s.navSectionTitle}>{title}</span>
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
</button>
)}
<div className={cx(s.navGroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
</>
);
}
/** Между выходом и редиректом на /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 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 [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []);
const navigation = (mobile = false) => (
<>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
<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 && (
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
<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} badge={unseen} />
{reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{user.role === 'admin' && (
<>
<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>
)}
</>
);
return (
<div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />}
<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={[
{ 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>
)}
<main className={s.content}>
<Suspense fallback={null}>
<Outlet />
</Suspense>
</main>
<PlayerBar variant={island ? 'island' : 'bar'} />
</div>
);
}
/** Третье пятно фона — в цвете обложки текущего трека. */
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;
}