Save sidebar state (#6217)

* Save sidebar section open state in browser history state

This means that state is saved when going back, but not when navigating to the scenes page from elsewhere.
This commit is contained in:
WithoutPants 2025-10-31 15:21:43 +11:00 committed by GitHub
parent 299e1ac1f9
commit 336fa3b70e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 182 additions and 96 deletions

View file

@ -54,6 +54,7 @@ interface ISidebarFilter {
option: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
sectionID?: string;
}
export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
@ -61,6 +62,7 @@ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
option,
filter,
setFilter,
sectionID,
}) => {
const intl = useIntl();
@ -127,6 +129,7 @@ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
onUnselect={onUnselect}
selected={selected}
singleValue
sectionID={sectionID}
/>
</>
);

View file

@ -10,6 +10,8 @@ import ScreenUtils from "src/utils/screen";
import Mousetrap from "mousetrap";
import { Button } from "react-bootstrap";
const savedFiltersSectionID = "saved-filters";
export const FilteredSidebarHeader: React.FC<{
sidebarOpen: boolean;
showEditFilter: () => void;
@ -60,6 +62,7 @@ export const FilteredSidebarHeader: React.FC<{
<SidebarSection
className="sidebar-saved-filters"
text={<FormattedMessage id="search_filter.saved_filters" />}
sectionID={savedFiltersSectionID}
>
<SidebarSavedFilterList
filter={filter}

View file

@ -110,7 +110,8 @@ export const SidebarPerformersFilter: React.FC<{
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel;
}> = ({ title, option, filter, setFilter, filterHook }) => {
sectionID?: string;
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
@ -119,7 +120,7 @@ export const SidebarPerformersFilter: React.FC<{
useQuery: usePerformerQueryFilter,
});
return <SidebarListFilter {...state} title={title} />;
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
};
export default PerformersFilter;

View file

@ -77,6 +77,7 @@ interface ISidebarFilter {
option: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
sectionID?: string;
}
const any = "any";
@ -87,6 +88,7 @@ export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
option,
filter,
setFilter,
sectionID,
}) => {
const intl = useIntl();
@ -199,6 +201,7 @@ export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
singleValue
preCandidates={ratingValue === null ? ratingStars : undefined}
preSelected={ratingValue !== null ? ratingStars : undefined}
sectionID={sectionID}
/>
<div></div>
</>

View file

@ -275,7 +275,8 @@ export const SidebarListFilter: React.FC<{
postSelected?: React.ReactNode;
preCandidates?: React.ReactNode;
postCandidates?: React.ReactNode;
onOpen?: () => void;
// used to store open/closed state in SidebarStateContext
sectionID?: string;
}> = ({
title,
selected,
@ -291,7 +292,7 @@ export const SidebarListFilter: React.FC<{
postCandidates,
preSelected,
postSelected,
onOpen,
sectionID,
}) => {
// TODO - sort items?
@ -325,6 +326,7 @@ export const SidebarListFilter: React.FC<{
<SidebarSection
className="sidebar-list-filter"
text={title}
sectionID={sectionID}
outsideCollapse={
<>
{preSelected ? <div className="extra">{preSelected}</div> : null}
@ -342,7 +344,6 @@ export const SidebarListFilter: React.FC<{
{postSelected ? <div className="extra">{postSelected}</div> : null}
</>
}
onOpen={onOpen}
>
{preCandidates ? <div className="extra">{preCandidates}</div> : null}
<CandidateList

View file

@ -98,7 +98,8 @@ export const SidebarStudiosFilter: React.FC<{
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel;
}> = ({ title, option, filter, setFilter, filterHook }) => {
sectionID?: string;
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
@ -110,7 +111,7 @@ export const SidebarStudiosFilter: React.FC<{
includeSubMessageID: "subsidiary_studios",
});
return <SidebarListFilter {...state} title={title} />;
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
};
export default StudiosFilter;

View file

@ -103,7 +103,8 @@ export const SidebarTagsFilter: React.FC<{
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel;
}> = ({ title, option, filter, setFilter, filterHook }) => {
sectionID?: string;
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
@ -114,7 +115,7 @@ export const SidebarTagsFilter: React.FC<{
includeSubMessageID: "sub_tags",
});
return <SidebarListFilter {...state} title={title} />;
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
};
export default TagsFilter;

View file

@ -41,6 +41,7 @@ import {
Sidebar,
SidebarPane,
SidebarPaneContent,
SidebarStateContext,
useSidebarState,
} from "../Shared/Sidebar";
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
@ -290,6 +291,7 @@ const SidebarContent: React.FC<{
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
sectionID="studios"
/>
)}
<SidebarPerformersFilter
@ -299,6 +301,7 @@ const SidebarContent: React.FC<{
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
sectionID="performers"
/>
<SidebarTagsFilter
title={<FormattedMessage id="tags" />}
@ -307,6 +310,7 @@ const SidebarContent: React.FC<{
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
sectionID="tags"
/>
<SidebarRatingFilter
title={<FormattedMessage id="rating" />}
@ -314,6 +318,7 @@ const SidebarContent: React.FC<{
option={RatingCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="rating"
/>
<SidebarBooleanFilter
title={<FormattedMessage id="organized" />}
@ -321,6 +326,7 @@ const SidebarContent: React.FC<{
option={OrganizedCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="organized"
/>
</ScenesFilterSidebarSections>
@ -447,6 +453,8 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
showSidebar,
setShowSidebar,
loading: sidebarStateLoading,
sectionOpen,
setSectionOpen,
} = useSidebarState(view);
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
@ -695,6 +703,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
>
{modal}
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
<SidebarPane hideSidebar={!showSidebar}>
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
<SidebarContent
@ -727,7 +736,9 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
setShowSidebar(true);
setSearchFocus(true);
}}
onRemoveSearchTerm={() => setFilter(filter.clearSearchTerm())}
onRemoveSearchTerm={() =>
setFilter(filter.clearSearchTerm())
}
view={view}
/>
}
@ -775,6 +786,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
)}
</SidebarPaneContent>
</SidebarPane>
</SidebarStateContext.Provider>
</div>
</TaggerContext>
);

View file

@ -3,7 +3,7 @@ import {
faChevronRight,
faChevronUp,
} from "@fortawesome/free-solid-svg-icons";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Button, Collapse, CollapseProps } from "react-bootstrap";
import { Icon } from "./Icon";
@ -12,21 +12,26 @@ interface IProps {
text: React.ReactNode;
collapseProps?: Partial<CollapseProps>;
outsideCollapse?: React.ReactNode;
onOpen?: () => void;
onOpenChanged?: (o: boolean) => void;
open?: boolean;
}
export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
props: React.PropsWithChildren<IProps>
) => {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(props.open ?? false);
function toggleOpen() {
const nv = !open;
setOpen(nv);
if (props.onOpen && nv) {
props.onOpen();
props.onOpenChanged?.(nv);
}
useEffect(() => {
if (props.open !== undefined) {
setOpen(props.open);
}
}, [props.open]);
return (
<div className={props.className}>

View file

@ -15,6 +15,9 @@ import { Button, CollapseProps } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Icon } from "./Icon";
import { faSliders } from "@fortawesome/free-solid-svg-icons";
import { useHistory } from "react-router-dom";
export type SidebarSectionStates = Record<string, boolean>;
// this needs to correspond to the CSS media query that overlaps the sidebar over content
const fixedSidebarMediaQuery = "only screen and (max-width: 767px)";
@ -61,14 +64,35 @@ export const SidebarPaneContent: React.FC = ({ children }) => {
return <div className="sidebar-pane-content">{children}</div>;
};
interface IContext {
sectionOpen: SidebarSectionStates;
setSectionOpen: (section: string, open: boolean) => void;
}
export const SidebarStateContext = React.createContext<IContext | null>(null);
export const SidebarSection: React.FC<
PropsWithChildren<{
text: React.ReactNode;
className?: string;
outsideCollapse?: React.ReactNode;
onOpen?: () => void;
// used to store open/closed state in SidebarStateContext
sectionID?: string;
}>
> = ({ className = "", text, outsideCollapse, onOpen, children }) => {
> = ({ className = "", text, outsideCollapse, sectionID = "", children }) => {
// this is optional
const contextState = React.useContext(SidebarStateContext);
const openState =
!contextState || !sectionID
? undefined
: contextState.sectionOpen[sectionID] ?? undefined;
function onOpenInternal(open: boolean) {
if (contextState && sectionID) {
contextState.setSectionOpen(sectionID, open);
}
}
const collapseProps: Partial<CollapseProps> = {
mountOnEnter: true,
unmountOnExit: true,
@ -79,7 +103,8 @@ export const SidebarSection: React.FC<
collapseProps={collapseProps}
text={text}
outsideCollapse={outsideCollapse}
onOpen={onOpen}
onOpenChanged={onOpenInternal}
open={openState}
>
{children}
</CollapseButton>
@ -110,6 +135,7 @@ export function defaultShowSidebar() {
export function useSidebarState(view?: View) {
const [interfaceLocalForage, setInterfaceLocalForage] =
useInterfaceLocalForage();
const history = useHistory();
const { data: interfaceLocalForageData, loading } = interfaceLocalForage;
@ -118,6 +144,7 @@ export function useSidebarState(view?: View) {
}, [view, interfaceLocalForageData]);
const [showSidebar, setShowSidebar] = useState<boolean>();
const [sectionOpen, setSectionOpen] = useState<SidebarSectionStates>();
// set initial state once loading is done
useEffect(() => {
@ -132,7 +159,17 @@ export function useSidebarState(view?: View) {
// only show sidebar by default on large screens
setShowSidebar(!!viewConfig.showSidebar && defaultShowSidebar());
}, [view, loading, showSidebar, viewConfig.showSidebar]);
setSectionOpen(
(history.location.state as { sectionOpen?: SidebarSectionStates })
?.sectionOpen || {}
);
}, [
view,
loading,
showSidebar,
viewConfig.showSidebar,
history.location.state,
]);
const onSetShowSidebar = useCallback(
(show: boolean | ((prevState: boolean | undefined) => boolean)) => {
@ -154,9 +191,28 @@ export function useSidebarState(view?: View) {
[showSidebar, setInterfaceLocalForage, view, viewConfig]
);
const onSetSectionOpen = useCallback(
(section: string, open: boolean) => {
const newSectionOpen = { ...sectionOpen, [section]: open };
setSectionOpen(newSectionOpen);
if (view === undefined) return;
history.replace({
...history.location,
state: {
...(history.location.state as {}),
sectionOpen: newSectionOpen,
},
});
},
[sectionOpen, view, history]
);
return {
showSidebar: showSidebar ?? defaultShowSidebar(),
sectionOpen: sectionOpen || {},
setShowSidebar: onSetShowSidebar,
setSectionOpen: onSetSectionOpen,
loading: showSidebar === undefined,
};
}