feat(web): settings to keep the search open and hide home pickers; collapsible menu sections
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m44s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m43s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / api / api (push) Successful in 2m27s
ci / db / db (push) Successful in 2m23s

- appearance settings get "collapse search" (off keeps the field always open)
  and separate switches for the activity and mood pickers on the home page;
  stored in ui_prefs (search_collapse, home_activities, home_moods)
- Collection and Admin headers in the sidebar fold their items; the state is
  remembered in the browser, a folded Admin shows the review queue badge;
  icon-only and phone layouts always show every item

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-21 08:26:48 +03:00
1 parent 73e642c1ec
commit b27b915b63
13 files changed
+470 -284

No files matched your search

+52 -14
View File
@@ -1,6 +1,7 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
@@ -23,6 +24,7 @@ import {
dislikeIcon,
settingsIcon,
androidIcon,
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';
@@ -76,6 +78,42 @@ function NavLink({
);
}
const FOLDED_KEY = 'pipu.nav.folded';
function readFolded(): string[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(FOLDED_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
/** Раздел меню: заголовок сворачивает пункты; состояние помнит браузер. Свёрнутый раздел показывает сумму бейджей. */
function NavSection({ id, title, badge, children }: { id: string; title: string; badge?: number; children: ReactNode }) {
const [folded, setFolded] = useState(() => readFolded().includes(id));
const toggle = () => {
const next = !folded;
setFolded(next);
try {
const rest = readFolded().filter((x) => x !== id);
localStorage.setItem(FOLDED_KEY, JSON.stringify(next ? [...rest, id] : rest));
} catch {
// Без хранилища раздел просто не запомнит состояние.
}
};
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>
</>
);
}
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
export function AppShell() {
return useAuthState().status === 'signed_in' ? (
@@ -94,7 +132,7 @@ function Shell() {
const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island';
const searchAt = user.ui_prefs.search_position ?? 'center';
const search = <SearchBox align={searchAt} />;
const search = <SearchBox align={searchAt} collapsible={user.ui_prefs.search_collapse !== false} />;
const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin');
@@ -138,19 +176,19 @@ function Shell() {
<nav className={s.side} aria-label={t('nav.main')}>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<div className={s.navSection}>{t('nav.collection')}</div>
<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 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 && (
<>
<div className={s.navSection}>{t('nav.admin')}</div>
<NavSection id="admin" title={t('nav.admin')} badge={pending}>
<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} />
@@ -171,7 +209,7 @@ function Shell() {
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
)}
</>
</NavSection>
)}
</nav>