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 ( {({ isActive }) => ( <> {label} {badge ? {badge > 99 ? '99+' : badge} : null} {!badge && count ? {count} : null} )} ); } 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 ? (

{title}

) : ( )}
{children}
); } /** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */ export function AppShell() { return useAuthState().status === 'signed_in' ? ( ) : 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 = ; 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) => ( <> {!staff && reviews && ( )} {staff && ( {user.role === 'admin' && ( )} {reviews && ( )} {user.role === 'admin' && ( <> )} )} ); return (
{user.ui_prefs.ambient !== false && }
{searchAt === 'left' && search}
{searchAt === 'center' && search}
{searchAt === 'right' && search} } header={
{name}
@{user.username} · {t(`roles.${user.role}`)}
} 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() }, ]} />
{mobileOpen && ( )}
); } /** Третье пятно фона — в цвете обложки текущего трека. */ function ShellAmbient() { const player = usePlayer(); const track = usePlayerState().track; return ; } /** Файлов на рассмотрении — бейдж «Очереди» у админа (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; }