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
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>
331 lines
12 KiB
TypeScript
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;
|
|
}
|