mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
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:
parent
a91b9c4d92
commit
ed4d17b8f0
33 changed files with 2883 additions and 232 deletions
|
|
@ -8,9 +8,11 @@ import {
|
|||
IListFilterOperation,
|
||||
ListOperationButtons,
|
||||
} from "./ListOperationButtons";
|
||||
import { ButtonToolbar } from "react-bootstrap";
|
||||
import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
|
||||
import { View } from "./views";
|
||||
import { IListSelect, useFilterOperations } from "./util";
|
||||
import { SidebarIcon } from "../Shared/Sidebar";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
export interface IItemListOperation<T extends QueryResult> {
|
||||
text: string;
|
||||
|
|
@ -41,6 +43,7 @@ export interface IFilteredListToolbar {
|
|||
onDelete?: () => void;
|
||||
operations?: IListFilterOperation[];
|
||||
zoomable?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
|
|
@ -53,7 +56,9 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
|||
onDelete,
|
||||
operations,
|
||||
zoomable = false,
|
||||
onToggleSidebar,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const filterOptions = filter.options;
|
||||
const { setDisplayMode, setZoom } = useFilterOperations({
|
||||
filter,
|
||||
|
|
@ -63,29 +68,52 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
|||
|
||||
return (
|
||||
<ButtonToolbar className="filtered-list-toolbar">
|
||||
{showEditFilter && (
|
||||
<ListFilter
|
||||
onFilterUpdate={setFilter}
|
||||
filter={filter}
|
||||
openFilterDialog={() => showEditFilter()}
|
||||
view={view}
|
||||
/>
|
||||
{onToggleSidebar && (
|
||||
<div>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
className="sidebar-toggle-button"
|
||||
onClick={onToggleSidebar}
|
||||
variant="secondary"
|
||||
title={intl.formatMessage({ id: "actions.sidebar.open" })}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
<ListOperationButtons
|
||||
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}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<ButtonGroup>
|
||||
{showEditFilter && (
|
||||
<ListFilter
|
||||
onFilterUpdate={setFilter}
|
||||
filter={filter}
|
||||
openFilterDialog={() => showEditFilter()}
|
||||
view={view}
|
||||
withSidebar={!!onToggleSidebar}
|
||||
/>
|
||||
)}
|
||||
<ListOperationButtons
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { BooleanCriterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import {
|
||||
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 {
|
||||
criterion: BooleanCriterion;
|
||||
|
|
@ -43,3 +48,86 @@ export const BooleanFilter: React.FC<IBooleanFilter> = ({
|
|||
</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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Badge, Button } from "react-bootstrap";
|
|||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
interface IFilterButtonProps {
|
||||
filter: ListFilterModel;
|
||||
|
|
@ -13,10 +14,16 @@ export const FilterButton: React.FC<IFilterButtonProps> = ({
|
|||
filter,
|
||||
onClick,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const count = useMemo(() => filter.count(), [filter]);
|
||||
|
||||
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} />
|
||||
{count ? (
|
||||
<Badge pill variant="info">
|
||||
|
|
|
|||
96
ui/v2.5/src/components/List/Filters/FilterSidebar.tsx
Normal file
96
ui/v2.5/src/components/List/Filters/FilterSidebar.tsx
Normal 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]);
|
||||
}
|
||||
|
|
@ -1,10 +1,22 @@
|
|||
import React from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { FilterSelect, SelectObject } from "src/components/Shared/Select";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import { ModifierCriterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { ILabeledId } from "src/models/list-filter/types";
|
||||
import { ILoadResults, useCacheResults } from "src/hooks/data";
|
||||
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 {
|
||||
criterion: ModifierCriterion<ILabeledId[]>;
|
||||
|
|
@ -63,3 +75,403 @@ export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
|
|||
</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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { useFindPerformersQuery } from "src/core/generated-graphql";
|
||||
import { useFindPerformersForSelectQuery } from "src/core/generated-graphql";
|
||||
import { ObjectsFilter } from "./SelectableFilter";
|
||||
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 {
|
||||
criterion: PerformersCriterion;
|
||||
setCriterion: (c: PerformersCriterion) => void;
|
||||
}
|
||||
|
||||
function usePerformerQuery(query: string) {
|
||||
const { data, loading } = useFindPerformersQuery({
|
||||
function usePerformerQuery(query: string, skip?: boolean) {
|
||||
const { data, loading } = useFindPerformersForSelectQuery({
|
||||
variables: {
|
||||
filter: {
|
||||
q: query,
|
||||
per_page: 200,
|
||||
},
|
||||
},
|
||||
skip,
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
import React from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import React, { useMemo } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { CriterionModifier } from "../../../core/generated-graphql";
|
||||
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 { 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 {
|
||||
criterion: ModifierCriterion<INumberValue>;
|
||||
|
|
@ -59,3 +71,136 @@ export const RatingFilter: React.FC<IRatingFilterProps> = ({
|
|||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
364
ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx
Normal file
364
ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx
Normal 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 });
|
||||
}
|
||||
|
|
@ -1,22 +1,27 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { useFindStudiosQuery } from "src/core/generated-graphql";
|
||||
import React, { ReactNode, useMemo } from "react";
|
||||
import { useFindStudiosForSelectQuery } from "src/core/generated-graphql";
|
||||
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||
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 {
|
||||
criterion: StudiosCriterion;
|
||||
setCriterion: (c: StudiosCriterion) => void;
|
||||
}
|
||||
|
||||
function useStudioQuery(query: string) {
|
||||
const { data, loading } = useFindStudiosQuery({
|
||||
function useStudioQuery(query: string, skip?: boolean) {
|
||||
const { data, loading } = useFindStudiosForSelectQuery({
|
||||
variables: {
|
||||
filter: {
|
||||
q: query,
|
||||
per_page: 200,
|
||||
},
|
||||
},
|
||||
skip,
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,27 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { useFindTagsQuery } from "src/core/generated-graphql";
|
||||
import React, { ReactNode, useMemo } from "react";
|
||||
import { useFindTagsForSelectQuery } from "src/core/generated-graphql";
|
||||
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||
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 {
|
||||
criterion: StudiosCriterion;
|
||||
setCriterion: (c: StudiosCriterion) => void;
|
||||
}
|
||||
|
||||
function useTagQuery(query: string) {
|
||||
const { data, loading } = useFindTagsQuery({
|
||||
function useTagQuery(query: string, skip?: boolean) {
|
||||
const { data, loading } = useFindTagsForSelectQuery({
|
||||
variables: {
|
||||
filter: {
|
||||
q: query,
|
||||
per_page: 200,
|
||||
},
|
||||
},
|
||||
skip,
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -61,11 +61,13 @@ export function useDebouncedSearchInput(
|
|||
export const SearchTermInput: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
||||
}> = ({ filter, onFilterUpdate }) => {
|
||||
focus?: ReturnType<typeof useFocus>;
|
||||
}> = ({ filter, onFilterUpdate, focus: providedFocus }) => {
|
||||
const intl = useIntl();
|
||||
const [localInput, setLocalInput] = useState(filter.searchTerm);
|
||||
|
||||
const focus = useFocus();
|
||||
const localFocus = useFocus();
|
||||
const focus = providedFocus ?? localFocus;
|
||||
const [, setQueryFocus] = focus;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -233,6 +235,7 @@ interface IListFilterProps {
|
|||
filter: ListFilterModel;
|
||||
view?: View;
|
||||
openFilterDialog: () => void;
|
||||
withSidebar?: boolean;
|
||||
}
|
||||
|
||||
export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
|
|
@ -240,6 +243,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
filter,
|
||||
openFilterDialog,
|
||||
view,
|
||||
withSidebar,
|
||||
}) => {
|
||||
const filterOptions = filter.options;
|
||||
|
||||
|
|
@ -313,31 +317,38 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 d-flex">
|
||||
<SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
|
||||
</div>
|
||||
{!withSidebar && (
|
||||
<div className="d-flex">
|
||||
<SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ButtonGroup className="mr-2 mb-2">
|
||||
<SavedFilterDropdown
|
||||
filter={filter}
|
||||
onSetFilter={(f) => {
|
||||
onFilterUpdate(f);
|
||||
}}
|
||||
view={view}
|
||||
/>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="filter-tooltip">
|
||||
<FormattedMessage id="search_filter.name" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<FilterButton onClick={() => openFilterDialog()} filter={filter} />
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
{!withSidebar && (
|
||||
<ButtonGroup className="mr-2">
|
||||
<SavedFilterDropdown
|
||||
filter={filter}
|
||||
onSetFilter={(f) => {
|
||||
onFilterUpdate(f);
|
||||
}}
|
||||
view={view}
|
||||
/>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="filter-tooltip">
|
||||
<FormattedMessage id="search_filter.name" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<FilterButton
|
||||
onClick={() => openFilterDialog()}
|
||||
filter={filter}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
<Dropdown as={ButtonGroup} className="mr-2 mb-2">
|
||||
<Dropdown as={ButtonGroup} className="mr-2">
|
||||
<InputGroup.Prepend>
|
||||
<Dropdown.Toggle variant="secondary">
|
||||
{currentSortBy
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
|
|||
if (!children) return null;
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown as={ButtonGroup}>
|
||||
<Dropdown.Toggle variant="secondary" id="more-menu">
|
||||
<Icon icon={faEllipsisH} />
|
||||
</Dropdown.Toggle>
|
||||
|
|
@ -116,7 +116,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
|||
|
||||
if (buttons.length > 0) {
|
||||
return (
|
||||
<ButtonGroup className="ml-2 mb-2">
|
||||
<ButtonGroup className="ml-2">
|
||||
{buttons.map((button) => {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
|
|
@ -206,7 +206,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
|||
<>
|
||||
{maybeRenderButtons()}
|
||||
|
||||
<div className="mx-2 mb-2">{renderMore()}</div>
|
||||
<ButtonGroup className="ml-2">{renderMore()}</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup className="mb-2">
|
||||
<ButtonGroup className="ml-2">
|
||||
{displayModeOptions.map((option) => (
|
||||
<OverlayTrigger
|
||||
key={option}
|
||||
|
|
@ -140,7 +140,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
|||
function maybeRenderZoom() {
|
||||
if (onSetZoom && displayMode === DisplayMode.Grid) {
|
||||
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
|
||||
className="zoom-slider ml-1"
|
||||
type="range"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React, { HTMLAttributes, useState } from "react";
|
||||
import React, { HTMLAttributes, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
Form,
|
||||
FormControl,
|
||||
InputGroup,
|
||||
Modal,
|
||||
|
|
@ -17,12 +18,171 @@ import {
|
|||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
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 { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
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 {
|
||||
filter: ListFilterModel;
|
||||
|
|
@ -49,7 +209,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
|||
SavedFilterDataFragment | undefined
|
||||
>();
|
||||
|
||||
const [saveFilter] = useSaveFilter();
|
||||
const saveFilter = useSaveFilter();
|
||||
const [destroyFilter] = useSavedFilterDestroy();
|
||||
const [saveUISetting] = useConfigureUISetting();
|
||||
|
||||
|
|
@ -60,18 +220,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
|||
|
||||
try {
|
||||
setSaving(true);
|
||||
await saveFilter({
|
||||
variables: {
|
||||
input: {
|
||||
id,
|
||||
mode: filter.mode,
|
||||
name,
|
||||
find_filter: filterCopy.makeFindFilter(),
|
||||
object_filter: filterCopy.makeSavedFilter(),
|
||||
ui_options: filterCopy.makeSavedUIOptions(),
|
||||
},
|
||||
},
|
||||
});
|
||||
await saveFilter(filterCopy, name, id);
|
||||
|
||||
Toast.success(
|
||||
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() {
|
||||
if (error) return <h6 className="text-center">{error.message}</h6>;
|
||||
|
||||
|
|
@ -327,8 +408,24 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{maybeRenderDeleteAlert()}
|
||||
{maybeRenderOverwriteAlert()}
|
||||
<DeleteAlert
|
||||
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>
|
||||
<FormControl
|
||||
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) => {
|
||||
const SavedFilterDropdownRef = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
|
|
@ -377,7 +787,7 @@ export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
|
|||
SavedFilterDropdownRef.displayName = "SavedFilterDropdown";
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown as={ButtonGroup}>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ input[type="range"].zoom-slider {
|
|||
max-width: 60px;
|
||||
padding-left: 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 {
|
||||
|
|
@ -103,7 +106,7 @@ input[type="range"].zoom-slider {
|
|||
|
||||
.saved-filter-list {
|
||||
list-style: none;
|
||||
margin-bottom: 0;
|
||||
margin-bottom: 0.25rem;
|
||||
max-height: 230px;
|
||||
overflow-y: auto;
|
||||
padding-left: 0;
|
||||
|
|
@ -113,11 +116,18 @@ input[type="range"].zoom-slider {
|
|||
|
||||
.dropdown-item {
|
||||
align-items: center;
|
||||
color: $text-color;
|
||||
display: inline;
|
||||
overflow-x: hidden;
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 0.25rem;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: #8a9ba826;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 1.3em;
|
||||
margin-left: 0.25em;
|
||||
|
|
@ -267,13 +358,16 @@ input[type="range"].zoom-slider {
|
|||
}
|
||||
|
||||
.filter-button {
|
||||
position: relative;
|
||||
|
||||
.fa-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 60%;
|
||||
position: absolute;
|
||||
right: -3px;
|
||||
right: 0;
|
||||
|
||||
// button group has a z-index of 1
|
||||
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 {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
|
@ -582,6 +803,21 @@ input[type="range"].zoom-slider {
|
|||
|
||||
.filtered-list-toolbar {
|
||||
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 {
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import { useIntl } from "react-intl";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
|
|
@ -31,6 +31,23 @@ import { IListFilterOperation } from "../List/ListOperationButtons";
|
|||
import { FilteredListToolbar } from "../List/FilteredListToolbar";
|
||||
import { useFilteredItemList } from "../List/ItemList";
|
||||
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) {
|
||||
const duration = result?.data?.findScenes?.duration;
|
||||
|
|
@ -184,6 +201,70 @@ const SceneList: React.FC<{
|
|||
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 {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
defaultSort?: string;
|
||||
|
|
@ -199,6 +280,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
|||
const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
|
||||
|
||||
// States
|
||||
const {
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
loading: sidebarStateLoading,
|
||||
} = useSidebarState(view);
|
||||
|
||||
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
|
||||
useFilteredItemList({
|
||||
filterStateProps: {
|
||||
|
|
@ -237,6 +324,10 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
|||
});
|
||||
|
||||
useAddKeybinds(filter, totalCount);
|
||||
useFilteredSidebarKeybinds({
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
});
|
||||
|
||||
const onCloseEditDelete = useCloseEditDelete({
|
||||
closeModal,
|
||||
|
|
@ -340,62 +431,81 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
|||
];
|
||||
|
||||
// render
|
||||
if (filterLoading) return null;
|
||||
if (filterLoading || sidebarStateLoading) return null;
|
||||
|
||||
return (
|
||||
<TaggerContext>
|
||||
<div className="item-list-container">
|
||||
<div
|
||||
className={cx("item-list-container scene-list", {
|
||||
"hide-sidebar": !showSidebar,
|
||||
})}
|
||||
>
|
||||
{modal}
|
||||
|
||||
<FilteredListToolbar
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
listSelect={listSelect}
|
||||
onEdit={() =>
|
||||
showModal(
|
||||
<EditScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onDelete={() => {
|
||||
showModal(
|
||||
<DeleteScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
operations={otherOperations}
|
||||
zoomable
|
||||
/>
|
||||
<SidebarPane hideSidebar={!showSidebar}>
|
||||
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
|
||||
<SidebarContent
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
sidebarOpen={showSidebar}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
/>
|
||||
</Sidebar>
|
||||
<div>
|
||||
<FilteredListToolbar
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
listSelect={listSelect}
|
||||
onEdit={() =>
|
||||
showModal(
|
||||
<EditScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onDelete={() => {
|
||||
showModal(
|
||||
<DeleteScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
operations={otherOperations}
|
||||
onToggleSidebar={() => setShowSidebar((v) => !v)}
|
||||
zoomable
|
||||
/>
|
||||
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAll={() => clearAllCriteria()}
|
||||
/>
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAll={() => clearAllCriteria()}
|
||||
/>
|
||||
|
||||
<PagedList
|
||||
result={result}
|
||||
cachedResult={cachedResult}
|
||||
filter={filter}
|
||||
totalCount={totalCount}
|
||||
onChangePage={setPage}
|
||||
metadataByline={metadataByline}
|
||||
>
|
||||
<SceneList
|
||||
filter={effectiveFilter}
|
||||
scenes={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
fromGroupId={fromGroupId}
|
||||
/>
|
||||
</PagedList>
|
||||
<PagedList
|
||||
result={result}
|
||||
cachedResult={cachedResult}
|
||||
filter={filter}
|
||||
totalCount={totalCount}
|
||||
onChangePage={setPage}
|
||||
metadataByline={metadataByline}
|
||||
>
|
||||
<SceneList
|
||||
filter={effectiveFilter}
|
||||
scenes={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
fromGroupId={fromGroupId}
|
||||
/>
|
||||
</PagedList>
|
||||
</div>
|
||||
</SidebarPane>
|
||||
</div>
|
||||
</TaggerContext>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
.marker-wall {
|
||||
.wall-item {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { FormattedMessage } from "react-intl";
|
|||
|
||||
export interface IAlertModalProps {
|
||||
text: JSX.Element | string;
|
||||
confirmVariant?: string;
|
||||
show?: boolean;
|
||||
confirmButtonText?: string;
|
||||
onConfirm: () => void;
|
||||
|
|
@ -13,6 +14,7 @@ export interface IAlertModalProps {
|
|||
export const AlertModal: React.FC<IAlertModalProps> = ({
|
||||
text,
|
||||
show,
|
||||
confirmVariant = "danger",
|
||||
confirmButtonText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
|
|
@ -21,7 +23,7 @@ export const AlertModal: React.FC<IAlertModalProps> = ({
|
|||
<Modal show={show}>
|
||||
<Modal.Body>{text}</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="danger" onClick={() => onConfirm()}>
|
||||
<Button variant={confirmVariant} onClick={() => onConfirm()}>
|
||||
{confirmButtonText ?? <FormattedMessage id="actions.confirm" />}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onCancel()}>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ export const ClearableInput: React.FC<IClearableInput> = ({
|
|||
setQueryFocus();
|
||||
}
|
||||
|
||||
function onInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Escape") {
|
||||
queryRef.current?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx("clearable-input-group", className)}>
|
||||
<FormControl
|
||||
|
|
@ -46,6 +52,7 @@ export const ClearableInput: React.FC<IClearableInput> = ({
|
|||
placeholder={placeholder}
|
||||
value={value}
|
||||
onInput={onChangeQuery}
|
||||
onKeyDown={onInputKeyDown}
|
||||
className="clearable-text-field"
|
||||
/>
|
||||
{queryClearShowing && (
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import {
|
|||
faChevronUp,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Collapse } from "react-bootstrap";
|
||||
import { Button, Collapse, CollapseProps } from "react-bootstrap";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
text: React.ReactNode;
|
||||
collapseProps?: Partial<CollapseProps>;
|
||||
outsideCollapse?: React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
function toggleOpen() {
|
||||
const nv = !open;
|
||||
setOpen(nv);
|
||||
if (props.onOpen && nv) {
|
||||
props.onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<Button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="minimal collapse-button"
|
||||
>
|
||||
<Icon icon={open ? faChevronDown : faChevronRight} fixedWidth />
|
||||
<span>{props.text}</span>
|
||||
</Button>
|
||||
<Collapse in={open}>
|
||||
<div className="collapse-header">
|
||||
<Button
|
||||
onClick={() => toggleOpen()}
|
||||
className="minimal collapse-button"
|
||||
>
|
||||
<Icon icon={open ? faChevronDown : faChevronRight} fixedWidth />
|
||||
<span>{props.text}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{props.outsideCollapse}
|
||||
<Collapse in={open} {...props.collapseProps}>
|
||||
<div>{props.children}</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface IRatingStarsProps {
|
|||
disabled?: boolean;
|
||||
precision: RatingStarPrecision;
|
||||
valueRequired?: boolean;
|
||||
orMore?: boolean;
|
||||
}
|
||||
|
||||
export const RatingStars = PatchComponent(
|
||||
|
|
@ -199,6 +200,8 @@ export const RatingStars = PatchComponent(
|
|||
return `star-fill-${w}`;
|
||||
}
|
||||
|
||||
const suffix = props.orMore ? "+" : "";
|
||||
|
||||
const renderRatingButton = (thisStar: number) => {
|
||||
const ratingFraction = getCurrentSelectedRating();
|
||||
|
||||
|
|
@ -237,6 +240,7 @@ export const RatingStars = PatchComponent(
|
|||
return (
|
||||
<span className="star-rating-number">
|
||||
{ratingFraction.rating + ratingFraction.fraction}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
198
ui/v2.5/src/components/Shared/Sidebar.tsx
Normal file
198
ui/v2.5/src/components/Shared/Sidebar.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -66,3 +66,53 @@ export const TruncatedText: React.FC<ITruncatedTextProps> = ({
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -303,6 +303,12 @@ button.collapse-button {
|
|||
.file-info-panel a > & {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.RatingStars {
|
||||
|
|
@ -728,3 +734,193 @@ button.btn.favorite-button {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2061,8 +2061,8 @@ export const useTagsMerge = () =>
|
|||
},
|
||||
});
|
||||
|
||||
export const useSaveFilter = () =>
|
||||
GQL.useSaveFilterMutation({
|
||||
export const useSaveFilter = () => {
|
||||
const [saveFilterMutation] = GQL.useSaveFilterMutation({
|
||||
update(cache, result) {
|
||||
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 = () =>
|
||||
GQL.useDestroySavedFilterMutation({
|
||||
update(cache, result, { variables }) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import localForage from "localforage";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import React, { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import { View } from "src/components/List/views";
|
||||
import { ConfigImageLightboxInput } from "src/core/generated-graphql";
|
||||
|
||||
interface IInterfaceQueryConfig {
|
||||
|
|
@ -9,11 +10,17 @@ interface IInterfaceQueryConfig {
|
|||
currentPage: number;
|
||||
}
|
||||
|
||||
export interface IViewConfig {
|
||||
showSidebar?: boolean;
|
||||
}
|
||||
|
||||
type IQueryConfig = Record<string, IInterfaceQueryConfig>;
|
||||
|
||||
interface IInterfaceConfig {
|
||||
queryConfig: IQueryConfig;
|
||||
imageLightbox: ConfigImageLightboxInput;
|
||||
// Partial is required because using View makes the key mandatory
|
||||
viewConfig: Partial<Record<View, IViewConfig>>;
|
||||
}
|
||||
|
||||
export interface IChangelogConfig {
|
||||
|
|
|
|||
34
ui/v2.5/src/hooks/OutsideClick.tsx
Normal file
34
ui/v2.5/src/hooks/OutsideClick.tsx
Normal 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
20
ui/v2.5/src/hooks/data.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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/range";
|
||||
@import "styles/scrollbars";
|
||||
|
|
@ -258,6 +264,10 @@ dd {
|
|||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.item-list-header {
|
||||
align-content: center;
|
||||
// border-bottom: solid 2px #192127;
|
||||
|
|
@ -270,9 +280,10 @@ dd {
|
|||
.item-list-container {
|
||||
padding-top: 15px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
// this breaks sticky sidebar - need to work out why this is here
|
||||
// @media (max-width: 576px) {
|
||||
// overflow-x: hidden;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,10 @@
|
|||
"set_image": "Set image…",
|
||||
"show": "Show",
|
||||
"show_configuration": "Show Configuration",
|
||||
"sidebar": {
|
||||
"close": "Close sidebar",
|
||||
"open": "Open sidebar"
|
||||
},
|
||||
"skip": "Skip",
|
||||
"split": "Split",
|
||||
"stop": "Stop",
|
||||
|
|
@ -932,7 +936,7 @@
|
|||
"destination": "Destination",
|
||||
"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",
|
||||
"reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}",
|
||||
"reassign_files": {
|
||||
|
|
@ -982,6 +986,7 @@
|
|||
"scrape_entity_title": "{entity_type} Scrape Results",
|
||||
"scrape_results_existing": "Existing",
|
||||
"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",
|
||||
"unsaved_changes": "Unsaved changes. Are you sure you want to leave?"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
const ret = this.clone();
|
||||
ret.criteria = [];
|
||||
|
|
@ -470,6 +483,12 @@ export class ListFilterModel {
|
|||
return ret;
|
||||
}
|
||||
|
||||
public setCriteria(criteria: Criterion[]) {
|
||||
const ret = this.clone();
|
||||
ret.criteria = criteria;
|
||||
return ret;
|
||||
}
|
||||
|
||||
public removeCriterion(type: CriterionType) {
|
||||
const ret = this.clone();
|
||||
const c = ret.criteria.find((cc) => cc.criterionOption.type === type);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
|
||||
const useFocus = () => {
|
||||
const htmlElRef = useRef<HTMLInputElement | null>(null);
|
||||
const setFocus = () => {
|
||||
const setFocus = useCallback(() => {
|
||||
const currentEl = htmlElRef.current;
|
||||
if (currentEl) {
|
||||
currentEl.focus();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
return [htmlElRef, setFocus] as const;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,39 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
const isMobile = () =>
|
||||
window.matchMedia("only screen and (max-width: 576px)").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 = {
|
||||
isMobile,
|
||||
isTouch,
|
||||
matchesMediaQuery,
|
||||
};
|
||||
|
||||
export default ScreenUtils;
|
||||
|
|
|
|||
Loading…
Reference in a new issue