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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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