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
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:
1 parent
73e642c1ec
commit
b27b915b63
13 files changed
+470
-284
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user