Scene Filter sidebar (#5714)

* Add Sidebar component
* Add PerformerQuickFilter to Scene filter sidebar
* Add other quick filters
* Add confirmVariant field to AlertModal
* Add SidebarSavedFilterList
* Add sidebar toggle button
* Add data-type attr for criterion option
* Refactor LabeledIdFilter
* Move search input into sidebar
* Save sidebar state in local forage
* Add sidebar rating filter
* Add organised filter
* Open sidebar to / key. Focus search input on sidebar open
* Blur clearable input on escape key
This commit is contained in:
WithoutPants 2025-06-11 15:55:10 +10:00 committed by GitHub
parent a91b9c4d92
commit ed4d17b8f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2883 additions and 232 deletions

View file

@ -8,9 +8,11 @@ import {
IListFilterOperation, IListFilterOperation,
ListOperationButtons, ListOperationButtons,
} from "./ListOperationButtons"; } from "./ListOperationButtons";
import { ButtonToolbar } from "react-bootstrap"; import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
import { View } from "./views"; import { View } from "./views";
import { IListSelect, useFilterOperations } from "./util"; import { IListSelect, useFilterOperations } from "./util";
import { SidebarIcon } from "../Shared/Sidebar";
import { useIntl } from "react-intl";
export interface IItemListOperation<T extends QueryResult> { export interface IItemListOperation<T extends QueryResult> {
text: string; text: string;
@ -41,6 +43,7 @@ export interface IFilteredListToolbar {
onDelete?: () => void; onDelete?: () => void;
operations?: IListFilterOperation[]; operations?: IListFilterOperation[];
zoomable?: boolean; zoomable?: boolean;
onToggleSidebar?: () => void;
} }
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
@ -53,7 +56,9 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
onDelete, onDelete,
operations, operations,
zoomable = false, zoomable = false,
onToggleSidebar,
}) => { }) => {
const intl = useIntl();
const filterOptions = filter.options; const filterOptions = filter.options;
const { setDisplayMode, setZoom } = useFilterOperations({ const { setDisplayMode, setZoom } = useFilterOperations({
filter, filter,
@ -63,29 +68,52 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
return ( return (
<ButtonToolbar className="filtered-list-toolbar"> <ButtonToolbar className="filtered-list-toolbar">
{showEditFilter && ( {onToggleSidebar && (
<ListFilter <div>
onFilterUpdate={setFilter} <ButtonGroup>
filter={filter} <Button
openFilterDialog={() => showEditFilter()} className="sidebar-toggle-button"
view={view} onClick={onToggleSidebar}
/> variant="secondary"
title={intl.formatMessage({ id: "actions.sidebar.open" })}
>
<SidebarIcon />
</Button>
</ButtonGroup>
</div>
)} )}
<ListOperationButtons
onSelectAll={onSelectAll} <div>
onSelectNone={onSelectNone} <ButtonGroup>
otherOperations={operations} {showEditFilter && (
itemsSelected={selectedIds.size > 0} <ListFilter
onEdit={onEdit} onFilterUpdate={setFilter}
onDelete={onDelete} filter={filter}
/> openFilterDialog={() => showEditFilter()}
<ListViewOptions view={view}
displayMode={filter.displayMode} withSidebar={!!onToggleSidebar}
displayModeOptions={filterOptions.displayModeOptions} />
onSetDisplayMode={setDisplayMode} )}
zoomIndex={zoomable ? filter.zoomIndex : undefined} <ListOperationButtons
onSetZoom={zoomable ? setZoom : undefined} onSelectAll={onSelectAll}
/> onSelectNone={onSelectNone}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={onEdit}
onDelete={onDelete}
/>
<ListViewOptions
displayMode={filter.displayMode}
displayModeOptions={filterOptions.displayModeOptions}
onSetDisplayMode={setDisplayMode}
zoomIndex={zoomable ? filter.zoomIndex : undefined}
onSetZoom={zoomable ? setZoom : undefined}
/>
</ButtonGroup>
</div>
<div>
<ButtonGroup></ButtonGroup>
</div>
</ButtonToolbar> </ButtonToolbar>
); );
}; };

View file

@ -1,8 +1,13 @@
import cloneDeep from "lodash-es/cloneDeep"; import cloneDeep from "lodash-es/cloneDeep";
import React from "react"; import React, { useMemo } from "react";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { BooleanCriterion } from "src/models/list-filter/criteria/criterion"; import {
import { FormattedMessage } from "react-intl"; BooleanCriterion,
CriterionOption,
} from "src/models/list-filter/criteria/criterion";
import { FormattedMessage, useIntl } from "react-intl";
import { ListFilterModel } from "src/models/list-filter/filter";
import { Option, SidebarListFilter } from "./SidebarListFilter";
interface IBooleanFilter { interface IBooleanFilter {
criterion: BooleanCriterion; criterion: BooleanCriterion;
@ -43,3 +48,86 @@ export const BooleanFilter: React.FC<IBooleanFilter> = ({
</div> </div>
); );
}; };
interface ISidebarFilter {
title?: React.ReactNode;
option: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
}
export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
title,
option,
filter,
setFilter,
}) => {
const intl = useIntl();
const trueLabel = intl.formatMessage({
id: "true",
});
const falseLabel = intl.formatMessage({
id: "false",
});
const trueOption = useMemo(
() => ({
id: "true",
label: trueLabel,
}),
[trueLabel]
);
const falseOption = useMemo(
() => ({
id: "false",
label: falseLabel,
}),
[falseLabel]
);
const criteria = filter.criteriaFor(option.type) as BooleanCriterion[];
const criterion = criteria.length > 0 ? criteria[0] : null;
const selected: Option[] = useMemo(() => {
if (!criterion) return [];
if (criterion.value === "true") {
return [trueOption];
} else if (criterion.value === "false") {
return [falseOption];
}
return [];
}, [trueOption, falseOption, criterion]);
const options: Option[] = useMemo(() => {
return [trueOption, falseOption].filter((o) => !selected.includes(o));
}, [selected, trueOption, falseOption]);
function onSelect(item: Option) {
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
newCriterion.value = item.id;
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
}
function onUnselect() {
setFilter(filter.removeCriterion(option.type));
}
return (
<>
<SidebarListFilter
title={title}
candidates={options}
onSelect={onSelect}
onUnselect={onUnselect}
selected={selected}
singleValue
/>
</>
);
};

View file

@ -3,6 +3,7 @@ import { Badge, Button } from "react-bootstrap";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { faFilter } from "@fortawesome/free-solid-svg-icons"; import { faFilter } from "@fortawesome/free-solid-svg-icons";
import { Icon } from "src/components/Shared/Icon"; import { Icon } from "src/components/Shared/Icon";
import { useIntl } from "react-intl";
interface IFilterButtonProps { interface IFilterButtonProps {
filter: ListFilterModel; filter: ListFilterModel;
@ -13,10 +14,16 @@ export const FilterButton: React.FC<IFilterButtonProps> = ({
filter, filter,
onClick, onClick,
}) => { }) => {
const intl = useIntl();
const count = useMemo(() => filter.count(), [filter]); const count = useMemo(() => filter.count(), [filter]);
return ( return (
<Button variant="secondary" className="filter-button" onClick={onClick}> <Button
variant="secondary"
className="filter-button"
onClick={onClick}
title={intl.formatMessage({ id: "search_filter.edit_filter" })}
>
<Icon icon={faFilter} /> <Icon icon={faFilter} />
{count ? ( {count ? (
<Badge pill variant="info"> <Badge pill variant="info">

View file

@ -0,0 +1,96 @@
import React, { useEffect } from "react";
import { FormattedMessage } from "react-intl";
import { SidebarSection, SidebarToolbar } from "src/components/Shared/Sidebar";
import { ListFilterModel } from "src/models/list-filter/filter";
import { FilterButton } from "./FilterButton";
import { SearchTermInput } from "../ListFilter";
import { SidebarSavedFilterList } from "../SavedFilterList";
import { View } from "../views";
import useFocus from "src/utils/focus";
import ScreenUtils from "src/utils/screen";
import Mousetrap from "mousetrap";
export const FilteredSidebarToolbar: React.FC<{
onClose?: () => void;
}> = ({ onClose, children }) => {
return <SidebarToolbar onClose={onClose}>{children}</SidebarToolbar>;
};
export const FilteredSidebarHeader: React.FC<{
sidebarOpen: boolean;
onClose?: () => void;
showEditFilter: () => void;
filter: ListFilterModel;
setFilter: (filter: ListFilterModel) => void;
view?: View;
}> = ({ sidebarOpen, onClose, showEditFilter, filter, setFilter, view }) => {
const focus = useFocus();
const [, setFocus] = focus;
// Set the focus on the input field when the sidebar is opened
// Don't do this on mobile devices
useEffect(() => {
if (sidebarOpen && !ScreenUtils.isMobile()) {
setFocus();
}
}, [sidebarOpen, setFocus]);
return (
<>
<FilteredSidebarToolbar onClose={onClose} />
<div className="sidebar-search-container">
<SearchTermInput
filter={filter}
onFilterUpdate={setFilter}
focus={focus}
/>
<FilterButton onClick={() => showEditFilter()} filter={filter} />
</div>
<SidebarSection
className="sidebar-saved-filters"
text={<FormattedMessage id="search_filter.saved_filters" />}
>
<SidebarSavedFilterList
filter={filter}
onSetFilter={setFilter}
view={view}
/>
</SidebarSection>
</>
);
};
export function useFilteredSidebarKeybinds(props: {
showSidebar: boolean;
setShowSidebar: (show: boolean) => void;
}) {
const { showSidebar, setShowSidebar } = props;
// Show the sidebar when the user presses the "/" key
useEffect(() => {
Mousetrap.bind("/", (e) => {
if (!showSidebar) {
setShowSidebar(true);
e.preventDefault();
}
});
return () => {
Mousetrap.unbind("/");
};
}, [showSidebar, setShowSidebar]);
// Hide the sidebar when the user presses the "Esc" key
useEffect(() => {
Mousetrap.bind("esc", (e) => {
if (showSidebar) {
setShowSidebar(false);
e.preventDefault();
}
});
return () => {
Mousetrap.unbind("esc");
};
}, [showSidebar, setShowSidebar]);
}

View file

@ -1,10 +1,22 @@
import React from "react"; import React, { useCallback, useMemo, useState } from "react";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { FilterSelect, SelectObject } from "src/components/Shared/Select"; import { FilterSelect, SelectObject } from "src/components/Shared/Select";
import { objectTitle } from "src/core/files"; import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import { ModifierCriterion } from "src/models/list-filter/criteria/criterion"; import { ILoadResults, useCacheResults } from "src/hooks/data";
import { ILabeledId } from "src/models/list-filter/types"; import {
CriterionOption,
ModifierCriterion,
} from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import {
IHierarchicalLabelValue,
ILabeledId,
ILabeledValueListValue,
} from "src/models/list-filter/types";
import { Option } from "./SidebarListFilter";
import { CriterionModifier } from "src/core/generated-graphql";
import { useIntl } from "react-intl";
interface ILabeledIdFilterProps { interface ILabeledIdFilterProps {
criterion: ModifierCriterion<ILabeledId[]>; criterion: ModifierCriterion<ILabeledId[]>;
@ -63,3 +75,403 @@ export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
</Form.Group> </Form.Group>
); );
}; };
type ModifierValue = "any" | "none" | "any_of" | "only" | "include_subs";
export function getModifierCandidates(props: {
modifier: CriterionModifier;
defaultModifier: CriterionModifier;
hasSelected?: boolean;
hasExcluded?: boolean;
singleValue?: boolean;
hierarchical?: boolean;
}) {
const {
modifier,
defaultModifier,
hasSelected,
hasExcluded,
singleValue,
hierarchical,
} = props;
const ret: ModifierValue[] = [];
if (modifier === defaultModifier && !hasSelected && !hasExcluded) {
ret.push("any");
}
if (modifier === defaultModifier && !hasSelected && !hasExcluded) {
ret.push("none");
}
if (!singleValue && modifier === defaultModifier && hasSelected) {
ret.push("any_of");
}
if (
hierarchical &&
modifier === defaultModifier &&
(hasSelected || hasExcluded)
) {
ret.push("include_subs");
}
if (
!singleValue &&
modifier === defaultModifier &&
hasSelected &&
!hasExcluded
) {
ret.push("only");
}
return ret;
}
export function modifierValueToModifier(key: ModifierValue): CriterionModifier {
switch (key) {
case "any":
return CriterionModifier.NotNull;
case "none":
return CriterionModifier.IsNull;
case "any_of":
return CriterionModifier.Includes;
case "only":
return CriterionModifier.Equals;
}
throw new Error("Invalid modifier value");
}
function getDefaultModifier(singleValue: boolean) {
if (singleValue) {
return CriterionModifier.Includes;
}
return CriterionModifier.IncludesAll;
}
export function useSelectionState(props: {
criterion: ModifierCriterion<ILabeledValueListValue>;
setCriterion: (c: ModifierCriterion<ILabeledValueListValue>) => void;
singleValue?: boolean;
hierarchical?: boolean;
includeSubMessageID?: string;
}) {
const intl = useIntl();
const {
criterion,
setCriterion,
singleValue = false,
hierarchical = false,
includeSubMessageID,
} = props;
const { modifier } = criterion;
const defaultModifier = getDefaultModifier(singleValue);
const selectedModifiers = useMemo(() => {
return {
any: modifier === CriterionModifier.NotNull,
none: modifier === CriterionModifier.IsNull,
any_of: !singleValue && modifier === CriterionModifier.Includes,
only: !singleValue && modifier === CriterionModifier.Equals,
include_subs:
hierarchical &&
modifier === defaultModifier &&
(criterion.value as IHierarchicalLabelValue).depth === -1,
};
}, [modifier, singleValue, criterion.value, defaultModifier, hierarchical]);
const selected = useMemo(() => {
const modifierValues: Option[] = Object.entries(selectedModifiers)
.filter((v) => v[1])
.map((v) => {
const messageID =
v[0] === "include_subs"
? includeSubMessageID
: `criterion_modifier_values.${v[0]}`;
return {
id: v[0],
label: `(${intl.formatMessage({
id: messageID,
})})`,
className: "modifier-object",
};
});
return modifierValues.concat(
criterion.value.items.map((s) => ({
id: s.id,
label: s.label,
}))
);
}, [intl, selectedModifiers, criterion.value.items, includeSubMessageID]);
const excluded = useMemo(() => {
return criterion.value.excluded.map((s) => ({
id: s.id,
label: s.label,
}));
}, [criterion.value.excluded]);
const includingOnly = modifier == CriterionModifier.Equals;
const excludingOnly =
modifier == CriterionModifier.Excludes ||
modifier == CriterionModifier.NotEquals;
const onSelect = useCallback(
(v: Option, exclude: boolean) => {
const newCriterion: ModifierCriterion<ILabeledValueListValue> =
criterion.clone();
if (v.className === "modifier-object") {
if (v.id === "include_subs") {
(newCriterion.value as IHierarchicalLabelValue).depth = -1;
setCriterion(newCriterion);
return;
}
newCriterion.modifier = modifierValueToModifier(v.id as ModifierValue);
setCriterion(newCriterion);
return;
}
// if only exclude is allowed, then add to excluded
if (excludingOnly) {
exclude = true;
}
const items = !exclude ? criterion.value.items : criterion.value.excluded;
const newItems = [...items, v];
if (!exclude) {
newCriterion.value.items = newItems;
} else {
newCriterion.value.excluded = newItems;
}
setCriterion(newCriterion);
},
[excludingOnly, criterion, setCriterion]
);
const onUnselect = useCallback(
(v: Option, exclude: boolean) => {
const newCriterion = criterion.clone();
if (v.className === "modifier-object") {
if (v.id === "include_subs") {
newCriterion.value.depth = 0;
setCriterion(newCriterion);
return;
}
newCriterion.modifier = defaultModifier;
setCriterion(newCriterion);
return;
}
const items = !exclude ? criterion.value.items : criterion.value.excluded;
const newItems = items.filter((i) => i.id !== v.id);
if (!exclude) {
newCriterion.value.items = newItems;
} else {
newCriterion.value.excluded = newItems;
}
setCriterion(newCriterion);
},
[criterion, setCriterion, defaultModifier]
);
return { selected, excluded, onSelect, onUnselect, includingOnly };
}
export function useCriterion(
option: CriterionOption,
filter: ListFilterModel,
setFilter: (f: ListFilterModel) => void
) {
const criterion = useMemo(() => {
const ret = filter.criteria.find(
(c) => c.criterionOption.type === option.type
);
if (ret) return ret as ModifierCriterion<ILabeledValueListValue>;
const newCriterion = filter.makeCriterion(
option.type
) as ModifierCriterion<ILabeledValueListValue>;
return newCriterion;
}, [filter, option]);
const setCriterion = useCallback(
(c: ModifierCriterion<ILabeledValueListValue>) => {
const newCriteria = filter.criteria.filter(
(cc) => cc.criterionOption.type !== option.type
);
if (c.isValid()) newCriteria.push(c);
setFilter(filter.setCriteria(newCriteria));
},
[option.type, setFilter, filter]
);
return { criterion, setCriterion };
}
export function useQueryState(
useQuery: (q: string, skip: boolean) => ILoadResults<ILabeledId[]>,
skip: boolean
) {
const [query, setQuery] = useState("");
const { results: queryResults } = useCacheResults(useQuery(query, skip));
return { query, setQuery, queryResults };
}
export function useCandidates(props: {
criterion: ModifierCriterion<ILabeledValueListValue>;
queryResults: ILabeledId[] | undefined;
selected: Option[];
excluded: Option[];
hierarchical?: boolean;
singleValue?: boolean;
includeSubMessageID?: string;
}) {
const intl = useIntl();
const {
criterion,
queryResults,
selected,
excluded,
hierarchical = false,
singleValue = false,
includeSubMessageID,
} = props;
const { modifier } = criterion;
const results = useMemo(() => {
if (
!queryResults ||
modifier === CriterionModifier.IsNull ||
modifier === CriterionModifier.NotNull
) {
return [];
}
return queryResults.filter(
(p) =>
selected.find((s) => s.id === p.id) === undefined &&
excluded.find((s) => s.id === p.id) === undefined
);
}, [queryResults, modifier, selected, excluded]);
const defaultModifier = getDefaultModifier(singleValue);
const candidates = useMemo(() => {
const hierarchicalCandidate =
hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1;
const modifierCandidates: Option[] = getModifierCandidates({
modifier,
defaultModifier,
hasSelected: selected.length > 0,
hasExcluded: excluded.length > 0,
singleValue,
hierarchical: hierarchicalCandidate,
}).map((v) => {
const messageID =
v === "include_subs"
? includeSubMessageID
: `criterion_modifier_values.${v}`;
return {
id: v,
label: `(${intl.formatMessage({
id: messageID,
})})`,
className: "modifier-object",
canExclude: false,
};
});
return modifierCandidates.concat(
(results ?? []).map((r) => ({
id: r.id,
label: r.label,
}))
);
}, [
defaultModifier,
intl,
modifier,
singleValue,
results,
selected,
excluded,
criterion.value,
hierarchical,
includeSubMessageID,
]);
return candidates;
}
export function useLabeledIdFilterState(props: {
option: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
useQuery: (q: string, skip: boolean) => ILoadResults<ILabeledId[]>;
singleValue?: boolean;
hierarchical?: boolean;
includeSubMessageID?: string;
}) {
const {
option,
filter,
setFilter,
useQuery,
singleValue = false,
hierarchical = false,
includeSubMessageID,
} = props;
// defer querying until the user opens the filter
const [skip, setSkip] = useState(true);
const { query, setQuery, queryResults } = useQueryState(useQuery, skip);
const { criterion, setCriterion } = useCriterion(option, filter, setFilter);
const { selected, excluded, onSelect, onUnselect, includingOnly } =
useSelectionState({
criterion,
setCriterion,
singleValue,
hierarchical,
includeSubMessageID,
});
const candidates = useCandidates({
criterion,
queryResults,
selected,
excluded,
hierarchical,
singleValue,
includeSubMessageID,
});
const onOpen = useCallback(() => {
setSkip(false);
}, []);
return {
candidates,
onSelect,
onUnselect,
selected,
excluded,
canExclude: !includingOnly,
query,
setQuery,
onOpen,
};
}

View file

@ -1,22 +1,27 @@
import React, { useMemo } from "react"; import React, { ReactNode, useMemo } from "react";
import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
import { useFindPerformersQuery } from "src/core/generated-graphql"; import { useFindPerformersForSelectQuery } from "src/core/generated-graphql";
import { ObjectsFilter } from "./SelectableFilter"; import { ObjectsFilter } from "./SelectableFilter";
import { sortByRelevance } from "src/utils/query"; import { sortByRelevance } from "src/utils/query";
import { ListFilterModel } from "src/models/list-filter/filter";
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
import { useLabeledIdFilterState } from "./LabeledIdFilter";
import { SidebarListFilter } from "./SidebarListFilter";
interface IPerformersFilter { interface IPerformersFilter {
criterion: PerformersCriterion; criterion: PerformersCriterion;
setCriterion: (c: PerformersCriterion) => void; setCriterion: (c: PerformersCriterion) => void;
} }
function usePerformerQuery(query: string) { function usePerformerQuery(query: string, skip?: boolean) {
const { data, loading } = useFindPerformersQuery({ const { data, loading } = useFindPerformersForSelectQuery({
variables: { variables: {
filter: { filter: {
q: query, q: query,
per_page: 200, per_page: 200,
}, },
}, },
skip,
}); });
const results = useMemo(() => { const results = useMemo(() => {
@ -49,4 +54,20 @@ const PerformersFilter: React.FC<IPerformersFilter> = ({
); );
}; };
export const SidebarPerformersFilter: React.FC<{
title?: ReactNode;
option: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
}> = ({ title, option, filter, setFilter }) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
option,
useQuery: usePerformerQuery,
});
return <SidebarListFilter {...state} title={title} />;
};
export default PerformersFilter; export default PerformersFilter;

View file

@ -1,9 +1,21 @@
import React from "react"; import React, { useMemo } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "../../../core/generated-graphql";
import { INumberValue } from "../../../models/list-filter/types"; import { INumberValue } from "../../../models/list-filter/types";
import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion"; import {
CriterionOption,
ModifierCriterion,
} from "../../../models/list-filter/criteria/criterion";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { RatingStars } from "src/components/Shared/Rating/RatingStars";
import {
defaultRatingStarPrecision,
defaultRatingSystemOptions,
} from "src/utils/rating";
import { ConfigurationContext } from "src/hooks/Config";
import { RatingCriterion } from "src/models/list-filter/criteria/rating";
import { ListFilterModel } from "src/models/list-filter/filter";
import { Option, SidebarListFilter } from "./SidebarListFilter";
interface IRatingFilterProps { interface IRatingFilterProps {
criterion: ModifierCriterion<INumberValue>; criterion: ModifierCriterion<INumberValue>;
@ -59,3 +71,136 @@ export const RatingFilter: React.FC<IRatingFilterProps> = ({
return <></>; return <></>;
}; };
interface ISidebarFilter {
title?: React.ReactNode;
option: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
}
const any = "any";
const none = "none";
export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
title,
option,
filter,
setFilter,
}) => {
const intl = useIntl();
const anyLabel = `(${intl.formatMessage({
id: "criterion_modifier_values.any",
})})`;
const noneLabel = `(${intl.formatMessage({
id: "criterion_modifier_values.none",
})})`;
const anyOption = useMemo(
() => ({
id: "any",
label: anyLabel,
className: "modifier-object",
}),
[anyLabel]
);
const noneOption = useMemo(
() => ({
id: "none",
label: noneLabel,
className: "modifier-object",
}),
[noneLabel]
);
const { configuration: config } = React.useContext(ConfigurationContext);
const ratingSystemOptions =
config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;
const options: Option[] = useMemo(() => {
return [anyOption, noneOption];
}, [anyOption, noneOption]);
const criteria = filter.criteriaFor(option.type) as RatingCriterion[];
const criterion = criteria.length > 0 ? criteria[0] : null;
const selected: Option[] = useMemo(() => {
if (!criterion) return [];
if (criterion.modifier === CriterionModifier.NotNull) {
return [anyOption];
} else if (criterion.modifier === CriterionModifier.IsNull) {
return [noneOption];
}
return [];
}, [anyOption, noneOption, criterion]);
const ratingValue = useMemo(() => {
if (!criterion || criterion.modifier !== CriterionModifier.GreaterThan) {
return null;
}
return criterion.value.value ?? null;
}, [criterion]);
function onSelect(item: Option) {
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
if (item.id === any) {
newCriterion.modifier = CriterionModifier.NotNull;
// newCriterion.value
} else if (item.id === none) {
newCriterion.modifier = CriterionModifier.IsNull;
}
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
}
function onUnselect() {
setFilter(filter.removeCriterion(option.type));
}
function onRatingValueChange(value: number | null) {
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
if (value === null) {
setFilter(filter.removeCriterion(option.type));
return;
}
newCriterion.modifier = CriterionModifier.GreaterThan;
newCriterion.value.value = value - 1;
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
}
const ratingStars = (
<div className="no-icon-margin">
<RatingStars
value={ratingValue}
onSetRating={onRatingValueChange}
precision={
ratingSystemOptions.starPrecision ?? defaultRatingStarPrecision
}
orMore
/>
</div>
);
return (
<>
<SidebarListFilter
title={title}
candidates={options}
onSelect={onSelect}
onUnselect={onUnselect}
selected={selected}
singleValue
preCandidates={ratingValue === null ? ratingStars : undefined}
preSelected={ratingValue !== null ? ratingStars : undefined}
/>
<div></div>
</>
);
};

View file

@ -0,0 +1,364 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "react-bootstrap";
import { Icon } from "src/components/Shared/Icon";
import {
faCheckCircle,
faMinus,
faPlus,
faTimesCircle,
} from "@fortawesome/free-solid-svg-icons";
import { faTimesCircle as faTimesCircleRegular } from "@fortawesome/free-regular-svg-icons";
import { ClearableInput } from "src/components/Shared/ClearableInput";
import { useIntl } from "react-intl";
import { keyboardClickHandler } from "src/utils/keyboard";
import { useDebounce } from "src/hooks/debounce";
import useFocus from "src/utils/focus";
import cx from "classnames";
import ScreenUtils from "src/utils/screen";
import { SidebarSection } from "src/components/Shared/Sidebar";
import { TruncatedInlineText } from "src/components/Shared/TruncatedText";
interface ISelectedItem {
className?: string;
label: string;
excluded?: boolean;
onClick: () => void;
// true if the object is a special modifier value
modifier?: boolean;
}
const SelectedItem: React.FC<ISelectedItem> = ({
className,
label,
excluded = false,
onClick,
modifier = false,
}) => {
const iconClassName = excluded ? "exclude-icon" : "include-button";
const spanClassName = excluded
? "excluded-object-label"
: "selected-object-label";
const [hovered, setHovered] = useState(false);
const icon = useMemo(() => {
if (!hovered) {
return excluded ? faTimesCircle : faCheckCircle;
}
return faTimesCircleRegular;
}, [hovered, excluded]);
function onMouseOver() {
setHovered(true);
}
function onMouseOut() {
setHovered(false);
}
return (
<li
className={cx("selected-object", className, {
"modifier-object": modifier,
})}
>
<a
onClick={() => onClick()}
onKeyDown={keyboardClickHandler(onClick)}
onMouseEnter={() => onMouseOver()}
onMouseLeave={() => onMouseOut()}
onFocus={() => onMouseOver()}
onBlur={() => onMouseOut()}
tabIndex={0}
>
<div className="label-group">
<Icon className={`fa-fw ${iconClassName}`} icon={icon} />
<TruncatedInlineText className={spanClassName} text={label} />
</div>
</a>
</li>
);
};
const CandidateItem: React.FC<{
className?: string;
onSelect: (exclude: boolean) => void;
label: string;
canExclude?: boolean;
modifier?: boolean;
singleValue?: boolean;
}> = ({
onSelect,
label,
canExclude,
modifier = false,
singleValue = false,
className,
}) => {
const singleValueClass = singleValue ? "single-value" : "";
const includeIcon = (
<Icon
className={`fa-fw include-button ${singleValueClass}`}
icon={faPlus}
/>
);
const excludeIcon = (
<Icon className={`fa-fw exclude-icon ${singleValueClass}`} icon={faMinus} />
);
return (
<li
className={cx("unselected-object", className, {
"modifier-object": modifier,
})}
>
<a
onClick={() => onSelect(false)}
onKeyDown={keyboardClickHandler(() => onSelect(false))}
tabIndex={0}
>
<div className="label-group">
{includeIcon}
<TruncatedInlineText
className="unselected-object-label"
text={label}
/>
</div>
<div>
{/* TODO item count */}
{/* <span className="object-count">{p.id}</span> */}
{canExclude && (
<Button
onClick={(e) => {
e.stopPropagation();
onSelect(true);
}}
onKeyDown={(e) => e.stopPropagation()}
className="minimal exclude-button"
>
<span className="exclude-button-text">exclude</span>
{excludeIcon}
</Button>
)}
</div>
</a>
</li>
);
};
export type Option<T = unknown> = {
id: string;
className?: string;
value?: T;
label: string;
canExclude?: boolean; // defaults to true
};
export const SelectedList: React.FC<{
items: Option[];
onUnselect: (item: Option) => void;
excluded?: boolean;
}> = ({ items, onUnselect, excluded }) => {
if (items.length === 0) {
return null;
}
return (
<ul className={cx("selected-list", { "excluded-list": excluded })}>
{items.map((p) => (
<SelectedItem
key={p.id}
className={p.className}
label={p.label}
excluded={excluded}
onClick={() => onUnselect(p)}
/>
))}
</ul>
);
};
const QueryField: React.FC<{
focus: ReturnType<typeof useFocus>;
value: string;
setValue: (query: string) => void;
}> = ({ focus, value, setValue }) => {
const intl = useIntl();
const [displayQuery, setDisplayQuery] = useState(value);
const debouncedSetQuery = useDebounce(setValue, 250);
useEffect(() => {
setDisplayQuery(value);
}, [value]);
const onQueryChange = useCallback(
(input: string) => {
setDisplayQuery(input);
debouncedSetQuery(input);
},
[debouncedSetQuery, setDisplayQuery]
);
return (
<ClearableInput
focus={focus}
value={displayQuery}
setValue={(v) => onQueryChange(v)}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
/>
);
};
interface IQueryableProps {
inputFocus?: ReturnType<typeof useFocus>;
query?: string;
setQuery?: (query: string) => void;
}
export const CandidateList: React.FC<
{
items: Option[];
onSelect: (item: Option, exclude: boolean) => void;
canExclude?: boolean;
singleValue?: boolean;
} & IQueryableProps
> = ({
inputFocus,
query,
setQuery,
items,
onSelect,
canExclude,
singleValue,
}) => {
const showQueryField =
inputFocus !== undefined && query !== undefined && setQuery !== undefined;
return (
<div className="queryable-candidate-list">
{showQueryField && (
<QueryField
focus={inputFocus}
value={query}
setValue={(v) => setQuery(v)}
/>
)}
<ul>
{items.map((p) => (
<CandidateItem
key={p.id}
className={p.className}
onSelect={(exclude) => onSelect(p, exclude)}
label={p.label}
canExclude={canExclude && (p.canExclude ?? true)}
singleValue={singleValue}
/>
))}
</ul>
</div>
);
};
export const SidebarListFilter: React.FC<{
title: React.ReactNode;
selected: Option[];
excluded?: Option[];
candidates: Option[];
singleValue?: boolean;
onSelect: (item: Option, exclude: boolean) => void;
onUnselect: (item: Option, exclude: boolean) => void;
canExclude?: boolean;
query?: string;
setQuery?: (query: string) => void;
preSelected?: React.ReactNode;
postSelected?: React.ReactNode;
preCandidates?: React.ReactNode;
postCandidates?: React.ReactNode;
onOpen?: () => void;
}> = ({
title,
selected,
excluded,
candidates,
onSelect,
onUnselect,
canExclude,
query,
setQuery,
singleValue = false,
preCandidates,
postCandidates,
preSelected,
postSelected,
onOpen,
}) => {
// TODO - sort items?
const inputFocus = useFocus();
const [, setInputFocus] = inputFocus;
function unselectHook(item: Option, exclude: boolean) {
onUnselect(item, exclude);
// focus the input box
// don't do this on touch devices, as it's annoying
if (!ScreenUtils.isTouch()) {
setInputFocus();
}
}
function selectHook(item: Option, exclude: boolean) {
onSelect(item, exclude);
// reset filter query after selecting
setQuery?.("");
// focus the input box
// don't do this on touch devices, as it's annoying
if (!ScreenUtils.isTouch()) {
setInputFocus();
}
}
return (
<SidebarSection
className="sidebar-list-filter"
text={title}
outsideCollapse={
<>
{preSelected ? <div className="extra">{preSelected}</div> : null}
<SelectedList
items={selected}
onUnselect={(i) => unselectHook(i, false)}
/>
{excluded && (
<SelectedList
items={excluded}
onUnselect={(i) => unselectHook(i, true)}
excluded
/>
)}
{postSelected ? <div className="extra">{postSelected}</div> : null}
</>
}
onOpen={onOpen}
>
{preCandidates ? <div className="extra">{preCandidates}</div> : null}
<CandidateList
items={candidates}
onSelect={selectHook}
canExclude={canExclude}
inputFocus={inputFocus}
query={query}
setQuery={setQuery}
singleValue={singleValue}
/>
{postCandidates ? <div className="extra">{postCandidates}</div> : null}
</SidebarSection>
);
};
export function useStaticResults<T>(r: T) {
return () => ({ results: r, loading: false });
}

View file

@ -1,22 +1,27 @@
import React, { useMemo } from "react"; import React, { ReactNode, useMemo } from "react";
import { useFindStudiosQuery } from "src/core/generated-graphql"; import { useFindStudiosForSelectQuery } from "src/core/generated-graphql";
import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { HierarchicalObjectsFilter } from "./SelectableFilter";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { sortByRelevance } from "src/utils/query"; import { sortByRelevance } from "src/utils/query";
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import { useLabeledIdFilterState } from "./LabeledIdFilter";
import { SidebarListFilter } from "./SidebarListFilter";
interface IStudiosFilter { interface IStudiosFilter {
criterion: StudiosCriterion; criterion: StudiosCriterion;
setCriterion: (c: StudiosCriterion) => void; setCriterion: (c: StudiosCriterion) => void;
} }
function useStudioQuery(query: string) { function useStudioQuery(query: string, skip?: boolean) {
const { data, loading } = useFindStudiosQuery({ const { data, loading } = useFindStudiosForSelectQuery({
variables: { variables: {
filter: { filter: {
q: query, q: query,
per_page: 200, per_page: 200,
}, },
}, },
skip,
}); });
const results = useMemo(() => { const results = useMemo(() => {
@ -50,4 +55,23 @@ const StudiosFilter: React.FC<IStudiosFilter> = ({
); );
}; };
export const SidebarStudiosFilter: React.FC<{
title?: ReactNode;
option: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
}> = ({ title, option, filter, setFilter }) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
option,
useQuery: useStudioQuery,
singleValue: true,
hierarchical: true,
includeSubMessageID: "subsidiary_studios",
});
return <SidebarListFilter {...state} title={title} />;
};
export default StudiosFilter; export default StudiosFilter;

View file

@ -1,22 +1,27 @@
import React, { useMemo } from "react"; import React, { ReactNode, useMemo } from "react";
import { useFindTagsQuery } from "src/core/generated-graphql"; import { useFindTagsForSelectQuery } from "src/core/generated-graphql";
import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { HierarchicalObjectsFilter } from "./SelectableFilter";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { sortByRelevance } from "src/utils/query"; import { sortByRelevance } from "src/utils/query";
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import { useLabeledIdFilterState } from "./LabeledIdFilter";
import { SidebarListFilter } from "./SidebarListFilter";
interface ITagsFilter { interface ITagsFilter {
criterion: StudiosCriterion; criterion: StudiosCriterion;
setCriterion: (c: StudiosCriterion) => void; setCriterion: (c: StudiosCriterion) => void;
} }
function useTagQuery(query: string) { function useTagQuery(query: string, skip?: boolean) {
const { data, loading } = useFindTagsQuery({ const { data, loading } = useFindTagsForSelectQuery({
variables: { variables: {
filter: { filter: {
q: query, q: query,
per_page: 200, per_page: 200,
}, },
}, },
skip,
}); });
const results = useMemo(() => { const results = useMemo(() => {
@ -46,4 +51,22 @@ const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
); );
}; };
export const SidebarTagsFilter: React.FC<{
title?: ReactNode;
option: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
}> = ({ title, option, filter, setFilter }) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
option,
useQuery: useTagQuery,
hierarchical: true,
includeSubMessageID: "sub_tags",
});
return <SidebarListFilter {...state} title={title} />;
};
export default TagsFilter; export default TagsFilter;

View file

@ -61,11 +61,13 @@ export function useDebouncedSearchInput(
export const SearchTermInput: React.FC<{ export const SearchTermInput: React.FC<{
filter: ListFilterModel; filter: ListFilterModel;
onFilterUpdate: (newFilter: ListFilterModel) => void; onFilterUpdate: (newFilter: ListFilterModel) => void;
}> = ({ filter, onFilterUpdate }) => { focus?: ReturnType<typeof useFocus>;
}> = ({ filter, onFilterUpdate, focus: providedFocus }) => {
const intl = useIntl(); const intl = useIntl();
const [localInput, setLocalInput] = useState(filter.searchTerm); const [localInput, setLocalInput] = useState(filter.searchTerm);
const focus = useFocus(); const localFocus = useFocus();
const focus = providedFocus ?? localFocus;
const [, setQueryFocus] = focus; const [, setQueryFocus] = focus;
useEffect(() => { useEffect(() => {
@ -233,6 +235,7 @@ interface IListFilterProps {
filter: ListFilterModel; filter: ListFilterModel;
view?: View; view?: View;
openFilterDialog: () => void; openFilterDialog: () => void;
withSidebar?: boolean;
} }
export const ListFilter: React.FC<IListFilterProps> = ({ export const ListFilter: React.FC<IListFilterProps> = ({
@ -240,6 +243,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
filter, filter,
openFilterDialog, openFilterDialog,
view, view,
withSidebar,
}) => { }) => {
const filterOptions = filter.options; const filterOptions = filter.options;
@ -313,31 +317,38 @@ export const ListFilter: React.FC<IListFilterProps> = ({
return ( return (
<> <>
<div className="mb-2 d-flex"> {!withSidebar && (
<SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} /> <div className="d-flex">
</div> <SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
</div>
)}
<ButtonGroup className="mr-2 mb-2"> {!withSidebar && (
<SavedFilterDropdown <ButtonGroup className="mr-2">
filter={filter} <SavedFilterDropdown
onSetFilter={(f) => { filter={filter}
onFilterUpdate(f); onSetFilter={(f) => {
}} onFilterUpdate(f);
view={view} }}
/> view={view}
<OverlayTrigger />
placement="top" <OverlayTrigger
overlay={ placement="top"
<Tooltip id="filter-tooltip"> overlay={
<FormattedMessage id="search_filter.name" /> <Tooltip id="filter-tooltip">
</Tooltip> <FormattedMessage id="search_filter.name" />
} </Tooltip>
> }
<FilterButton onClick={() => openFilterDialog()} filter={filter} /> >
</OverlayTrigger> <FilterButton
</ButtonGroup> onClick={() => openFilterDialog()}
filter={filter}
/>
</OverlayTrigger>
</ButtonGroup>
)}
<Dropdown as={ButtonGroup} className="mr-2 mb-2"> <Dropdown as={ButtonGroup} className="mr-2">
<InputGroup.Prepend> <InputGroup.Prepend>
<Dropdown.Toggle variant="secondary"> <Dropdown.Toggle variant="secondary">
{currentSortBy {currentSortBy

View file

@ -22,7 +22,7 @@ export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
if (!children) return null; if (!children) return null;
return ( return (
<Dropdown> <Dropdown as={ButtonGroup}>
<Dropdown.Toggle variant="secondary" id="more-menu"> <Dropdown.Toggle variant="secondary" id="more-menu">
<Icon icon={faEllipsisH} /> <Icon icon={faEllipsisH} />
</Dropdown.Toggle> </Dropdown.Toggle>
@ -116,7 +116,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
if (buttons.length > 0) { if (buttons.length > 0) {
return ( return (
<ButtonGroup className="ml-2 mb-2"> <ButtonGroup className="ml-2">
{buttons.map((button) => { {buttons.map((button) => {
return ( return (
<OverlayTrigger <OverlayTrigger
@ -206,7 +206,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
<> <>
{maybeRenderButtons()} {maybeRenderButtons()}
<div className="mx-2 mb-2">{renderMore()}</div> <ButtonGroup className="ml-2">{renderMore()}</ButtonGroup>
</> </>
); );
}; };

View file

@ -110,7 +110,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
} }
return ( return (
<ButtonGroup className="mb-2"> <ButtonGroup className="ml-2">
{displayModeOptions.map((option) => ( {displayModeOptions.map((option) => (
<OverlayTrigger <OverlayTrigger
key={option} key={option}
@ -140,7 +140,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
function maybeRenderZoom() { function maybeRenderZoom() {
if (onSetZoom && displayMode === DisplayMode.Grid) { if (onSetZoom && displayMode === DisplayMode.Grid) {
return ( return (
<div className="ml-2 mb-2 d-none d-sm-inline-flex"> <div className="ml-2 d-none d-sm-inline-flex">
<Form.Control <Form.Control
className="zoom-slider ml-1" className="zoom-slider ml-1"
type="range" type="range"

View file

@ -1,8 +1,9 @@
import React, { HTMLAttributes, useState } from "react"; import React, { HTMLAttributes, useEffect, useMemo, useState } from "react";
import { import {
Button, Button,
ButtonGroup, ButtonGroup,
Dropdown, Dropdown,
Form,
FormControl, FormControl,
InputGroup, InputGroup,
Modal, Modal,
@ -17,12 +18,171 @@ import {
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { SavedFilterDataFragment } from "src/core/generated-graphql"; import {
FilterMode,
SavedFilterDataFragment,
} from "src/core/generated-graphql";
import { View } from "./views"; import { View } from "./views";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { faBookmark, faSave, faTimes } from "@fortawesome/free-solid-svg-icons"; import { faBookmark, faSave, faTimes } from "@fortawesome/free-solid-svg-icons";
import { AlertModal } from "../Shared/Alert";
import cx from "classnames";
import { TruncatedInlineText } from "../Shared/TruncatedText";
const ExistingSavedFilterList: React.FC<{
name: string;
setName: (name: string) => void;
existing: { name: string; id: string }[];
}> = ({ name, setName, existing }) => {
const filtered = useMemo(() => {
if (!name) return existing;
return existing.filter((f) =>
f.name.toLowerCase().includes(name.toLowerCase())
);
}, [existing, name]);
return (
<ul className="existing-filter-list">
{filtered.map((f) => (
<li key={f.id}>
<Button
className="minimal"
variant="link"
onClick={() => setName(f.name)}
>
{f.name}
</Button>
</li>
))}
</ul>
);
};
export const SaveFilterDialog: React.FC<{
mode: FilterMode;
onClose: (name?: string, id?: string) => void;
}> = ({ mode, onClose }) => {
const intl = useIntl();
const [filterName, setFilterName] = useState("");
const { data } = useFindSavedFilters(mode);
const overwritingFilter = useMemo(() => {
const savedFilters = data?.findSavedFilters ?? [];
return savedFilters.find(
(f) => f.name.toLowerCase() === filterName.toLowerCase()
);
}, [data?.findSavedFilters, filterName]);
return (
<Modal show className="save-filter-dialog">
<Modal.Body>
<Form.Group>
<Form.Label>
<FormattedMessage id="filter_name" />
</Form.Label>
<FormControl
className="bg-secondary text-white border-secondary"
placeholder={`${intl.formatMessage({ id: "filter_name" })}…`}
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
/>
</Form.Group>
<ExistingSavedFilterList
name={filterName}
setName={setFilterName}
existing={data?.findSavedFilters ?? []}
/>
{!!overwritingFilter && (
<span className="saved-filter-overwrite-warning">
<FormattedMessage
id="dialogs.overwrite_filter_warning"
values={{
entityName: overwritingFilter.name,
}}
/>
</span>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => onClose()}>
{intl.formatMessage({ id: "actions.cancel" })}
</Button>
<Button
variant="primary"
onClick={() => onClose(filterName, overwritingFilter?.id)}
>
{intl.formatMessage({ id: "actions.save" })}
</Button>
</Modal.Footer>
</Modal>
);
};
const DeleteAlert: React.FC<{
deletingFilter: SavedFilterDataFragment | undefined;
onClose: (confirm?: boolean) => void;
}> = ({ deletingFilter, onClose }) => {
if (!deletingFilter) {
return null;
}
return (
<Modal show>
<Modal.Body>
<FormattedMessage
id="dialogs.delete_confirm"
values={{
entityName: deletingFilter.name,
}}
/>
</Modal.Body>
<Modal.Footer>
<Button variant="danger" onClick={() => onClose(true)}>
<FormattedMessage id="actions.delete" />
</Button>
<Button variant="secondary" onClick={() => onClose()}>
<FormattedMessage id="actions.cancel" />
</Button>
</Modal.Footer>
</Modal>
);
};
const OverwriteAlert: React.FC<{
overwritingFilter: SavedFilterDataFragment | undefined;
onClose: (confirm?: boolean) => void;
}> = ({ overwritingFilter, onClose }) => {
if (!overwritingFilter) {
return null;
}
return (
<Modal show>
<Modal.Body>
<FormattedMessage
id="dialogs.overwrite_filter_confirm"
values={{
entityName: overwritingFilter.name,
}}
/>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={() => onClose(true)}>
<FormattedMessage id="actions.overwrite" />
</Button>
<Button variant="secondary" onClick={() => onClose()}>
<FormattedMessage id="actions.cancel" />
</Button>
</Modal.Footer>
</Modal>
);
};
interface ISavedFilterListProps { interface ISavedFilterListProps {
filter: ListFilterModel; filter: ListFilterModel;
@ -49,7 +209,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
SavedFilterDataFragment | undefined SavedFilterDataFragment | undefined
>(); >();
const [saveFilter] = useSaveFilter(); const saveFilter = useSaveFilter();
const [destroyFilter] = useSavedFilterDestroy(); const [destroyFilter] = useSavedFilterDestroy();
const [saveUISetting] = useConfigureUISetting(); const [saveUISetting] = useConfigureUISetting();
@ -60,18 +220,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
try { try {
setSaving(true); setSaving(true);
await saveFilter({ await saveFilter(filterCopy, name, id);
variables: {
input: {
id,
mode: filter.mode,
name,
find_filter: filterCopy.makeFindFilter(),
object_filter: filterCopy.makeSavedFilter(),
ui_options: filterCopy.makeSavedUIOptions(),
},
},
});
Toast.success( Toast.success(
intl.formatMessage( intl.formatMessage(
@ -212,74 +361,6 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
); );
}; };
function maybeRenderDeleteAlert() {
if (!deletingFilter) {
return;
}
return (
<Modal show>
<Modal.Body>
<FormattedMessage
id="dialogs.delete_confirm"
values={{
entityName: deletingFilter.name,
}}
/>
</Modal.Body>
<Modal.Footer>
<Button
variant="danger"
onClick={() => onDeleteFilter(deletingFilter)}
>
{intl.formatMessage({ id: "actions.delete" })}
</Button>
<Button
variant="secondary"
onClick={() => setDeletingFilter(undefined)}
>
{intl.formatMessage({ id: "actions.cancel" })}
</Button>
</Modal.Footer>
</Modal>
);
}
function maybeRenderOverwriteAlert() {
if (!overwritingFilter) {
return;
}
return (
<Modal show>
<Modal.Body>
<FormattedMessage
id="dialogs.overwrite_filter_confirm"
values={{
entityName: overwritingFilter.name,
}}
/>
</Modal.Body>
<Modal.Footer>
<Button
variant="primary"
onClick={() =>
onSaveFilter(overwritingFilter.name, overwritingFilter.id)
}
>
{intl.formatMessage({ id: "actions.overwrite" })}
</Button>
<Button
variant="secondary"
onClick={() => setOverwritingFilter(undefined)}
>
{intl.formatMessage({ id: "actions.cancel" })}
</Button>
</Modal.Footer>
</Modal>
);
}
function renderSavedFilters() { function renderSavedFilters() {
if (error) return <h6 className="text-center">{error.message}</h6>; if (error) return <h6 className="text-center">{error.message}</h6>;
@ -327,8 +408,24 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
return ( return (
<> <>
{maybeRenderDeleteAlert()} <DeleteAlert
{maybeRenderOverwriteAlert()} deletingFilter={deletingFilter}
onClose={(confirm) => {
if (confirm) {
onDeleteFilter(deletingFilter!);
}
setDeletingFilter(undefined);
}}
/>
<OverwriteAlert
overwritingFilter={overwritingFilter}
onClose={(confirm) => {
if (confirm) {
onSaveFilter(overwritingFilter!.name, overwritingFilter!.id);
}
setOverwritingFilter(undefined);
}}
/>
<InputGroup> <InputGroup>
<FormControl <FormControl
className="bg-secondary text-white border-secondary" className="bg-secondary text-white border-secondary"
@ -365,6 +462,319 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
); );
}; };
interface ISavedFilterItem {
item: SavedFilterDataFragment;
onClick: () => void;
onDelete: () => void;
selected?: boolean;
}
const SavedFilterItem: React.FC<ISavedFilterItem> = ({
item,
onClick,
onDelete,
selected = false,
}) => {
const intl = useIntl();
return (
<li className="saved-filter-item">
<a onClick={onClick}>
<div className="label-group">
<TruncatedInlineText
className={cx("no-icon-margin", { selected })}
text={item.name}
/>
</div>
<div>
<Button
className="delete-button"
variant="minimal"
size="sm"
title={intl.formatMessage({ id: "actions.delete" })}
onClick={(e) => {
onDelete();
e.stopPropagation();
}}
>
<Icon fixedWidth icon={faTimes} />
</Button>
</div>
</a>
</li>
);
};
const SavedFilters: React.FC<{
error?: string;
loading?: boolean;
saving?: boolean;
savedFilters: SavedFilterDataFragment[];
onFilterClicked: (f: SavedFilterDataFragment) => void;
onDeleteClicked: (f: SavedFilterDataFragment) => void;
currentFilterID?: string;
}> = ({
error,
loading,
saving,
savedFilters,
onFilterClicked,
onDeleteClicked,
currentFilterID,
}) => {
if (error) return <h6 className="text-center">{error}</h6>;
if (loading || saving) {
return (
<div className="loading">
<LoadingIndicator message="" />
</div>
);
}
return (
<ul className="saved-filter-list">
{savedFilters.map((f) => (
<SavedFilterItem
key={f.name}
item={f}
onClick={() => onFilterClicked(f)}
onDelete={() => onDeleteClicked(f)}
selected={currentFilterID === f.id}
/>
))}
</ul>
);
};
export const SidebarSavedFilterList: React.FC<ISavedFilterListProps> = ({
filter,
onSetFilter,
view,
}) => {
const Toast = useToast();
const intl = useIntl();
const [currentSavedFilter, setCurrentSavedFilter] = useState<{
id: string;
set: boolean;
}>();
const { data, error, loading, refetch } = useFindSavedFilters(filter.mode);
const [filterName, setFilterName] = useState("");
const [saving, setSaving] = useState(false);
const [deletingFilter, setDeletingFilter] = useState<
SavedFilterDataFragment | undefined
>();
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [settingDefault, setSettingDefault] = useState(false);
const saveFilter = useSaveFilter();
const [destroyFilter] = useSavedFilterDestroy();
const [saveUISetting] = useConfigureUISetting();
const filteredFilters = useMemo(() => {
const savedFilters = data?.findSavedFilters ?? [];
if (!filterName) return savedFilters;
return savedFilters.filter(
(f) =>
!filterName || f.name.toLowerCase().includes(filterName.toLowerCase())
);
}, [data?.findSavedFilters, filterName]);
// handle when filter is changed to de-select the current filter
useEffect(() => {
// HACK - first change will be from setting the filter
// second change is likely from somewhere else
setCurrentSavedFilter((v) => {
if (!v) return v;
if (v.set) {
setCurrentSavedFilter({ id: v.id, set: false });
} else {
setCurrentSavedFilter(undefined);
}
});
}, [filter]);
async function onSaveFilter(name: string, id?: string) {
try {
setSaving(true);
await saveFilter(filter, name, id);
Toast.success(
intl.formatMessage(
{
id: "toast.saved_entity",
},
{
entity: intl.formatMessage({ id: "filter" }).toLocaleLowerCase(),
}
)
);
setFilterName("");
setShowSaveDialog(false);
refetch();
} catch (err) {
Toast.error(err);
} finally {
setSaving(false);
}
}
async function onDeleteFilter(f: SavedFilterDataFragment) {
try {
setSaving(true);
await destroyFilter({
variables: {
input: {
id: f.id,
},
},
});
Toast.success(
intl.formatMessage(
{
id: "toast.delete_past_tense",
},
{
count: 1,
singularEntity: intl.formatMessage({ id: "filter" }),
pluralEntity: intl.formatMessage({ id: "filters" }),
}
)
);
refetch();
} catch (err) {
Toast.error(err);
} finally {
setSaving(false);
setDeletingFilter(undefined);
}
}
async function onSetDefaultFilter() {
if (!view) {
return;
}
const filterCopy = filter.clone();
try {
setSaving(true);
await saveUISetting({
variables: {
key: `defaultFilters.${view.toString()}`,
value: {
mode: filter.mode,
find_filter: filterCopy.makeFindFilter(),
object_filter: filterCopy.makeSavedFilter(),
ui_options: filterCopy.makeSavedUIOptions(),
},
},
});
Toast.success(
intl.formatMessage({
id: "toast.default_filter_set",
})
);
} catch (err) {
Toast.error(err);
} finally {
setSaving(false);
setSettingDefault(false);
}
}
function filterClicked(f: SavedFilterDataFragment) {
const newFilter = filter.clone();
newFilter.currentPage = 1;
// #1795 - reset search term if not present in saved filter
newFilter.searchTerm = "";
newFilter.configureFromSavedFilter(f);
// #1507 - reset random seed when loaded
newFilter.randomSeed = -1;
setCurrentSavedFilter({ id: f.id, set: true });
onSetFilter(newFilter);
}
return (
<div className="sidebar-saved-filter-list-container">
<DeleteAlert
deletingFilter={deletingFilter}
onClose={(confirm) => {
if (confirm) {
onDeleteFilter(deletingFilter!);
}
setDeletingFilter(undefined);
}}
/>
{showSaveDialog && (
<SaveFilterDialog
mode={filter.mode}
onClose={(name, id) => {
setShowSaveDialog(false);
if (name) {
onSaveFilter(name, id);
}
}}
/>
)}
<AlertModal
show={!!settingDefault}
text={<FormattedMessage id="dialogs.set_default_filter_confirm" />}
confirmVariant="primary"
onConfirm={() => onSetDefaultFilter()}
onCancel={() => setSettingDefault(false)}
/>
<div className="toolbar">
<Button
className="minimal save-filter-button"
size="sm"
onClick={() => setShowSaveDialog(true)}
>
<span>
<FormattedMessage id="actions.save_filter" />
</span>
</Button>
<Button
className="minimal set-as-default-button"
variant="secondary"
size="sm"
onClick={() => setSettingDefault(true)}
>
<FormattedMessage id="actions.set_as_default" />
</Button>
</div>
<FormControl
className="bg-secondary text-white border-secondary saved-filter-search-input"
placeholder={`${intl.formatMessage({ id: "filter_name" })}…`}
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
/>
<SavedFilters
error={error?.message}
loading={loading}
saving={saving}
savedFilters={filteredFilters}
onFilterClicked={filterClicked}
onDeleteClicked={setDeletingFilter}
currentFilterID={currentSavedFilter?.id}
/>
</div>
);
};
export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => { export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
const SavedFilterDropdownRef = React.forwardRef< const SavedFilterDropdownRef = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -377,7 +787,7 @@ export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
SavedFilterDropdownRef.displayName = "SavedFilterDropdown"; SavedFilterDropdownRef.displayName = "SavedFilterDropdown";
return ( return (
<Dropdown> <Dropdown as={ButtonGroup}>
<OverlayTrigger <OverlayTrigger
placement="top" placement="top"
overlay={ overlay={

View file

@ -40,6 +40,9 @@ input[type="range"].zoom-slider {
max-width: 60px; max-width: 60px;
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
// width is set to 100% by default, but in a flex container, it gets a very small width
width: unset;
} }
.query-text-field-group { .query-text-field-group {
@ -103,7 +106,7 @@ input[type="range"].zoom-slider {
.saved-filter-list { .saved-filter-list {
list-style: none; list-style: none;
margin-bottom: 0; margin-bottom: 0.25rem;
max-height: 230px; max-height: 230px;
overflow-y: auto; overflow-y: auto;
padding-left: 0; padding-left: 0;
@ -113,11 +116,18 @@ input[type="range"].zoom-slider {
.dropdown-item { .dropdown-item {
align-items: center; align-items: center;
color: $text-color;
display: inline; display: inline;
overflow-x: hidden; overflow-x: hidden;
padding-left: 1.25rem; padding-left: 1.25rem;
padding-right: 0.25rem; padding-right: 0.25rem;
text-overflow: ellipsis; text-overflow: ellipsis;
&:focus,
&:hover {
background-color: #8a9ba826;
cursor: pointer;
}
} }
.btn-group { .btn-group {
@ -134,6 +144,87 @@ input[type="range"].zoom-slider {
} }
} }
.sidebar-saved-filter-list-container .toolbar {
align-items: center;
display: flex;
justify-content: space-between;
padding: 0.5rem;
.btn {
font-weight: bold;
}
}
.sidebar-saved-filter-list-container {
.label-group {
align-items: center;
display: flex;
overflow: hidden;
}
.saved-filter-item {
cursor: pointer;
height: 2em;
margin-bottom: 0.25rem;
a {
align-items: center;
display: flex;
height: 2em;
justify-content: space-between;
outline: none;
&:hover,
&:focus-visible {
background-color: rgba(138, 155, 168, 0.15);
}
.selected-object-label,
.excluded-object-label {
font-size: 16px;
}
}
.label-group {
align-items: center;
display: flex;
overflow: hidden;
.selected {
font-weight: bold;
}
.fa-icon {
flex-shrink: 0;
}
}
.delete-button {
color: $danger;
}
}
.saved-filter-search-input {
margin-bottom: 0.5rem;
}
}
.save-filter-dialog {
.existing-filter-list {
max-height: 300px;
overflow-y: auto;
}
}
.save-filter-button {
color: $text-color;
}
.saved-filter-overwrite-warning {
color: $danger;
font-weight: bold;
}
.edit-filter-dialog .rating-stars { .edit-filter-dialog .rating-stars {
font-size: 1.3em; font-size: 1.3em;
margin-left: 0.25em; margin-left: 0.25em;
@ -267,13 +358,16 @@ input[type="range"].zoom-slider {
} }
.filter-button { .filter-button {
position: relative;
.fa-icon { .fa-icon {
margin: 0; margin: 0;
} }
.badge { .badge {
font-size: 60%;
position: absolute; position: absolute;
right: -3px; right: 0;
// button group has a z-index of 1 // button group has a z-index of 1
z-index: 2; z-index: 2;
@ -390,6 +484,133 @@ input[type="range"].zoom-slider {
} }
} }
// used to align list text without icons to those that do
.sidebar .no-icon-margin {
// icon width is 17.5px + 5.6px margin each side
margin-left: 28.7px;
}
.sidebar-list-filter .clearable-input-group {
margin-bottom: 0.5rem;
}
.sidebar-list-filter ul {
list-style-type: none;
margin-bottom: 0.25rem;
max-height: 300px;
overflow-y: auto;
// to prevent unnecessary vertical scrollbar
padding-bottom: 0.15rem;
padding-inline-start: 0;
.modifier-object {
font-style: italic;
.selected-object-label,
.unselected-object-label {
opacity: 0.6;
}
}
.unselected-object {
opacity: 0.8;
}
.selected-object,
.excluded-object,
.unselected-object {
cursor: pointer;
height: 2em;
margin-bottom: 0.25rem;
a {
align-items: center;
display: flex;
height: 2em;
justify-content: space-between;
outline: none;
&:hover,
&:focus-visible {
background-color: rgba(138, 155, 168, 0.15);
}
.selected-object-label,
.excluded-object-label {
font-size: 16px;
}
}
.include-button {
color: $success;
&.single-value {
visibility: hidden;
}
}
.exclude-icon {
color: $danger;
}
.exclude-button {
align-items: center;
display: flex;
margin-left: 0.25rem;
padding-left: 0.25rem;
padding-right: 0.25rem;
.exclude-button-text {
color: $danger;
display: none;
font-size: 12px;
font-weight: 600;
}
&:hover {
background-color: inherit;
}
&:hover .exclude-button-text,
&:focus .exclude-button-text {
display: inline;
}
}
.object-count {
color: $text-muted;
font-size: 12px;
}
}
.selected-object:hover,
.selected-object a:focus-visible,
.excluded-object:hover,
.excluded-object a:focus-visible {
.include-button,
.exclude-icon {
color: #fff;
}
}
.selected-object,
.unselected-object {
.label-group {
align-items: center;
display: flex;
overflow: hidden;
}
}
}
.sidebar-list-filter > .extra {
padding-top: 0.25rem;
}
.sidebar-list-filter .extra {
min-height: 2em;
}
.tilted { .tilted {
transform: rotate(45deg); transform: rotate(45deg);
} }
@ -582,6 +803,21 @@ input[type="range"].zoom-slider {
.filtered-list-toolbar { .filtered-list-toolbar {
justify-content: center; justify-content: center;
margin-bottom: 0.5rem;
& > .btn-group {
flex-wrap: wrap;
justify-content: center;
row-gap: 0.5rem;
}
}
.sidebar-pane .filtered-list-toolbar {
flex-wrap: nowrap;
& > .btn-group {
align-items: baseline;
}
} }
.search-term-input { .search-term-input {
@ -613,3 +849,30 @@ input[type="range"].zoom-slider {
} }
} }
} }
.item-list-container .sidebar-pane {
width: 100%;
}
.sidebar {
.sidebar-search-container {
display: flex;
margin-bottom: 0.5rem;
margin-top: 0.25rem;
}
.search-term-input {
flex-grow: 1;
margin-right: 0.25rem;
.clearable-text-field {
height: 100%;
}
}
}
@include media-breakpoint-down(xs) {
.sidebar .search-term-input {
margin-right: 0.5rem;
}
}

View file

@ -1,6 +1,6 @@
import React, { useCallback, useContext, useEffect, useMemo } from "react"; import React, { useCallback, useContext, useEffect, useMemo } from "react";
import cloneDeep from "lodash-es/cloneDeep"; import cloneDeep from "lodash-es/cloneDeep";
import { useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@ -31,6 +31,23 @@ import { IListFilterOperation } from "../List/ListOperationButtons";
import { FilteredListToolbar } from "../List/FilteredListToolbar"; import { FilteredListToolbar } from "../List/FilteredListToolbar";
import { useFilteredItemList } from "../List/ItemList"; import { useFilteredItemList } from "../List/ItemList";
import { FilterTags } from "../List/FilterTags"; import { FilterTags } from "../List/FilterTags";
import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar";
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers";
import { StudiosCriterionOption } from "src/models/list-filter/criteria/studios";
import { TagsCriterionOption } from "src/models/list-filter/criteria/tags";
import { SidebarTagsFilter } from "../List/Filters/TagsFilter";
import cx from "classnames";
import { RatingCriterionOption } from "src/models/list-filter/criteria/rating";
import { SidebarRatingFilter } from "../List/Filters/RatingFilter";
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
import {
FilteredSidebarHeader,
useFilteredSidebarKeybinds,
} from "../List/Filters/FilterSidebar";
import { PatchContainerComponent } from "src/patch";
function renderMetadataByline(result: GQL.FindScenesQueryResult) { function renderMetadataByline(result: GQL.FindScenesQueryResult) {
const duration = result?.data?.findScenes?.duration; const duration = result?.data?.findScenes?.duration;
@ -184,6 +201,70 @@ const SceneList: React.FC<{
return null; return null;
}; };
const ScenesFilterSidebarSections = PatchContainerComponent(
"FilteredSceneList.SidebarSections"
);
const SidebarContent: React.FC<{
filter: ListFilterModel;
setFilter: (filter: ListFilterModel) => void;
view?: View;
sidebarOpen: boolean;
onClose?: () => void;
showEditFilter: (editingCriterion?: string) => void;
}> = ({ filter, setFilter, view, showEditFilter, sidebarOpen, onClose }) => {
return (
<>
<FilteredSidebarHeader
sidebarOpen={sidebarOpen}
onClose={onClose}
showEditFilter={showEditFilter}
filter={filter}
setFilter={setFilter}
view={view}
/>
<ScenesFilterSidebarSections>
<SidebarStudiosFilter
title={<FormattedMessage id="studios" />}
data-type={StudiosCriterionOption.type}
option={StudiosCriterionOption}
filter={filter}
setFilter={setFilter}
/>
<SidebarPerformersFilter
title={<FormattedMessage id="performers" />}
data-type={PerformersCriterionOption.type}
option={PerformersCriterionOption}
filter={filter}
setFilter={setFilter}
/>
<SidebarTagsFilter
title={<FormattedMessage id="tags" />}
data-type={TagsCriterionOption.type}
option={TagsCriterionOption}
filter={filter}
setFilter={setFilter}
/>
<SidebarRatingFilter
title={<FormattedMessage id="rating" />}
data-type={RatingCriterionOption.type}
option={RatingCriterionOption}
filter={filter}
setFilter={setFilter}
/>
<SidebarBooleanFilter
title={<FormattedMessage id="organized" />}
data-type={OrganizedCriterionOption.type}
option={OrganizedCriterionOption}
filter={filter}
setFilter={setFilter}
/>
</ScenesFilterSidebarSections>
</>
);
};
interface IFilteredScenes { interface IFilteredScenes {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
defaultSort?: string; defaultSort?: string;
@ -199,6 +280,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
// States // States
const {
showSidebar,
setShowSidebar,
loading: sidebarStateLoading,
} = useSidebarState(view);
const { filterState, queryResult, modalState, listSelect, showEditFilter } = const { filterState, queryResult, modalState, listSelect, showEditFilter } =
useFilteredItemList({ useFilteredItemList({
filterStateProps: { filterStateProps: {
@ -237,6 +324,10 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
}); });
useAddKeybinds(filter, totalCount); useAddKeybinds(filter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
});
const onCloseEditDelete = useCloseEditDelete({ const onCloseEditDelete = useCloseEditDelete({
closeModal, closeModal,
@ -340,62 +431,81 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
]; ];
// render // render
if (filterLoading) return null; if (filterLoading || sidebarStateLoading) return null;
return ( return (
<TaggerContext> <TaggerContext>
<div className="item-list-container"> <div
className={cx("item-list-container scene-list", {
"hide-sidebar": !showSidebar,
})}
>
{modal} {modal}
<FilteredListToolbar <SidebarPane hideSidebar={!showSidebar}>
filter={filter} <Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
setFilter={setFilter} <SidebarContent
showEditFilter={showEditFilter} filter={filter}
view={view} setFilter={setFilter}
listSelect={listSelect} showEditFilter={showEditFilter}
onEdit={() => view={view}
showModal( sidebarOpen={showSidebar}
<EditScenesDialog onClose={() => setShowSidebar(false)}
selected={selectedItems} />
onClose={onCloseEditDelete} </Sidebar>
/> <div>
) <FilteredListToolbar
} filter={filter}
onDelete={() => { setFilter={setFilter}
showModal( showEditFilter={showEditFilter}
<DeleteScenesDialog view={view}
selected={selectedItems} listSelect={listSelect}
onClose={onCloseEditDelete} onEdit={() =>
/> showModal(
); <EditScenesDialog
}} selected={selectedItems}
operations={otherOperations} onClose={onCloseEditDelete}
zoomable />
/> )
}
onDelete={() => {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}}
operations={otherOperations}
onToggleSidebar={() => setShowSidebar((v) => !v)}
zoomable
/>
<FilterTags <FilterTags
criteria={filter.criteria} criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)} onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={removeCriterion} onRemoveCriterion={removeCriterion}
onRemoveAll={() => clearAllCriteria()} onRemoveAll={() => clearAllCriteria()}
/> />
<PagedList <PagedList
result={result} result={result}
cachedResult={cachedResult} cachedResult={cachedResult}
filter={filter} filter={filter}
totalCount={totalCount} totalCount={totalCount}
onChangePage={setPage} onChangePage={setPage}
metadataByline={metadataByline} metadataByline={metadataByline}
> >
<SceneList <SceneList
filter={effectiveFilter} filter={effectiveFilter}
scenes={items} scenes={items}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectChange={onSelectChange} onSelectChange={onSelectChange}
fromGroupId={fromGroupId} fromGroupId={fromGroupId}
/> />
</PagedList> </PagedList>
</div>
</SidebarPane>
</div> </div>
</TaggerContext> </TaggerContext>
); );

View file

@ -902,6 +902,40 @@ input[type="range"].blue-slider {
} }
} }
.scene-list .filtered-list-toolbar {
display: flex;
flex-wrap: wrap;
row-gap: 1rem;
& > div {
display: flex;
flex: 1;
&:first-child {
justify-content: flex-start;
}
&:nth-child(2) {
justify-content: center;
}
&:last-child {
justify-content: flex-end;
}
}
}
.scene-list.hide-sidebar .sidebar-toggle-button {
transition-delay: 0.1s;
transition-duration: 0;
transition-property: opacity;
}
.scene-list:not(.hide-sidebar) .sidebar-toggle-button {
opacity: 0;
pointer-events: none;
}
.scene-wall, .scene-wall,
.marker-wall { .marker-wall {
.wall-item { .wall-item {

View file

@ -4,6 +4,7 @@ import { FormattedMessage } from "react-intl";
export interface IAlertModalProps { export interface IAlertModalProps {
text: JSX.Element | string; text: JSX.Element | string;
confirmVariant?: string;
show?: boolean; show?: boolean;
confirmButtonText?: string; confirmButtonText?: string;
onConfirm: () => void; onConfirm: () => void;
@ -13,6 +14,7 @@ export interface IAlertModalProps {
export const AlertModal: React.FC<IAlertModalProps> = ({ export const AlertModal: React.FC<IAlertModalProps> = ({
text, text,
show, show,
confirmVariant = "danger",
confirmButtonText, confirmButtonText,
onConfirm, onConfirm,
onCancel, onCancel,
@ -21,7 +23,7 @@ export const AlertModal: React.FC<IAlertModalProps> = ({
<Modal show={show}> <Modal show={show}>
<Modal.Body>{text}</Modal.Body> <Modal.Body>{text}</Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="danger" onClick={() => onConfirm()}> <Button variant={confirmVariant} onClick={() => onConfirm()}>
{confirmButtonText ?? <FormattedMessage id="actions.confirm" />} {confirmButtonText ?? <FormattedMessage id="actions.confirm" />}
</Button> </Button>
<Button variant="secondary" onClick={() => onCancel()}> <Button variant="secondary" onClick={() => onCancel()}>

View file

@ -39,6 +39,12 @@ export const ClearableInput: React.FC<IClearableInput> = ({
setQueryFocus(); setQueryFocus();
} }
function onInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Escape") {
queryRef.current?.blur();
}
}
return ( return (
<div className={cx("clearable-input-group", className)}> <div className={cx("clearable-input-group", className)}>
<FormControl <FormControl
@ -46,6 +52,7 @@ export const ClearableInput: React.FC<IClearableInput> = ({
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onInput={onChangeQuery} onInput={onChangeQuery}
onKeyDown={onInputKeyDown}
className="clearable-text-field" className="clearable-text-field"
/> />
{queryClearShowing && ( {queryClearShowing && (

View file

@ -4,12 +4,15 @@ import {
faChevronUp, faChevronUp,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Collapse } from "react-bootstrap"; import { Button, Collapse, CollapseProps } from "react-bootstrap";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface IProps { interface IProps {
className?: string; className?: string;
text: React.ReactNode; text: React.ReactNode;
collapseProps?: Partial<CollapseProps>;
outsideCollapse?: React.ReactNode;
onOpen?: () => void;
} }
export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = ( export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
@ -17,16 +20,27 @@ export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
) => { ) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
function toggleOpen() {
const nv = !open;
setOpen(nv);
if (props.onOpen && nv) {
props.onOpen();
}
}
return ( return (
<div className={props.className}> <div className={props.className}>
<Button <div className="collapse-header">
onClick={() => setOpen(!open)} <Button
className="minimal collapse-button" onClick={() => toggleOpen()}
> className="minimal collapse-button"
<Icon icon={open ? faChevronDown : faChevronRight} fixedWidth /> >
<span>{props.text}</span> <Icon icon={open ? faChevronDown : faChevronRight} fixedWidth />
</Button> <span>{props.text}</span>
<Collapse in={open}> </Button>
</div>
{props.outsideCollapse}
<Collapse in={open} {...props.collapseProps}>
<div>{props.children}</div> <div>{props.children}</div>
</Collapse> </Collapse>
</div> </div>

View file

@ -19,6 +19,7 @@ export interface IRatingStarsProps {
disabled?: boolean; disabled?: boolean;
precision: RatingStarPrecision; precision: RatingStarPrecision;
valueRequired?: boolean; valueRequired?: boolean;
orMore?: boolean;
} }
export const RatingStars = PatchComponent( export const RatingStars = PatchComponent(
@ -199,6 +200,8 @@ export const RatingStars = PatchComponent(
return `star-fill-${w}`; return `star-fill-${w}`;
} }
const suffix = props.orMore ? "+" : "";
const renderRatingButton = (thisStar: number) => { const renderRatingButton = (thisStar: number) => {
const ratingFraction = getCurrentSelectedRating(); const ratingFraction = getCurrentSelectedRating();
@ -237,6 +240,7 @@ export const RatingStars = PatchComponent(
return ( return (
<span className="star-rating-number"> <span className="star-rating-number">
{ratingFraction.rating + ratingFraction.fraction} {ratingFraction.rating + ratingFraction.fraction}
{suffix}
</span> </span>
); );
}; };

View file

@ -0,0 +1,198 @@
import React, {
PropsWithChildren,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { CollapseButton } from "./CollapseButton";
import { useOnOutsideClick } from "src/hooks/OutsideClick";
import ScreenUtils, { useMediaQuery } from "src/utils/screen";
import { IViewConfig, useInterfaceLocalForage } from "src/hooks/LocalForage";
import { View } from "../List/views";
import cx from "classnames";
import { Button, ButtonToolbar, CollapseProps } from "react-bootstrap";
import { useIntl } from "react-intl";
const fixedSidebarMediaQuery = "only screen and (max-width: 1199px)";
export const Sidebar: React.FC<
PropsWithChildren<{
hide?: boolean;
onHide?: () => void;
}>
> = ({ hide, onHide, children }) => {
const ref = React.useRef<HTMLDivElement>(null);
const closeOnOutsideClick = useMediaQuery(fixedSidebarMediaQuery) && !hide;
useOnOutsideClick(
ref,
!closeOnOutsideClick ? undefined : onHide,
"ignore-sidebar-outside-click"
);
return (
<div ref={ref} className="sidebar">
{children}
</div>
);
};
// SidebarPane is a container for a Sidebar and content.
// It is expected that the children will be two elements:
// a Sidebar and a content element.
export const SidebarPane: React.FC<
PropsWithChildren<{
hideSidebar?: boolean;
}>
> = ({ hideSidebar = false, children }) => {
return (
<div className={cx("sidebar-pane", { "hide-sidebar": hideSidebar })}>
{children}
</div>
);
};
export const SidebarSection: React.FC<
PropsWithChildren<{
text: React.ReactNode;
className?: string;
outsideCollapse?: React.ReactNode;
onOpen?: () => void;
}>
> = ({ className = "", text, outsideCollapse, onOpen, children }) => {
const collapseProps: Partial<CollapseProps> = {
mountOnEnter: true,
unmountOnExit: true,
};
return (
<CollapseButton
className={`sidebar-section ${className}`}
collapseProps={collapseProps}
text={text}
outsideCollapse={outsideCollapse}
onOpen={onOpen}
>
{children}
</CollapseButton>
);
};
export const SidebarIcon: React.FC = () => (
<>
{/* From: https://iconduck.com/icons/19707/sidebar
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. */}
<svg
className="svg-inline--fa fa-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
</>
);
export const SidebarToolbar: React.FC<{
onClose?: () => void;
}> = ({ onClose, children }) => {
const intl = useIntl();
return (
<ButtonToolbar className="sidebar-toolbar">
{onClose ? (
<Button
onClick={onClose}
className="sidebar-close-button"
variant="secondary"
title={intl.formatMessage({ id: "actions.sidebar.close" })}
>
<SidebarIcon />
</Button>
) : null}
{children}
</ButtonToolbar>
);
};
// show sidebar by default if not on mobile
export function defaultShowSidebar() {
return !ScreenUtils.matchesMediaQuery(fixedSidebarMediaQuery);
}
export function useSidebarState(view?: View) {
const [interfaceLocalForage, setInterfaceLocalForage] =
useInterfaceLocalForage();
const { data: interfaceLocalForageData, loading } = interfaceLocalForage;
const viewConfig: IViewConfig = useMemo(() => {
return view ? interfaceLocalForageData?.viewConfig?.[view] || {} : {};
}, [view, interfaceLocalForageData]);
const [showSidebar, setShowSidebar] = useState<boolean>();
// set initial state once loading is done
useEffect(() => {
if (showSidebar !== undefined) return;
if (!view) {
setShowSidebar(defaultShowSidebar());
return;
}
if (loading) return;
// only show sidebar by default on large screens
setShowSidebar(!!viewConfig.showSidebar && defaultShowSidebar());
}, [view, loading, showSidebar, viewConfig.showSidebar]);
const onSetShowSidebar = useCallback(
(show: boolean | ((prevState: boolean | undefined) => boolean)) => {
const nv = typeof show === "function" ? show(showSidebar) : show;
setShowSidebar(nv);
if (view === undefined) return;
setInterfaceLocalForage((prev) => ({
...prev,
viewConfig: {
...prev.viewConfig,
[view]: {
...viewConfig,
showSidebar: nv,
},
},
}));
},
[showSidebar, setInterfaceLocalForage, view, viewConfig]
);
return {
showSidebar: showSidebar ?? defaultShowSidebar(),
setShowSidebar: onSetShowSidebar,
loading: showSidebar === undefined,
};
}

View file

@ -66,3 +66,53 @@ export const TruncatedText: React.FC<ITruncatedTextProps> = ({
</div> </div>
); );
}; };
export const TruncatedInlineText: React.FC<ITruncatedTextProps> = ({
text,
className,
placement = "bottom",
delay = 1000,
}) => {
const [showTooltip, setShowTooltip] = useState(false);
const target = useRef(null);
const startShowingTooltip = useDebounce(() => setShowTooltip(true), delay);
if (!text) return <></>;
const handleFocus = (element: HTMLElement) => {
// Check if visible size is smaller than the content size
if (
element.offsetWidth < element.scrollWidth ||
element.offsetHeight + 10 < element.scrollHeight
)
startShowingTooltip();
};
const handleBlur = () => {
startShowingTooltip.cancel();
setShowTooltip(false);
};
const overlay = (
<Overlay target={target.current} show={showTooltip} placement={placement}>
<Tooltip id={CLASSNAME} className={CLASSNAME_TOOLTIP}>
{text}
</Tooltip>
</Overlay>
);
return (
<span
className={cx(CLASSNAME, "inline", className)}
ref={target}
onMouseEnter={(e) => handleFocus(e.currentTarget)}
onFocus={(e) => handleFocus(e.currentTarget)}
onMouseLeave={handleBlur}
onBlur={handleBlur}
>
{text}
{overlay}
</span>
);
};

View file

@ -303,6 +303,12 @@ button.collapse-button {
.file-info-panel a > & { .file-info-panel a > & {
word-break: break-all; word-break: break-all;
} }
&.inline {
display: inline;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.RatingStars { .RatingStars {
@ -728,3 +734,193 @@ button.btn.favorite-button {
padding-right: 0; padding-right: 0;
} }
} }
$sidebar-width: 250px;
.sidebar-pane {
display: flex;
.sidebar {
// TODO - use different colours for sidebar and toolbar
background-color: $body-bg;
border-right: 1px solid $secondary;
flex: $sidebar-width;
flex-grow: 0;
flex-shrink: 0;
padding-left: 15px;
transition: margin-left 0.1s;
}
.sidebar {
bottom: 0;
left: 0;
margin-top: 4rem;
overflow-y: auto;
position: fixed;
scrollbar-gutter: stable;
top: 0;
width: $sidebar-width;
z-index: 100;
}
&.hide-sidebar .sidebar {
margin-left: -$sidebar-width;
}
> :nth-child(2) {
flex-grow: 1;
padding-left: 0.5rem;
}
&.hide-sidebar {
> :nth-child(2) {
padding-left: 0;
}
}
@include media-breakpoint-up(xl) {
transition: margin-left 0.1s;
&:not(.hide-sidebar) {
> :nth-child(2) {
margin-left: calc($sidebar-width - 15px);
}
}
}
@include media-breakpoint-down(xs) {
.sidebar {
width: 100%;
}
&.hide-sidebar .sidebar {
margin-left: -100%;
}
&.hide-sidebar > :nth-child(2) {
width: 100%;
}
}
@include media-breakpoint-down(xs) {
display: block;
.sidebar {
margin-bottom: $navbar-height;
margin-top: 0;
}
}
}
.sidebar-toolbar {
// TODO - use different colours for sidebar and toolbar
background-color: $body-bg;
display: flex;
justify-content: space-between;
margin-bottom: 0;
padding-bottom: 1rem;
position: sticky;
top: 0;
z-index: 101;
}
@include media-breakpoint-down(xs) {
.sidebar-toolbar {
padding-top: 1rem;
}
}
.sidebar-section {
border-bottom: 1px solid $secondary;
.collapse-header {
// background-color: $secondary;
padding: 0.25rem;
.collapse-button {
font-weight: bold;
text-align: left;
width: 100%;
}
}
.collapse,
// include collapsing to allow for the transition
.collapsing {
padding-top: 0.25rem;
}
}
.sidebar-section:first-child .collapse-header {
border-top: 1px solid $secondary;
}
$sticky-header-height: calc(50px + 3.3rem);
// special case for sidebar in details view
.detail-body {
.sidebar {
// required for sticky to work
align-self: flex-start;
// take a further 15px padding to match the detail body
height: calc(100vh - $sticky-header-height - 15px);
margin-top: -15px;
max-height: calc(100vh - $sticky-header-height - 15px);
overflow-y: auto;
padding-left: 0;
position: sticky;
// sticky detail header is 50px + 3.3rem
top: calc(50px + 3.3rem);
.sidebar-toolbar {
padding-top: 15px;
}
}
.sidebar-pane.hide-sidebar .sidebar {
left: -$sidebar-width;
margin-left: calc(-15px - $sidebar-width);
}
// on smaller viewports we want the sidebar to overlap content
@include media-breakpoint-down(lg) {
.sidebar-pane:not(.hide-sidebar) .sidebar {
margin-right: -$sidebar-width;
}
.sidebar-pane > :nth-child(2) {
transition: none;
}
}
@include media-breakpoint-down(xs) {
.sidebar {
flex: 100% 0 0;
height: calc(100vh - 4rem);
max-height: calc(100vh - 4rem);
padding-top: 0;
top: 0;
}
.sidebar-pane:not(.hide-sidebar) .sidebar {
margin-right: -100%;
}
.sidebar-pane.hide-sidebar .sidebar {
display: none;
}
}
@include media-breakpoint-up(xl) {
.sidebar-pane:not(.hide-sidebar) {
> :nth-child(2) {
margin-left: 0;
}
}
.sidebar-pane.hide-sidebar {
> :nth-child(2) {
padding-left: 15px;
}
}
}
}

View file

@ -2061,8 +2061,8 @@ export const useTagsMerge = () =>
}, },
}); });
export const useSaveFilter = () => export const useSaveFilter = () => {
GQL.useSaveFilterMutation({ const [saveFilterMutation] = GQL.useSaveFilterMutation({
update(cache, result) { update(cache, result) {
if (!result.data?.saveFilter) return; if (!result.data?.saveFilter) return;
@ -2070,6 +2070,26 @@ export const useSaveFilter = () =>
}, },
}); });
function saveFilter(filter: ListFilterModel, name: string, id?: string) {
const filterCopy = filter.clone();
return saveFilterMutation({
variables: {
input: {
id,
mode: filter.mode,
name,
find_filter: filterCopy.makeFindFilter(),
object_filter: filterCopy.makeSavedFilter(),
ui_options: filterCopy.makeSavedUIOptions(),
},
},
});
}
return saveFilter;
};
export const useSavedFilterDestroy = () => export const useSavedFilterDestroy = () =>
GQL.useDestroySavedFilterMutation({ GQL.useDestroySavedFilterMutation({
update(cache, result, { variables }) { update(cache, result, { variables }) {

View file

@ -1,6 +1,7 @@
import localForage from "localforage"; import localForage from "localforage";
import isEqual from "lodash-es/isEqual"; import isEqual from "lodash-es/isEqual";
import React, { Dispatch, SetStateAction, useEffect } from "react"; import React, { Dispatch, SetStateAction, useEffect } from "react";
import { View } from "src/components/List/views";
import { ConfigImageLightboxInput } from "src/core/generated-graphql"; import { ConfigImageLightboxInput } from "src/core/generated-graphql";
interface IInterfaceQueryConfig { interface IInterfaceQueryConfig {
@ -9,11 +10,17 @@ interface IInterfaceQueryConfig {
currentPage: number; currentPage: number;
} }
export interface IViewConfig {
showSidebar?: boolean;
}
type IQueryConfig = Record<string, IInterfaceQueryConfig>; type IQueryConfig = Record<string, IInterfaceQueryConfig>;
interface IInterfaceConfig { interface IInterfaceConfig {
queryConfig: IQueryConfig; queryConfig: IQueryConfig;
imageLightbox: ConfigImageLightboxInput; imageLightbox: ConfigImageLightboxInput;
// Partial is required because using View makes the key mandatory
viewConfig: Partial<Record<View, IViewConfig>>;
} }
export interface IChangelogConfig { export interface IChangelogConfig {

View file

@ -0,0 +1,34 @@
import React, { useEffect } from "react";
export const useOnOutsideClick = (
ref: React.RefObject<HTMLElement>,
callback?: () => void,
excludeClassName?: string
) => {
useEffect(() => {
if (!callback) return;
/**
* Alert if clicked on outside of element
*/
function handleClickOutside(event: MouseEvent) {
if (
ref.current &&
event.target instanceof Node &&
!ref.current.contains(event.target) &&
!(
excludeClassName &&
(event.target as HTMLElement).closest(`.${excludeClassName}`)
)
) {
callback?.();
}
}
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref, callback, excludeClassName]);
};

20
ui/v2.5/src/hooks/data.ts Normal file
View file

@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
export interface ILoadResults<T> {
results: T;
loading: boolean;
}
export function useCacheResults<T>(data: ILoadResults<T>) {
const [results, setResults] = useState<T | undefined>(
!data.loading ? data.results : undefined
);
useEffect(() => {
if (!data.loading) {
setResults(data.results);
}
}, [data.loading, data.results]);
return { loading: data.loading, results };
}

View file

@ -1,3 +1,9 @@
// variables required by other scss files
// this is calculated from the existing height
// TODO: we should set this explicitly in the navbar
$navbar-height: 48.75px;
@import "styles/theme"; @import "styles/theme";
@import "styles/range"; @import "styles/range";
@import "styles/scrollbars"; @import "styles/scrollbars";
@ -258,6 +264,10 @@ dd {
padding: 5px 0; padding: 5px 0;
} }
.tab-content {
padding-bottom: 0;
}
.item-list-header { .item-list-header {
align-content: center; align-content: center;
// border-bottom: solid 2px #192127; // border-bottom: solid 2px #192127;
@ -270,9 +280,10 @@ dd {
.item-list-container { .item-list-container {
padding-top: 15px; padding-top: 15px;
@media (max-width: 576px) { // this breaks sticky sidebar - need to work out why this is here
overflow-x: hidden; // @media (max-width: 576px) {
} // overflow-x: hidden;
// }
} }
} }

View file

@ -124,6 +124,10 @@
"set_image": "Set image…", "set_image": "Set image…",
"show": "Show", "show": "Show",
"show_configuration": "Show Configuration", "show_configuration": "Show Configuration",
"sidebar": {
"close": "Close sidebar",
"open": "Open sidebar"
},
"skip": "Skip", "skip": "Skip",
"split": "Split", "split": "Split",
"stop": "Stop", "stop": "Stop",
@ -932,7 +936,7 @@
"destination": "Destination", "destination": "Destination",
"source": "Source" "source": "Source"
}, },
"overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?", "overwrite_filter_warning": "Saved filter \"{entityName}\" will be overwritten.",
"performers_found": "{count} performers found", "performers_found": "{count} performers found",
"reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}", "reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}",
"reassign_files": { "reassign_files": {
@ -982,6 +986,7 @@
"scrape_entity_title": "{entity_type} Scrape Results", "scrape_entity_title": "{entity_type} Scrape Results",
"scrape_results_existing": "Existing", "scrape_results_existing": "Existing",
"scrape_results_scraped": "Scraped", "scrape_results_scraped": "Scraped",
"set_default_filter_confirm": "Are you sure you want to set this filter as the default?",
"set_image_url_title": "Image URL", "set_image_url_title": "Image URL",
"unsaved_changes": "Unsaved changes. Are you sure you want to leave?" "unsaved_changes": "Unsaved changes. Are you sure you want to leave?"
}, },

View file

@ -463,6 +463,19 @@ export class ListFilterModel {
}; };
} }
public criteriaFor(type: CriterionType) {
return this.criteria.filter((c) => c.criterionOption.type === type);
}
public replaceCriteria(type: CriterionType, newCriteria: Criterion[]) {
const criteria = [
...this.criteria.filter((c) => c.criterionOption.type !== type),
...newCriteria,
];
return this.setCriteria(criteria);
}
public clearCriteria() { public clearCriteria() {
const ret = this.clone(); const ret = this.clone();
ret.criteria = []; ret.criteria = [];
@ -470,6 +483,12 @@ export class ListFilterModel {
return ret; return ret;
} }
public setCriteria(criteria: Criterion[]) {
const ret = this.clone();
ret.criteria = criteria;
return ret;
}
public removeCriterion(type: CriterionType) { public removeCriterion(type: CriterionType) {
const ret = this.clone(); const ret = this.clone();
const c = ret.criteria.find((cc) => cc.criterionOption.type === type); const c = ret.criteria.find((cc) => cc.criterionOption.type === type);

View file

@ -1,13 +1,13 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect, useCallback } from "react";
const useFocus = () => { const useFocus = () => {
const htmlElRef = useRef<HTMLInputElement | null>(null); const htmlElRef = useRef<HTMLInputElement | null>(null);
const setFocus = () => { const setFocus = useCallback(() => {
const currentEl = htmlElRef.current; const currentEl = htmlElRef.current;
if (currentEl) { if (currentEl) {
currentEl.focus(); currentEl.focus();
} }
}; }, []);
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
return [htmlElRef, setFocus] as const; return [htmlElRef, setFocus] as const;

View file

@ -1,11 +1,39 @@
import { useEffect, useState } from "react";
const isMobile = () => const isMobile = () =>
window.matchMedia("only screen and (max-width: 576px)").matches; window.matchMedia("only screen and (max-width: 576px)").matches;
const isTouch = () => window.matchMedia("(pointer: coarse)").matches; const isTouch = () => window.matchMedia("(pointer: coarse)").matches;
function matchesMediaQuery(query: string) {
return window.matchMedia(query).matches;
}
// from: https://dev.to/salimzade/handle-media-query-in-react-with-hooks-3cp3
export const useMediaQuery = (query: string): boolean => {
const [matches, setMatches] = useState<boolean>(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
// Define the listener as a separate function to avoid recreating it on each render
const listener = () => setMatches(media.matches);
// Use 'change' instead of 'resize' for better performance
media.addEventListener("change", listener);
// Cleanup function to remove the event listener
return () => media.removeEventListener("change", listener);
}, [query]); // Only recreate the listener when 'matches' or 'query' changes
return matches;
};
const ScreenUtils = { const ScreenUtils = {
isMobile, isMobile,
isTouch, isTouch,
matchesMediaQuery,
}; };
export default ScreenUtils; export default ScreenUtils;