diff --git a/apps/web/e2e/admin-navigation.spec.ts b/apps/web/e2e/admin-navigation.spec.ts new file mode 100644 index 0000000..91f26ea --- /dev/null +++ b/apps/web/e2e/admin-navigation.spec.ts @@ -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(); + } +}); diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css index 93f8e71..b89c45c 100644 --- a/apps/web/src/widgets/AppShell.module.css +++ b/apps/web/src/widgets/AppShell.module.css @@ -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; } } diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 174f87b..06932ce 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -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 ? ( -