fix: repair Android startup and improve mobile web layouts
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 2m22s
release / android (APK в релиз) (push) Successful in 4m15s
release / release (версия и заметки) (push) Successful in 6s

This commit is contained in:
mrleo1nid committed 2026-10-21 17:14:05 +03:00
1 parent 4c2286e70d
commit b0cbd0f149
30 files changed
+609 -140

No files matched your search

+10
View File
@@ -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"
+3 -1
View File
@@ -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',
],
+22 -15
View File
@@ -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 <Splash />;
return (
<ThemeContext.Provider value={theme}>
@@ -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 (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
{/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */}
<Stack.Protected guard={!connected}>
<Stack.Screen name="connect" />
</Stack.Protected>
<Stack.Protected guard={connected && !user}>
<Stack.Screen name="login" />
</Stack.Protected>
<Stack.Protected guard={needsSetup}>
<Stack.Screen name="setup" />
</Stack.Protected>
<Stack.Protected guard={!!user && !needsSetup}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="album/[id]" />
<Stack.Screen name="artist/[id]" />
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack.Protected>
</Stack>
);
}
+119
View File
@@ -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') });
});
+4
View File
@@ -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=/);
});
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>pipu</title>
+8
View File
@@ -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; }
}
+6 -6
View File
@@ -181,7 +181,7 @@ export function UsersPage() {
{recsDone && <p className={s.ok}>{t('users.recs_done', { username: recsDone })}</p>}
<div className={s.tableWrap}>
<table className={s.table}>
<table className={cx(s.table, s.mobileCards)}>
<thead>
<tr>
<th>{t('users.col_user')}</th>
@@ -197,7 +197,7 @@ export function UsersPage() {
const st = status(u);
return (
<tr key={u.id}>
<td>
<td data-label={t('users.col_user')}>
<div className={s.userCell}>
<Avatar name={u.display_name ?? u.username} />
<div>
@@ -209,17 +209,17 @@ export function UsersPage() {
</div>
</div>
</td>
<td>
<td data-label={t('users.col_role')}>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
</td>
<td>
<td data-label={t('users.col_status')}>
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
</td>
<td className={s.muted}>
<td className={s.muted} data-label={t('users.col_last_login')}>
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
</td>
<td>
<td className={s.cardAction}>
{items.length > 0 && (
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
)}
@@ -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; }
}
+7
View File
@@ -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; }
}
+8
View File
@@ -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); }
}
+26
View File
@@ -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; }
}
@@ -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; }
}
+20
View File
@@ -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; }
}
+43 -25
View File
@@ -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<HTMLElement>(
'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(
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div className={s.dialogHead}>
@@ -181,7 +194,8 @@ export function Dialog({
<div className={s.dialogBody}>{children}</div>
{footer && <div className={s.dialogFoot}>{footer}</div>}
</div>
</div>
</div>,
document.body,
);
}
@@ -205,17 +219,18 @@ export function Menu({
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(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<HTMLElement>('[role="menuitem"]')?.focus();
menuRef.current?.querySelector<HTMLElement>('[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<HTMLElement>('[role="menuitem"]') ?? [])];
const els = [...(menuRef.current?.querySelectorAll<HTMLElement>('[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 = (
<div ref={menuRef} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
{header}
{items.map((it) => (
<button
key={it.label}
type="button"
role="menuitem"
className={cx(s.menuItem, it.danger && s.menuDanger)}
onClick={() => {
setOpen(false);
it.onSelect();
}}
>
{it.icon && <Icon src={it.icon} size={18} />}
{it.label}
</button>
))}
</div>
);
return (
<div className={s.menuWrap} ref={ref}>
<button
@@ -241,26 +276,9 @@ export function Menu({
>
{trigger}
</button>
{open && (
<div className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
{header}
{items.map((it) => (
<button
key={it.label}
type="button"
role="menuitem"
className={cx(s.menuItem, it.danger && s.menuDanger)}
onClick={() => {
setOpen(false);
it.onSelect();
}}
>
{it.icon && <Icon src={it.icon} size={18} />}
{it.label}
</button>
))}
</div>
)}
{open && (window.matchMedia?.('(max-width: 767px)').matches
? createPortal(menu, document.body)
: menu)}
</div>
);
}
+7
View File
@@ -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);
};
+31
View File
@@ -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<string, unknown> = {
'/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 },
};
+60 -26
View File
@@ -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; }
}
+88 -56
View File
@@ -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 (
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
<Link to={to} aria-label={label} title={label} className={s.navItem} activeOptions={{ exact: to === '/' }}>
{({ isActive }) => (
<>
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
@@ -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 (
<>
<button type="button" className={s.navSection} aria-expanded={!folded} 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, folded && s.navGroupFolded)}>{children}</div>
{mobile ? (
<h3 className={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3>
) : (
<button type="button" className={s.navSection} aria-expanded={!folded} 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>
</>
);
}
@@ -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) => (
<>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink
to="/collection/dislikes"
label={t('nav.dislikes')}
icon={dislikeIcon}
activeIcon={dislikeFillIcon}
count={counts.dislikes}
/>
</NavSection>
{!staff && reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{staff && (
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} 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' && (
<>
<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>
)}
</>
);
return (
<div className={cx(s.shell, island && s.island)}>
@@ -195,55 +256,26 @@ function Shell() {
</div>
</header>
<nav className={s.side} aria-label={t('nav.main')}>
<nav className={s.side} aria-label={t('nav.main')}>{navigation()}</nav>
<nav className={s.mobileBottom} aria-label={t('nav.main')}>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavSection id="collection" title={t('nav.collection')}>
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink
to="/collection/dislikes"
label={t('nav.dislikes')}
icon={dislikeIcon}
activeIcon={dislikeFillIcon}
count={counts.dislikes}
/>
</NavSection>
{!staff && reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{staff && (
<NavSection id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} 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' && (
<>
<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>
)}
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
<button type="button" className={s.navItem} aria-label={t('nav.more')}
aria-haspopup="dialog" aria-expanded={mobileOpen} onClick={() => setMobileOpen(true)}>
<Icon src={moreIcon} className={s.navIcon} />
<span className={s.navLabel}>{t('nav.more')}</span>
{(pending ?? 0) + (unseen ?? 0) > 0 && <span className={s.mobileDot} />}
</button>
</nav>
{mobileOpen && (
<Dialog title={t('nav.main')} onClose={closeMobile}>
<nav className={s.mobileMenu} aria-label={t('nav.main')}
onClick={(e) => { if ((e.target as HTMLElement).closest('a')) closeMobile(); }}>
{navigation(true)}
</nav>
</Dialog>
)}
<main className={s.content}>
<Suspense fallback={null}>
+21
View File
@@ -297,3 +297,24 @@ a.source:hover {
.queueCurrent .title {
color: var(--accent2);
}
@media (max-width: 767px) {
.bar { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px; padding: 0 8px; }
.bar.island { position: relative; left: auto; bottom: auto; width: auto; height: auto;
transform: none; margin: 0 8px; border-radius: 16px; }
.empty { grid-column: 1 / -1; }
.placeholder { flex: none; }
.now { gap: 8px; }
.now > :first-child { width: 44px !important; height: 44px !important; }
.source { display: none; }
.controls { gap: 2px; }
.play, .icon { width: 44px; height: 44px; }
.right { display: flex; gap: 0; }
.right > :not(.queueButton) { display: none; }
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 359px) {
.now > :first-child { display: none; }
}
+1 -1
View File
@@ -175,7 +175,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<button
ref={queueButton}
type="button"
className={cx(s.icon, queueOpen && s.accent)}
className={cx(s.icon, s.queueButton, queueOpen && s.accent)}
aria-label={t('player.queue')}
aria-expanded={queueOpen}
aria-controls={queueOpen ? queueId : undefined}
+11
View File
@@ -135,6 +135,7 @@
cursor: pointer;
}
.option:hover,
.active {
background: var(--hover);
}
@@ -185,3 +186,13 @@
display: none;
}
}
@media (max-width: 767px) {
.box, .expanded { width: 100%; min-width: 0; }
.input { height: 44px; font-size: 16px; }
.box:not(.expanded) .input { padding: 0 40px; color: var(--text); }
.box:not(.expanded) .input::placeholder { color: var(--text3); }
.left .popover, .center .popover, .right .popover { width: 100%; left: 0; right: auto; transform: none; max-height: 55dvh; }
.option { min-height: 48px; }
.all { min-height: 44px; height: auto; padding: 10px; }
}
-1
View File
@@ -209,7 +209,6 @@ export function SearchBox({
aria-selected={i === active}
className={cx(s.option, i === active && s.active)}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActive(i)}
onClick={() => choose(i)}
>
<Cover album={it.cover} size={36} round={it.round} />