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 ? ( -

{title}

+

{title}

) : ( - )} -
{children}
+
{children}
); } @@ -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 && } {staff && ( - + - {user.role === 'admin' && ( - - )} - - - - {reviews && ( - - )} - {(user.role === 'admin' || user.manages_updates) && } - {user.role === 'admin' && ( - <> - - - - + + + {user.role === 'admin' && ( + + )} + + + + + {reviews && ( + + )} + + {(user.role === 'admin' || user.manages_updates) && ( + + + {user.role === 'admin' && ( + <> + + + + + )} + )} )} diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 7018c99..5786acc 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -14,6 +14,15 @@ ## 2. Пользователи и роли +В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы: + +- **Медиатека:** библиотека и хранилища. +- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение. +- **Система:** обновления, конфигурация, настройки и журнал. + +На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы +раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав. + «Администрирование → Пользователи». | Роль | Что может | diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 066cd82..e393d41 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -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", diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 48d16ed..9bf7b21 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -50,6 +50,9 @@ "main": "Навигация", "home": "Главная", "admin": "Админка", + "admin_media": "Медиатека", + "admin_uploads": "Загрузки", + "admin_system": "Система", "users": "Пользователи", "audit": "Журнал", "library": "Библиотека",