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
@@ -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