- |
+ |
@@ -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() {
- |