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
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:
1 parent
17bb0e1846
commit
66d3897f66
6 files changed
+156
-27
No files matched your search
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -170,6 +170,22 @@
|
|||||||
display: none;
|
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 {
|
.navItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -295,6 +311,9 @@
|
|||||||
.navGroupFolded {
|
.navGroupFolded {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navSubsection { min-height: 0; }
|
||||||
|
.navSubgroup > .navItem { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileBottom { display: none; }
|
.mobileBottom { display: none; }
|
||||||
@@ -358,6 +377,8 @@
|
|||||||
border: 0; pointer-events: none;
|
border: 0; pointer-events: none;
|
||||||
}
|
}
|
||||||
.mobileMenu .navSectionTitle { display: block; }
|
.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; }
|
.mobileMenu .navChevron { display: none; }
|
||||||
.content, .island .content { padding: 20px 16px 24px; }
|
.content, .island .content { padding: 20px 16px 24px; }
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import { queueIcon } from '../shared/ui/icons';
|
|||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuthState, useSession, useUser } from '../app/session';
|
import { useAuthState, useSession, useUser } from '../app/session';
|
||||||
@@ -110,6 +110,8 @@ function NavSection({
|
|||||||
children,
|
children,
|
||||||
defaultFolded = false,
|
defaultFolded = false,
|
||||||
mobile = false,
|
mobile = false,
|
||||||
|
nested = false,
|
||||||
|
paths,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -117,12 +119,21 @@ function NavSection({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
defaultFolded?: boolean;
|
defaultFolded?: boolean;
|
||||||
mobile?: 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(() => {
|
const [folded, setFolded] = useState(() => {
|
||||||
|
if (active) return false;
|
||||||
if (readIds(FOLDED_KEY).includes(id)) return true;
|
if (readIds(FOLDED_KEY).includes(id)) return true;
|
||||||
if (readIds(TOUCHED_KEY).includes(id)) return false;
|
if (readIds(TOUCHED_KEY).includes(id)) return false;
|
||||||
return defaultFolded;
|
return defaultFolded;
|
||||||
});
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) setFolded(false);
|
||||||
|
}, [active, pathname]);
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
const next = !folded;
|
const next = !folded;
|
||||||
setFolded(next);
|
setFolded(next);
|
||||||
@@ -138,15 +149,15 @@ function NavSection({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mobile ? (
|
{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>
|
<span className={s.navSectionTitle}>{title}</span>
|
||||||
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
|
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
|
||||||
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
|
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
|
||||||
</button>
|
</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 reviews = user.reviews_uploads;
|
||||||
const pending = usePendingUploads(reviews);
|
const pending = usePendingUploads(reviews);
|
||||||
const unseen = useUnseenUploads(staff);
|
const unseen = useUnseenUploads(staff);
|
||||||
|
const uploadBadge = (reviews ? pending ?? 0 : 0) + (unseen ?? 0);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||||
const navigation = (mobile = 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 && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
|
||||||
{staff && (
|
{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} />
|
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||||
{user.role === 'admin' && (
|
<NavSection mobile={mobile} nested id="admin-media" title={t('nav.admin_media')} paths={['/admin/library', '/admin/sources']} defaultFolded>
|
||||||
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||||
)}
|
{user.role === 'admin' && (
|
||||||
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||||
<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} />
|
</NavSection>
|
||||||
{reviews && (
|
<NavSection mobile={mobile} nested id="admin-uploads" title={t('nav.admin_uploads')} badge={uploadBadge} paths={['/uploads', '/admin/uploads']} defaultFolded>
|
||||||
<NavLink
|
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||||
to="/admin/uploads"
|
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
|
||||||
label={t('nav.upload_queue')}
|
{reviews && (
|
||||||
icon={inboxIcon}
|
<NavLink
|
||||||
activeIcon={inboxFillIcon}
|
to="/admin/uploads"
|
||||||
badge={pending}
|
label={t('nav.upload_queue')}
|
||||||
/>
|
icon={inboxIcon}
|
||||||
)}
|
activeIcon={inboxFillIcon}
|
||||||
{(user.role === 'admin' || user.manages_updates) && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
|
badge={pending}
|
||||||
{user.role === 'admin' && (
|
/>
|
||||||
<>
|
)}
|
||||||
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
</NavSection>
|
||||||
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
{(user.role === 'admin' || user.manages_updates) && (
|
||||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
<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>
|
</NavSection>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
|
|
||||||
## 2. Пользователи и роли
|
## 2. Пользователи и роли
|
||||||
|
|
||||||
|
В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы:
|
||||||
|
|
||||||
|
- **Медиатека:** библиотека и хранилища.
|
||||||
|
- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение.
|
||||||
|
- **Система:** обновления, конфигурация, настройки и журнал.
|
||||||
|
|
||||||
|
На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы
|
||||||
|
раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав.
|
||||||
|
|
||||||
«Администрирование → Пользователи».
|
«Администрирование → Пользователи».
|
||||||
|
|
||||||
| Роль | Что может |
|
| Роль | Что может |
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"main": "Navigation",
|
"main": "Navigation",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
|
"admin_media": "Media library",
|
||||||
|
"admin_uploads": "Uploads",
|
||||||
|
"admin_system": "System",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"audit": "Audit log",
|
"audit": "Audit log",
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"main": "Навигация",
|
"main": "Навигация",
|
||||||
"home": "Главная",
|
"home": "Главная",
|
||||||
"admin": "Админка",
|
"admin": "Админка",
|
||||||
|
"admin_media": "Медиатека",
|
||||||
|
"admin_uploads": "Загрузки",
|
||||||
|
"admin_system": "Система",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"audit": "Журнал",
|
"audit": "Журнал",
|
||||||
"library": "Библиотека",
|
"library": "Библиотека",
|
||||||
|
|||||||
Reference in new issue
Block a user