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

+73
View File
@@ -0,0 +1,73 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
test('admin groups related pages and remembers collapsed sections', async ({ page }, info) => {
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
await nav.getByRole('button', { name: 'Админка', exact: true }).click();
await expect(nav.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(nav.getByRole('button', { name: title, exact: true })).toHaveAttribute('aria-expanded', 'false');
}
await nav.getByRole('button', { name: 'Система', exact: true }).click();
const system = nav.getByRole('group', { name: 'Система', exact: true });
await expect(system.getByRole('link')).toHaveCount(4);
for (const title of ['Обновления', 'Конфигурация', 'Настройки', 'Журнал']) {
await expect(system.getByRole('link', { name: title, exact: true })).toBeVisible();
}
await nav.screenshot({ path: info.outputPath('admin-groups.png') });
// The fake API resets authentication on a full document load; sign in again.
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'true');
await nav.getByRole('button', { name: 'Система', exact: true }).click();
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'false');
await login(page, 'mobile-admin', '/admin/audit');
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toBeVisible();
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toHaveAttribute('data-status', 'active');
await nav.getByRole('button', { name: 'Медиатека', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link')).toHaveCount(2);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link')).toHaveCount(3);
});
test('moderator sees only permitted groups and pages', async ({ page }) => {
await login(page, 'moderator', '/admin/library');
const nav = page.getByRole('navigation', { name: 'Навигация' });
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link', { name: 'Библиотека' })).toBeVisible();
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveCount(0);
await expect(nav.getByRole('link', { name: 'Хранилища' })).toHaveCount(0);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
});
test('mobile menu exposes grouped pages and closes after navigation', async ({ page }, info) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Навигация' });
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(dialog.getByRole('heading', { name: title, exact: true })).toBeVisible();
}
await dialog.screenshot({ path: info.outputPath('admin-groups-mobile.png') });
await dialog.getByRole('group', { name: 'Система', exact: true }).getByRole('link', { name: 'Обновления', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/admin\/updates$/);
});
test('tablet icon sidebar keeps all admin destinations accessible', async ({ page }) => {
await page.setViewportSize({ width: 900, height: 900 });
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
for (const title of ['Библиотека', 'Хранилища', 'Предложить треки', 'Очередь', 'Журнал']) {
await expect(nav.getByRole('link', { name: title, exact: true })).toBeVisible();
}
});
+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>
)}
+9
View File
@@ -14,6 +14,15 @@
## 2. Пользователи и роли
В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы:
- **Медиатека:** библиотека и хранилища.
- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение.
- **Система:** обновления, конфигурация, настройки и журнал.
На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы
раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав.
«Администрирование → Пользователи».
| Роль | Что может |
+3
View File
@@ -50,6 +50,9 @@
"main": "Navigation",
"home": "Home",
"admin": "Admin",
"admin_media": "Media library",
"admin_uploads": "Uploads",
"admin_system": "System",
"users": "Users",
"audit": "Audit log",
"library": "Library",
+3
View File
@@ -50,6 +50,9 @@
"main": "Навигация",
"home": "Главная",
"admin": "Админка",
"admin_media": "Медиатека",
"admin_uploads": "Загрузки",
"admin_system": "Система",
"users": "Пользователи",
"audit": "Журнал",
"library": "Библиотека",