feat(admin): group navigation into media, uploads and system
ci / ts / ts (packages + web) (push) Failing after 4m33s
release / android (APK в релиз) (push) Successful in 4m45s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 8s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m14s
release / prune (старые файлы и прогоны) (push) Successful in 12s

This commit is contained in:
mrleo1nid committed 2026-10-28 23:47:17 +03:00
1 parent 17bb0e1846
commit 66d3897f66
6 files changed
+156 -27

No files matched your search

+21
View File
@@ -170,6 +170,22 @@
display: none;
}
.navSubsection {
min-height: 40px;
margin: 2px 0;
font-size: 13px;
font-weight: 600;
letter-spacing: normal;
text-transform: none;
color: var(--text2);
}
.navSubgroup > .navItem {
margin-left: 12px;
padding-left: 12px;
border-left: 1px solid var(--line);
}
.navItem {
display: flex;
align-items: center;
@@ -295,6 +311,9 @@
.navGroupFolded {
display: contents;
}
.navSubsection { min-height: 0; }
.navSubgroup > .navItem { margin-left: 0; padding-left: 0; border-left: 0; }
}
.mobileBottom { display: none; }
@@ -358,6 +377,8 @@
border: 0; pointer-events: none;
}
.mobileMenu .navSectionTitle { display: block; }
.mobileMenu .navSubsection { color: var(--text2); font-size: 13px; }
.mobileMenu .navSubgroup > .navItem { margin-left: 12px; border-left: 1px solid var(--line); }
.mobileMenu .navChevron { display: none; }
.content, .island .content { padding: 20px 16px 24px; }
}
+47 -27
View File
@@ -2,7 +2,7 @@ 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, useState, type ReactNode } from 'react';
import { Suspense, useCallback, useEffect, useId, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
@@ -110,6 +110,8 @@ function NavSection({
children,
defaultFolded = false,
mobile = false,
nested = false,
paths,
}: {
id: string;
title: string;
@@ -117,12 +119,21 @@ function NavSection({
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);
@@ -138,15 +149,15 @@ function NavSection({
return (
<>
{mobile ? (
<h3 className={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3>
<h3 className={cx(s.navSection, nested && s.navSubsection)}><span className={s.navSectionTitle}>{title}</span></h3>
) : (
<button type="button" className={s.navSection} aria-expanded={!folded} onClick={toggle}>
<button type="button" className={cx(s.navSection, nested && s.navSubsection)} aria-expanded={!folded} aria-controls={groupId} 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>
<div id={groupId} role="group" aria-label={title} className={cx(s.navGroup, nested && s.navSubgroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
</>
);
}
@@ -178,6 +189,7 @@ function Shell() {
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) => (
@@ -208,30 +220,38 @@ function Shell() {
)}
{!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={reviews ? pending : undefined} defaultFolded>
<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} />
{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' || user.manages_updates) && <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 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>
)}