diff --git a/apps/mobile/.maestro/startup.yaml b/apps/mobile/.maestro/startup.yaml new file mode 100644 index 0000000..9752d1e --- /dev/null +++ b/apps/mobile/.maestro/startup.yaml @@ -0,0 +1,10 @@ +appId: app.pipu.music +--- +- launchApp: + clearState: true +- assertVisible: "Адрес сервера|Server address" +- stopApp +- launchApp +- assertVisible: "Адрес сервера|Server address" +- openLink: "pipu://album/1" +- assertVisible: "Адрес сервера|Server address" diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 83b96ab..d068733 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -30,7 +30,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'expo-secure-store', // Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка // Android по умолчанию запрещает незашифрованный HTTP. - ['expo-build-properties', { android: { usesCleartextTraffic: true } }], + // Распаковываем .so при установке: SoLoader на x86_64 с ARM-трансляцией ищет + // внутри APK lib/x86_64 вместо arm64-v8a и не находит libreactnative.so. + ['expo-build-properties', { android: { usesCleartextTraffic: true, useLegacyPackaging: true } }], // APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js). './plugins/withArm64Only', ], diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 60e543c..a74812c 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -1,6 +1,6 @@ import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Stack, useRouter, useSegments } from 'expo-router'; +import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import type { i18n as I18n } from 'i18next'; import { useEffect, useState } from 'react'; @@ -54,8 +54,6 @@ const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly stri function Gate() { const { server, session, ready } = useSessionCtx(); const auth = useAuthState(); - const segments = useSegments(); - const router = useRouter(); const { i18n } = useTranslation(); const user = auth.status === 'signed_in' ? auth.user : null; const system = useColorScheme(); @@ -79,17 +77,6 @@ function Gate() { return () => sub.remove(); }, []); - useEffect(() => { - if (!ready) return; - const first = segments[0] as string | undefined; - let target: string | null = null; - if (!server) target = first === 'connect' ? null : '/connect'; - else if (!user) target = first === 'login' || first === 'connect' ? null : '/login'; - else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup'; - else if (first === 'connect' || first === 'login' || first === 'setup') target = '/'; - if (target) router.replace(target as never); - }, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]); - if (!ready) return ; return ( @@ -101,9 +88,29 @@ function Gate() { function Screens() { const colors = useColors(); + const { server, session } = useSessionCtx(); + const auth = useAuthState(); + const connected = !!server && !!session; + const user = connected && auth.status === 'signed_in' ? auth.user : null; + const needsSetup = !!user && (user.must_change_credentials || user.must_change_password); return ( - + {/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */} + + + + + + + + + + + + + + + ); } diff --git a/apps/web/e2e/mobile.spec.ts b/apps/web/e2e/mobile.spec.ts new file mode 100644 index 0000000..ba7ae5c --- /dev/null +++ b/apps/web/e2e/mobile.spec.ts @@ -0,0 +1,119 @@ +import { expect, test, type Page } from '@playwright/test'; + +async function login(page: Page, username: string) { + await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); + await page.goto('/login'); + 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: 'Ещё', exact: true })).toBeVisible(); +} + +async function fits(page: Page) { + await expect.poll(() => page.evaluate(() => { + const main = document.querySelector('main')!; + return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth); + })).toBeLessThanOrEqual(1); +} + +for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430, 'alice']] as const) { + test(`mobile ${width}: ${username} navigation, forms and search`, async ({ page }, info) => { + test.setTimeout(60_000); + await page.setViewportSize({ width, height: 844 }); + await login(page, username); + await fits(page); + const bottom = page.getByRole('navigation', { name: 'Навигация' }).filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) }); + await expect(bottom.getByRole('link')).toHaveCount(3); + await page.getByRole('button', { name: 'Ещё', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Навигация' }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('link', { name: 'Дизлайки', exact: true })).toBeVisible(); + if (username !== 'alice') { + await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible(); + await expect(dialog.getByRole('link', { name: 'Очередь', exact: true })).toBeVisible(); + await expect(dialog.getByRole('link', { name: 'Хранилища', exact: true })).toHaveCount(username === 'mobile-admin' ? 1 : 0); + await dialog.getByRole('link', { name: 'Пользователи', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible(); + await fits(page); + await page.screenshot({ path: info.outputPath('users.png') }); + await page.getByRole('button', { name: /Действия.*alice/ }).click(); + const menu = page.getByRole('menu'); + await expect(menu).toBeVisible(); + const rect = await menu.boundingBox(); + expect(rect!.x).toBeGreaterThanOrEqual(0); + expect(rect!.x + rect!.width).toBeLessThanOrEqual(width); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Создать пользователя', exact: true }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.screenshot({ path: info.outputPath('create-user.png') }); + await page.getByRole('button', { name: 'Закрыть', exact: true }).click(); + } else { + await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Закрыть', exact: true }).click(); + } + await page.getByRole('button', { name: 'Меню профиля' }).click(); + await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible(); + await fits(page); + await page.screenshot({ path: info.outputPath('settings.png') }); + for (const name of ['Слева', 'По центру', 'Справа']) { + await page.getByRole('button', { name, exact: true }).click(); + await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино'); + await expect(page.getByRole('option', { name: /Кино/ }).first()).toBeVisible(); + await fits(page); + await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Escape'); + } + + if (username !== 'alice') { + await page.getByRole('button', { name: 'Ещё', exact: true }).click(); + await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible(); + await expect(page.locator('audio')).toBeVisible(); + await fits(page); + await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check(); + await expect(page.getByRole('region', { name: 'Действия с выбранными' })).toBeVisible(); + await fits(page); + await page.screenshot({ path: info.outputPath('upload-queue.png') }); + await page.getByRole('region', { name: 'Действия с выбранными' }).getByRole('button', { name: 'Отклонить' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByRole('button', { name: 'Закрыть', exact: true }).click(); + } + if (username === 'mobile-admin') { + await page.getByRole('button', { name: 'Ещё', exact: true }).click(); + await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click(); + await expect(page.getByRole('article', { name: 'Музыкальное хранилище' })).toBeVisible(); + await fits(page); + await page.getByRole('button', { name: 'Шаблоны путей', exact: true }).click(); + await page.getByRole('button', { name: 'Добавить шаблон', exact: true }).click(); + await fits(page); + await page.screenshot({ path: info.outputPath('patterns.png') }); + } + }); +} + +test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => { + await page.setViewportSize({ width: 320, height: 740 }); + await login(page, 'alice'); + const box = page.getByRole('combobox', { name: 'Поиск', exact: true }); + await box.fill('кино'); + await box.press('Enter'); + await page.getByRole('link', { name: /Группа крови/ }).first().click(); + await page.getByRole('listitem').first().click(); + const player = page.getByRole('contentinfo', { name: 'Плеер' }); + await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible(); + expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40); + await fits(page); + await page.getByRole('button', { name: 'Меню профиля' }).click(); + await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click(); + await page.getByRole('button', { name: 'Остров', exact: true }).click(); + const nav = page.getByRole('navigation').filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) }); + await expect.poll(async () => { + const p = await player.boundingBox(); + const n = await nav.boundingBox(); + return p!.y + p!.height <= n!.y + 1; + }).toBe(true); + await player.getByRole('button', { name: 'Очередь', exact: true }).click(); + await expect(page.getByRole('complementary', { name: 'Очередь', exact: true })).toBeVisible(); + await page.screenshot({ path: info.outputPath('player-queue.png') }); +}); diff --git a/apps/web/e2e/scenarios.spec.ts b/apps/web/e2e/scenarios.spec.ts index 4b540d5..b85a8a7 100644 --- a/apps/web/e2e/scenarios.spec.ts +++ b/apps/web/e2e/scenarios.spec.ts @@ -29,6 +29,10 @@ test('4. search suggest opens the artist and Enter opens search', async ({ page await expect(page.getByRole('heading', { name: 'Кино' })).toBeVisible(); await box.fill('кино'); + // A suggestion under the pointer must not become a keyboard selection. + const suggestion = page.getByRole('option', { name: /Кино/ }).first(); + await suggestion.hover(); + await expect(suggestion).toHaveAttribute('aria-selected', 'false'); await box.press('Enter'); await expect(page).toHaveURL(/\/search\?q=/); }); diff --git a/apps/web/index.html b/apps/web/index.html index 3e7ccad..ee8ef8e 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,7 @@ - + pipu diff --git a/apps/web/src/entities/catalog.module.css b/apps/web/src/entities/catalog.module.css index e545aeb..82cfe35 100644 --- a/apps/web/src/entities/catalog.module.css +++ b/apps/web/src/entities/catalog.module.css @@ -270,3 +270,11 @@ display: none; } } + +@media (max-width: 767px) { + .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } + .track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; } + .track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; } + .end { gap: 4px; } + .like { opacity: 1; width: 44px; height: 44px; } +} diff --git a/apps/web/src/pages/admin/UsersPage.tsx b/apps/web/src/pages/admin/UsersPage.tsx index a8f128b..a10b376 100644 --- a/apps/web/src/pages/admin/UsersPage.tsx +++ b/apps/web/src/pages/admin/UsersPage.tsx @@ -181,7 +181,7 @@ export function UsersPage() { {recsDone &&

{t('users.recs_done', { username: recsDone })}

}
- +
@@ -197,7 +197,7 @@ export function UsersPage() { const st = status(u); return ( - - - - -
{t('users.col_user')}
+
@@ -209,17 +209,17 @@ export function UsersPage() {
+ {t(`common:roles.${u.role}`)} {u.reviews_uploads && {t('users.review_uploads')}} + {t(`users.status_${st}`)} + {u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')} + {items.length > 0 && ( } items={items} /> )} diff --git a/apps/web/src/pages/admin/library.module.css b/apps/web/src/pages/admin/library.module.css index 8bbbe3c..22a9a35 100644 --- a/apps/web/src/pages/admin/library.module.css +++ b/apps/web/src/pages/admin/library.module.css @@ -320,3 +320,14 @@ gap: 10px; cursor: pointer; } + +@media (max-width: 767px) { + .card { padding: 16px; } + .cardActions > button { flex: 1 1 auto; } + .retryRow { flex-wrap: wrap; gap: 10px; } + .patternRow { grid-template-columns: 20px minmax(0, 1fr); } + .patternTools { grid-column: 2; margin-top: 0 !important; height: auto; flex-wrap: wrap; } + .patternTools .check { flex-basis: 100%; min-height: 44px; } + .patternTools > button { width: 44px; min-width: 44px; padding: 0; } + .dir { min-height: 44px; height: auto; padding: 8px; overflow-wrap: anywhere; } +} diff --git a/apps/web/src/pages/catalog.module.css b/apps/web/src/pages/catalog.module.css index 8ed631c..f59235a 100644 --- a/apps/web/src/pages/catalog.module.css +++ b/apps/web/src/pages/catalog.module.css @@ -218,3 +218,10 @@ height: 44px; border-radius: var(--radius-pill); } + +@media (max-width: 767px) { + .heroText { min-width: 0; } + .colTitle { font-size: clamp(24px, 7vw, 34px); overflow-wrap: anywhere; } + .underlineTabs { overflow-x: auto; } + .underlineTab { flex-shrink: 0; min-height: 44px; } +} diff --git a/apps/web/src/pages/home.module.css b/apps/web/src/pages/home.module.css index f6de17e..7356dbc 100644 --- a/apps/web/src/pages/home.module.css +++ b/apps/web/src/pages/home.module.css @@ -1,5 +1,7 @@ .home { min-height: 100%; + width: 100%; + overflow-x: clip; display: flex; flex-direction: column; align-items: center; @@ -107,3 +109,9 @@ .chipOn > span { color: #fff; } + +@media (max-width: 767px) { + .home { justify-content: flex-start; gap: 20px; padding: 8px 0 20px; } + .chip { min-height: 44px; height: auto; padding: 10px 14px; } + .orbLabel { font-size: clamp(18px, 5vw, 22px); } +} diff --git a/apps/web/src/pages/page.module.css b/apps/web/src/pages/page.module.css index 24bcbed..b4bd118 100644 --- a/apps/web/src/pages/page.module.css +++ b/apps/web/src/pages/page.module.css @@ -341,3 +341,29 @@ font-size: 12px; color: var(--text3); } + +@media (max-width: 767px) { + .page, .section, .head > *, .toolbar > *, .settingRow > * { min-width: 0; max-width: 100%; } + .page { gap: 20px; overflow-wrap: anywhere; } + .head h1 { font-size: 24px; } + .section { padding: 16px; } + .row > :first-child, .search { min-width: 0; flex-basis: 100%; max-width: none; } + .settingRow { align-items: flex-start; gap: 10px; } + .settingRow > :first-child { flex-basis: 100%; } + .actions { flex-wrap: wrap; justify-content: flex-start; } + .actions > * { flex: 1 1 auto; } + .chip { min-height: 44px; height: auto; padding: 8px 14px; } + .select { max-width: 100%; min-width: 0; font-size: 16px; } + .session { flex-wrap: wrap; overflow-wrap: anywhere; } + .tableWrap { min-width: 0; max-width: 100%; } + .mobileCards, .mobileCards tbody { display: block; } + .mobileCards thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } + .mobileCards tr { display: flex; flex-direction: column; padding: 14px; border-bottom: 1px solid var(--line); gap: 12px; } + .mobileCards tr:last-child { border-bottom: 0; } + .mobileCards td { display: flex; flex-wrap: wrap; gap: 6px 10px; height: auto; padding: 0; border: 0; white-space: normal; } + .mobileCards td::before { content: attr(data-label); flex-basis: 100%; font-size: 12px; color: var(--text3); } + .mobileCards .userCell { min-width: 0; } + .mobileCards .userCell > div { min-width: 0; overflow-wrap: anywhere; } + .mobileCards .cardAction { align-self: flex-end; } + .cardAction::before { display: none; } +} diff --git a/apps/web/src/pages/uploads/uploads.module.css b/apps/web/src/pages/uploads/uploads.module.css index 7eaaf02..50d3740 100644 --- a/apps/web/src/pages/uploads/uploads.module.css +++ b/apps/web/src/pages/uploads/uploads.module.css @@ -320,3 +320,22 @@ text-overflow: ellipsis; white-space: nowrap; } + +@media (max-width: 767px) { + .queueRow { grid-template-columns: 24px minmax(0, 1fr); gap: 10px; padding: 12px; } + .queueRow > :nth-child(3), .queueRow > .rowActions { grid-column: 2; } + .rowActions { flex-wrap: wrap; min-width: 0; } + .rowActions audio { width: 100%; min-width: 0; max-width: 100%; } + .meta { overflow-wrap: anywhere; } + .bulk { bottom: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + .bulk > span:nth-child(2) { display: none; } + .bulk > button { min-width: 0; font-size: 13px; padding: 10px 8px; } + .grid, .settingsGrid { grid-template-columns: minmax(0, 1fr); } + .drop { padding: 20px 12px; } + .dropActions > * { flex: 1 1 auto; } + .quota { min-width: 0; max-width: 100%; } + .file { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; } + .file > :last-child { grid-column: 2; grid-row: 1; } + .file > .size { grid-column: 1; } + .textarea { font-size: 16px; } +} diff --git a/apps/web/src/shared/ui/ui.module.css b/apps/web/src/shared/ui/ui.module.css index b7b41f9..d7c0022 100644 --- a/apps/web/src/shared/ui/ui.module.css +++ b/apps/web/src/shared/ui/ui.module.css @@ -196,6 +196,7 @@ z-index: 50; display: grid; place-items: center; + grid-template-columns: minmax(0, 1fr); padding: 16px; background: var(--overlay); animation: pipuIn 0.2s ease-out; @@ -317,3 +318,22 @@ color: #fff; font-weight: 700; } + +@media (max-width: 767px) { + .button { white-space: normal; height: auto; min-height: 44px; padding: 10px 14px; } + .iconButton { width: 44px; height: 44px; flex: none; } + .input, .select { font-size: 16px; min-width: 0; } + .segmented { flex-wrap: wrap; max-width: 100%; } + .segmented button { min-height: 44px; height: auto; padding: 8px 12px; } + .overlay { padding: 12px; z-index: 60; } + .dialog { min-width: 0; overflow-wrap: anywhere; max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)); } + .dialogHead { padding: 16px 16px 0; align-items: flex-start; } + .dialogHead h2 { overflow-wrap: anywhere; font-size: 18px; } + .dialogBody { padding: 16px; min-width: 0; } + .field, .inputWrap { min-width: 0; } + .dialogFoot { padding: 0 16px 16px; flex-wrap: wrap; } + .dialogFoot > * { flex: 1 1 auto; } + .menu { position: fixed; inset: auto 12px calc(12px + env(safe-area-inset-bottom, 0px)); + width: auto; min-width: 0; max-width: none; max-height: 75dvh; overflow-y: auto; z-index: 70; } + .menuItem { min-height: 44px; height: auto; padding: 12px; white-space: normal; overflow-wrap: anywhere; } +} diff --git a/apps/web/src/shared/ui/ui.tsx b/apps/web/src/shared/ui/ui.tsx index fd7a1f2..b1b3459 100644 --- a/apps/web/src/shared/ui/ui.tsx +++ b/apps/web/src/shared/ui/ui.tsx @@ -8,6 +8,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons'; @@ -162,6 +163,18 @@ export function Dialog({ (first ?? ref.current)?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); + if (e.key === 'Tab') { + const controls = [...(ref.current?.querySelectorAll( + 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]', + ) ?? [])].filter((el) => el.getClientRects().length > 0); + const first = controls[0]; + const last = controls[controls.length - 1]; + if (e.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) { + e.preventDefault(); last?.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); first?.focus(); + } + } }; document.addEventListener('keydown', onKey); return () => { @@ -169,7 +182,7 @@ export function Dialog({ prev?.focus(); }; }, [onClose]); - return ( + return createPortal(
e.target === e.currentTarget && onClose()}>
@@ -181,7 +194,8 @@ export function Dialog({
{children}
{footer &&
{footer}
}
-
+
, + document.body, ); } @@ -205,17 +219,18 @@ export function Menu({ }) { const [open, setOpen] = useState(false); const ref = useRef(null); + const menuRef = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { - if (!ref.current?.contains(e.target as Node)) setOpen(false); + if (!ref.current?.contains(e.target as Node) && !menuRef.current?.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); - ref.current?.querySelector('[role="menuitem"]')?.focus(); + menuRef.current?.querySelector('[role="menuitem"]')?.focus(); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); @@ -224,11 +239,31 @@ export function Menu({ const onMenuKey = (e: ReactKeyboardEvent) => { if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; e.preventDefault(); - const els = [...(ref.current?.querySelectorAll('[role="menuitem"]') ?? [])]; + const els = [...(menuRef.current?.querySelectorAll('[role="menuitem"]') ?? [])]; const i = els.indexOf(document.activeElement as HTMLElement); const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length; els[next]?.focus(); }; + const menu = ( +
+ {header} + {items.map((it) => ( + + ))} +
+ ); return (
- {open && ( -
- {header} - {items.map((it) => ( - - ))} -
- )} + {open && (window.matchMedia?.('(max-width: 767px)').matches + ? createPortal(menu, document.body) + : menu)}
); } diff --git a/apps/web/src/test/e2eBridge.ts b/apps/web/src/test/e2eBridge.ts index f97f7a8..4e09ac7 100644 --- a/apps/web/src/test/e2eBridge.ts +++ b/apps/web/src/test/e2eBridge.ts @@ -1,14 +1,21 @@ import { fakeApi, makeUser } from './fakeApi'; +import { mobileFixtures } from './mobileFixtures'; /** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */ export function installE2EFetch() { const api = fakeApi([ makeUser('admin', 'admin', { must_change_credentials: true }), makeUser('alice', 'user'), + makeUser('mobile-admin', 'admin', { reviews_uploads: true }), + makeUser('moderator', 'moderator', { reviews_uploads: true }), ]); const orig = window.fetch.bind(window); window.fetch = async (input, init) => { const req = new Request(input, init); + const path = new URL(req.url, window.location.origin).pathname; + if (req.method === 'GET' && path in mobileFixtures) { + return new Response(JSON.stringify(mobileFixtures[path]), { headers: { 'Content-Type': 'application/json' } }); + } if (new URL(req.url, window.location.origin).pathname.startsWith('/api/')) return api.fetch(req); return orig(input, init); }; diff --git a/apps/web/src/test/mobileFixtures.ts b/apps/web/src/test/mobileFixtures.ts new file mode 100644 index 0000000..7b46781 --- /dev/null +++ b/apps/web/src/test/mobileFixtures.ts @@ -0,0 +1,31 @@ +import type { components } from '@pipu/api-client'; + +const source: components['schemas']['Source'] = { + id: '00000000-0000-4000-8000-000000000099', name: 'Музыкальное хранилище', type: 'local', + nfs_server: null, nfs_export: null, nfs_options: '', local_path: 'music/very-long-library-folder/collection', + tag_priority: 'tags_first', scan_interval_minutes: null, accepts_uploads: true, + write_template: null, status: 'mounted', status_message: null, last_scan_at: null, + created_at: '2026-01-01T00:00:00Z', scan: null, +}; +const file: components['schemas']['UploadFile'] = { + id: '00000000-0000-4000-8000-000000000098', batch_id: '00000000-0000-4000-8000-000000000097', + batch_comment: 'Альбом для проверки', author: { id: '00000000-0000-4000-8000-000000000096', username: 'moderator' }, + status: 'pending_review', original_name: 'Длинное_название_трека_для_проверки_переноса.flac', + client_rel_path: 'music/album/Длинное_название_трека_для_проверки_переноса.flac', size_bytes: 42000000, + received_bytes: 42000000, format: 'FLAC', codec: 'flac', bitrate: 1000000, sample_rate: 44100, bit_depth: 16, + duration_ms: 200000, tags: {}, metadata: null, metadata_edited: false, matched_artist_id: null, + matched_album_id: null, duplicate_of_track_id: null, duplicate_level: null, possible_duplicate: true, + quality_upgrade: false, target_source_id: source.id, target_rel_path: null, status_reason: null, + reviewed_at: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', tus_url: null, + stream_url: '/api/v1/stream/mobile-preview', +}; + +/** Populated staff screens for browser layout checks; loaded only by the E2E bridge. */ +export const mobileFixtures: Record = { + '/api/v1/admin/sources': { items: [source] }, + [`/api/v1/admin/sources/${source.id}`]: source, + [`/api/v1/admin/sources/${source.id}/patterns`]: { patterns: [] }, + '/api/v1/admin/uploads': { items: [file], next_cursor: null }, + '/api/v1/uploads/quota': { limit_bytes: 10000000000, used_bytes: 42000000, per_user_limit_bytes: null, + user_used_bytes: 42000000, max_file_bytes: 1000000000 }, +}; diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css index ba32f07..05948c5 100644 --- a/apps/web/src/widgets/AppShell.module.css +++ b/apps/web/src/widgets/AppShell.module.css @@ -2,13 +2,14 @@ --side-w: 240px; position: relative; display: grid; - grid-template-columns: var(--side-w) 1fr; - grid-template-rows: 60px 1fr 88px; + grid-template-columns: var(--side-w) minmax(0, 1fr); + grid-template-rows: 60px minmax(0, 1fr) 88px; grid-template-areas: 'top top' 'side content' 'player player'; height: 100%; + height: 100dvh; } .shell > :where(:not([aria-hidden])) { @@ -18,7 +19,7 @@ /* Плеер-остров: плавает над контентом, контенту — запас снизу (docs/design.md#4). */ .island { - grid-template-rows: 60px 1fr; + grid-template-rows: 60px minmax(0, 1fr); grid-template-areas: 'top top' 'side content'; @@ -228,6 +229,8 @@ .content { grid-area: content; + min-width: 0; + min-height: 0; overflow-y: auto; padding: 32px 36px; animation: pipuIn 0.2s ease-out; @@ -268,36 +271,67 @@ } } +.mobileBottom { display: none; } + @media (max-width: 767px) { - .shell { + .shell, .shell.island { --side-w: 0px; - grid-template-columns: 1fr; - grid-template-rows: 60px 1fr 72px 64px; - grid-template-areas: - 'top' - 'content' - 'player' - 'side'; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) 72px calc(64px + env(safe-area-inset-bottom, 0px)); + grid-template-areas: 'top' 'content' 'player' 'mobile'; } - .side { - flex-direction: row; - justify-content: space-around; - padding: 6px 8px; - border-right: 0; + .top { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + padding: calc(8px + env(safe-area-inset-top, 0px)) 16px 10px; + } + .topLeft, .topCenter, .topRight { display: contents; } + .logoLink { grid-area: 1 / 1; justify-self: start; } + .topRight > :last-child { grid-area: 1 / 2; justify-self: end; } + .topLeft > :not(.logoLink), .topCenter > *, .topRight > :not(:last-child) { + grid-column: 1 / -1; + grid-row: 2; + min-width: 0; + } + .profileButton { min-width: 44px; min-height: 44px; } + .profileHead { grid-template-columns: auto minmax(0, 1fr); } + .profileName, .profileMeta { white-space: normal; overflow-wrap: anywhere; } + .side { display: none; } + .mobileBottom { + grid-area: mobile; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 4px; + padding: 4px 8px calc(4px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--line); + background: var(--bar-a); } - - .navSection { - display: none; + .mobileBottom .navItem { + position: relative; + flex-direction: column; + gap: 3px; + height: 56px; + padding: 6px 2px; + font-size: 11px; } - - .navItem { - flex: 1; + .mobileBottom .navLabel { display: block; flex: none; max-width: 100%; } + .mobileDot { + position: absolute; top: 6px; right: 24%; + width: 8px; height: 8px; border-radius: 50%; background: var(--accent2); } - - .content { - padding: 20px 16px; + .mobileMenu { display: flex; flex-direction: column; gap: 4px; } + .mobileMenu .navItem { + justify-content: flex-start; height: auto; min-height: 48px; padding: 8px 12px; font-size: 14px; } - + .mobileMenu .navLabel, .mobileMenu .navCount, .mobileMenu .navBadge { display: block; } + .mobileMenu .navLabel { white-space: normal; } + .mobileMenu .navGroup, .mobileMenu .navGroupFolded { display: contents; } + .mobileMenu .navSection { + display: flex; margin: 12px 0 0; padding: 10px 12px 4px; + border: 0; pointer-events: none; + } + .mobileMenu .navSectionTitle { display: block; } + .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 c65bb70..368ba26 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -1,12 +1,13 @@ import { unwrap } from '@pipu/core'; import { useQuery } from '@tanstack/react-query'; import { Link, Outlet, useNavigate } from '@tanstack/react-router'; -import { Suspense, useState, type ReactNode } from 'react'; +import { Suspense, useCallback, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuthState, useSession, useUser } from '../app/session'; import { useCollectionCounts } from '../entities/collection'; import { + moreIcon, auditFillIcon, auditIcon, groupFillIcon, @@ -27,7 +28,7 @@ import { chevronRightIcon, storageFillIcon, storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons'; -import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui'; +import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui'; import { LanguageSwitch } from './LanguageSwitch'; import { PlayerProvider, usePlayer, usePlayerState } from '../player/context'; import { Ambient } from './Ambient'; @@ -65,7 +66,7 @@ function NavLink({ badge?: number; // яркий бейдж (ждут решения) }) { return ( - + {({ isActive }) => ( <> @@ -97,12 +98,14 @@ function NavSection({ badge, children, defaultFolded = false, + mobile = false, }: { id: string; title: string; badge?: number; children: ReactNode; defaultFolded?: boolean; + mobile?: boolean; }) { const [folded, setFolded] = useState(() => { if (readIds(FOLDED_KEY).includes(id)) return true; @@ -123,12 +126,16 @@ function NavSection({ }; return ( <> - -
{children}
+ {mobile ? ( +

{title}

+ ) : ( + + )} +
{children}
); } @@ -156,6 +163,60 @@ function Shell() { const reviews = user.reviews_uploads; const pending = usePendingUploads(reviews); const unseen = useUnseenUploads(staff); + const [mobileOpen, setMobileOpen] = useState(false); + const closeMobile = useCallback(() => setMobileOpen(false), []); + const navigation = (mobile = false) => ( + <> + + + + + + + {!staff && reviews && ( + + )} + {staff && ( + + + {user.role === 'admin' && ( + + )} + + + + {reviews && ( + + )} + {user.role === 'admin' && ( + <> + + + + )} + + )} + + ); + return (
@@ -195,55 +256,26 @@ function Shell() {
-