fix: repair Android startup and improve mobile web layouts
This commit is contained in:
1 parent
4c2286e70d
commit
b0cbd0f149
30 files changed
+609
-140
No files matched your search
@@ -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"
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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') });
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user