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

+21 -15
View File
@@ -84,6 +84,8 @@ function WaveHome() {
staleTime: Infinity,
});
const showActivities = user.ui_prefs.home_activities !== false;
const showMoods = user.ui_prefs.home_moods !== false;
const waveOn = st.wave !== null;
const playing = waveOn && st.playing;
const track = waveOn ? st.track : null;
@@ -133,22 +135,26 @@ function WaveHome() {
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
<FormError>{error ? errorText(error) : null}</FormError>
{options.data && (
{options.data && (showActivities || showMoods) && (
<div className={s.picker}>
<Chips
title={t('wave.activity_label')}
options={options.data.activities}
value={activity}
label={(k) => tr(`wave.activities.${k}`)}
onPick={(k) => choose('activity', k)}
/>
<Chips
title={t('wave.mood_label')}
options={options.data.moods}
value={mood}
label={(k) => tr(`wave.moods.${k}`)}
onPick={(k) => choose('mood', k)}
/>
{showActivities && (
<Chips
title={t('wave.activity_label')}
options={options.data.activities}
value={activity}
label={(k) => tr(`wave.activities.${k}`)}
onPick={(k) => choose('activity', k)}
/>
)}
{showMoods && (
<Chips
title={t('wave.mood_label')}
options={options.data.moods}
value={mood}
label={(k) => tr(`wave.moods.${k}`)}
onPick={(k) => choose('mood', k)}
/>
)}
</div>
)}
</div>
+36
View File
@@ -139,6 +139,42 @@ function AppearanceSection() {
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.search_collapse')}</span>
<Segmented<'on' | 'off'>
label={t('settings.search_collapse')}
value={user.ui_prefs.search_collapse === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ search_collapse: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.home_activities')}</span>
<Segmented<'on' | 'off'>
label={t('settings.home_activities')}
value={user.ui_prefs.home_activities === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ home_activities: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.home_moods')}</span>
<Segmented<'on' | 'off'>
label={t('settings.home_moods')}
value={user.ui_prefs.home_moods === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ home_moods: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'>
+53 -3
View File
@@ -118,12 +118,52 @@
}
.navSection {
margin: 18px 12px 6px;
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0 2px;
padding: 6px 12px;
border: 0;
border-radius: 8px;
background: none;
font: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
text-align: left;
cursor: pointer;
}
.navSection:hover {
color: var(--text2);
}
.navSection:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.navSectionTitle {
flex: 1;
}
.navChevron {
transition: transform 0.2s;
}
.navChevronOpen {
transform: rotate(90deg);
}
/* Обёртка не мешает раскладке: пункты остаются детьми .side (на телефоне это нижняя панель). */
.navGroup {
display: contents;
}
.navGroupFolded {
display: none;
}
.navItem {
@@ -209,9 +249,19 @@
.navSection {
margin: 14px 0 6px;
text-align: center;
font-size: 0;
padding: 0;
border-radius: 0;
border-top: 1px solid var(--line);
pointer-events: none;
}
.navSection > * {
display: none;
}
/* Без подписей свернуть раздел нельзя — пункты видны всегда. */
.navGroupFolded {
display: contents;
}
}
+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>
+9 -3
View File
@@ -25,9 +25,15 @@ type Item = {
/**
* Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
* Без фокуса и текста свёрнут в кнопку с лупой; поле раскрывается влево с анимацией.
* Без фокуса и текста свёрнут в кнопку с лупой (если collapsible); поле раскрывается с анимацией.
*/
export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'right' }) {
export function SearchBox({
align = 'center',
collapsible = true,
}: {
align?: 'left' | 'center' | 'right';
collapsible?: boolean;
}) {
const { t } = useTranslation();
const session = useSession();
const navigate = useNavigate();
@@ -40,7 +46,7 @@ export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'r
const [focused, setFocused] = useState(false);
const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
const expanded = focused || q !== '';
const expanded = !collapsible || focused || q !== '';
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);