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,
|
IListFilterOperation,
|
||||||
ListOperationButtons,
|
ListOperationButtons,
|
||||||
} from "./ListOperationButtons";
|
} from "./ListOperationButtons";
|
||||||
import { ButtonToolbar } from "react-bootstrap";
|
import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
|
||||||
import { View } from "./views";
|
import { View } from "./views";
|
||||||
import { IListSelect, useFilterOperations } from "./util";
|
import { IListSelect, useFilterOperations } from "./util";
|
||||||
|
import { SidebarIcon } from "../Shared/Sidebar";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
|
||||||
export interface IItemListOperation<T extends QueryResult> {
|
export interface IItemListOperation<T extends QueryResult> {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -41,6 +43,7 @@ export interface IFilteredListToolbar {
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
operations?: IListFilterOperation[];
|
operations?: IListFilterOperation[];
|
||||||
zoomable?: boolean;
|
zoomable?: boolean;
|
||||||
|
onToggleSidebar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||||
|
|
@ -53,7 +56,9 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||||
onDelete,
|
onDelete,
|
||||||
operations,
|
operations,
|
||||||
zoomable = false,
|
zoomable = false,
|
||||||
|
onToggleSidebar,
|
||||||
}) => {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const filterOptions = filter.options;
|
const filterOptions = filter.options;
|
||||||
const { setDisplayMode, setZoom } = useFilterOperations({
|
const { setDisplayMode, setZoom } = useFilterOperations({
|
||||||
filter,
|
filter,
|
||||||
|
|
@ -63,29 +68,52 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonToolbar className="filtered-list-toolbar">
|
<ButtonToolbar className="filtered-list-toolbar">
|
||||||
{showEditFilter && (
|
{onToggleSidebar && (
|
||||||
<ListFilter
|
<div>
|
||||||
onFilterUpdate={setFilter}
|
<ButtonGroup>
|
||||||
filter={filter}
|
<Button
|
||||||
openFilterDialog={() => showEditFilter()}
|
className="sidebar-toggle-button"
|
||||||
view={view}
|
onClick={onToggleSidebar}
|
||||||
/>
|
variant="secondary"
|
||||||
|
title={intl.formatMessage({ id: "actions.sidebar.open" })}
|
||||||
|
>
|
||||||
|
<SidebarIcon />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<ListOperationButtons
|
|
||||||
onSelectAll={onSelectAll}
|
<div>
|
||||||
onSelectNone={onSelectNone}
|
<ButtonGroup>
|
||||||
otherOperations={operations}
|
{showEditFilter && (
|
||||||
itemsSelected={selectedIds.size > 0}
|
<ListFilter
|
||||||
onEdit={onEdit}
|
onFilterUpdate={setFilter}
|
||||||
onDelete={onDelete}
|
filter={filter}
|
||||||
/>
|
openFilterDialog={() => showEditFilter()}
|
||||||
<ListViewOptions
|
view={view}
|
||||||
displayMode={filter.displayMode}
|
withSidebar={!!onToggleSidebar}
|
||||||
displayModeOptions={filterOptions.displayModeOptions}
|
/>
|
||||||
onSetDisplayMode={setDisplayMode}
|
)}
|
||||||
zoomIndex={zoomable ? filter.zoomIndex : undefined}
|
<ListOperationButtons
|
||||||
onSetZoom={zoomable ? setZoom : undefined}
|
onSelectAll={onSelectAll}
|
||||||
/>
|
onSelectNone={onSelectNone}
|
||||||
|
otherOperations={operations}
|
||||||
|
itemsSelected={selectedIds.size > 0}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
<ListViewOptions
|
||||||
|
displayMode={filter.displayMode}
|
||||||
|
displayModeOptions={filterOptions.displayModeOptions}
|
||||||
|
onSetDisplayMode={setDisplayMode}
|
||||||
|
zoomIndex={zoomable ? filter.zoomIndex : undefined}
|
||||||
|
onSetZoom={zoomable ? setZoom : undefined}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ButtonGroup></ButtonGroup>
|
||||||
|
</div>
|
||||||
</ButtonToolbar>
|
</ButtonToolbar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import cloneDeep from "lodash-es/cloneDeep";
|
import cloneDeep from "lodash-es/cloneDeep";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Form } from "react-bootstrap";
|
import { Form } from "react-bootstrap";
|
||||||
import { BooleanCriterion } from "src/models/list-filter/criteria/criterion";
|
import {
|
||||||
import { FormattedMessage } from "react-intl";
|
BooleanCriterion,
|
||||||
|
CriterionOption,
|
||||||
|
} from "src/models/list-filter/criteria/criterion";
|
||||||
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { Option, SidebarListFilter } from "./SidebarListFilter";
|
||||||
|
|
||||||
interface IBooleanFilter {
|
interface IBooleanFilter {
|
||||||
criterion: BooleanCriterion;
|
criterion: BooleanCriterion;
|
||||||
|
|
@ -43,3 +48,86 @@ export const BooleanFilter: React.FC<IBooleanFilter> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ISidebarFilter {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
option: CriterionOption;
|
||||||
|
filter: ListFilterModel;
|
||||||
|
setFilter: (f: ListFilterModel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
|
||||||
|
title,
|
||||||
|
option,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const trueLabel = intl.formatMessage({
|
||||||
|
id: "true",
|
||||||
|
});
|
||||||
|
const falseLabel = intl.formatMessage({
|
||||||
|
id: "false",
|
||||||
|
});
|
||||||
|
|
||||||
|
const trueOption = useMemo(
|
||||||
|
() => ({
|
||||||
|
id: "true",
|
||||||
|
label: trueLabel,
|
||||||
|
}),
|
||||||
|
[trueLabel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const falseOption = useMemo(
|
||||||
|
() => ({
|
||||||
|
id: "false",
|
||||||
|
label: falseLabel,
|
||||||
|
}),
|
||||||
|
[falseLabel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const criteria = filter.criteriaFor(option.type) as BooleanCriterion[];
|
||||||
|
const criterion = criteria.length > 0 ? criteria[0] : null;
|
||||||
|
|
||||||
|
const selected: Option[] = useMemo(() => {
|
||||||
|
if (!criterion) return [];
|
||||||
|
|
||||||
|
if (criterion.value === "true") {
|
||||||
|
return [trueOption];
|
||||||
|
} else if (criterion.value === "false") {
|
||||||
|
return [falseOption];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [trueOption, falseOption, criterion]);
|
||||||
|
|
||||||
|
const options: Option[] = useMemo(() => {
|
||||||
|
return [trueOption, falseOption].filter((o) => !selected.includes(o));
|
||||||
|
}, [selected, trueOption, falseOption]);
|
||||||
|
|
||||||
|
function onSelect(item: Option) {
|
||||||
|
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
|
||||||
|
|
||||||
|
newCriterion.value = item.id;
|
||||||
|
|
||||||
|
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUnselect() {
|
||||||
|
setFilter(filter.removeCriterion(option.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarListFilter
|
||||||
|
title={title}
|
||||||
|
candidates={options}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onUnselect={onUnselect}
|
||||||
|
selected={selected}
|
||||||
|
singleValue
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Badge, Button } from "react-bootstrap";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { Icon } from "src/components/Shared/Icon";
|
import { Icon } from "src/components/Shared/Icon";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
|
||||||
interface IFilterButtonProps {
|
interface IFilterButtonProps {
|
||||||
filter: ListFilterModel;
|
filter: ListFilterModel;
|
||||||
|
|
@ -13,10 +14,16 @@ export const FilterButton: React.FC<IFilterButtonProps> = ({
|
||||||
filter,
|
filter,
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const count = useMemo(() => filter.count(), [filter]);
|
const count = useMemo(() => filter.count(), [filter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="secondary" className="filter-button" onClick={onClick}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="filter-button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={intl.formatMessage({ id: "search_filter.edit_filter" })}
|
||||||
|
>
|
||||||
<Icon icon={faFilter} />
|
<Icon icon={faFilter} />
|
||||||
{count ? (
|
{count ? (
|
||||||
<Badge pill variant="info">
|
<Badge pill variant="info">
|
||||||
|
|
|
||||||
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 { Form } from "react-bootstrap";
|
||||||
import { FilterSelect, SelectObject } from "src/components/Shared/Select";
|
import { FilterSelect, SelectObject } from "src/components/Shared/Select";
|
||||||
import { objectTitle } from "src/core/files";
|
import { objectTitle } from "src/core/files";
|
||||||
import { galleryTitle } from "src/core/galleries";
|
import { galleryTitle } from "src/core/galleries";
|
||||||
import { ModifierCriterion } from "src/models/list-filter/criteria/criterion";
|
import { ILoadResults, useCacheResults } from "src/hooks/data";
|
||||||
import { ILabeledId } from "src/models/list-filter/types";
|
import {
|
||||||
|
CriterionOption,
|
||||||
|
ModifierCriterion,
|
||||||
|
} from "src/models/list-filter/criteria/criterion";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import {
|
||||||
|
IHierarchicalLabelValue,
|
||||||
|
ILabeledId,
|
||||||
|
ILabeledValueListValue,
|
||||||
|
} from "src/models/list-filter/types";
|
||||||
|
import { Option } from "./SidebarListFilter";
|
||||||
|
import { CriterionModifier } from "src/core/generated-graphql";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
|
||||||
interface ILabeledIdFilterProps {
|
interface ILabeledIdFilterProps {
|
||||||
criterion: ModifierCriterion<ILabeledId[]>;
|
criterion: ModifierCriterion<ILabeledId[]>;
|
||||||
|
|
@ -63,3 +75,403 @@ export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ModifierValue = "any" | "none" | "any_of" | "only" | "include_subs";
|
||||||
|
|
||||||
|
export function getModifierCandidates(props: {
|
||||||
|
modifier: CriterionModifier;
|
||||||
|
defaultModifier: CriterionModifier;
|
||||||
|
hasSelected?: boolean;
|
||||||
|
hasExcluded?: boolean;
|
||||||
|
singleValue?: boolean;
|
||||||
|
hierarchical?: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
modifier,
|
||||||
|
defaultModifier,
|
||||||
|
hasSelected,
|
||||||
|
hasExcluded,
|
||||||
|
singleValue,
|
||||||
|
hierarchical,
|
||||||
|
} = props;
|
||||||
|
const ret: ModifierValue[] = [];
|
||||||
|
|
||||||
|
if (modifier === defaultModifier && !hasSelected && !hasExcluded) {
|
||||||
|
ret.push("any");
|
||||||
|
}
|
||||||
|
if (modifier === defaultModifier && !hasSelected && !hasExcluded) {
|
||||||
|
ret.push("none");
|
||||||
|
}
|
||||||
|
if (!singleValue && modifier === defaultModifier && hasSelected) {
|
||||||
|
ret.push("any_of");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
hierarchical &&
|
||||||
|
modifier === defaultModifier &&
|
||||||
|
(hasSelected || hasExcluded)
|
||||||
|
) {
|
||||||
|
ret.push("include_subs");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!singleValue &&
|
||||||
|
modifier === defaultModifier &&
|
||||||
|
hasSelected &&
|
||||||
|
!hasExcluded
|
||||||
|
) {
|
||||||
|
ret.push("only");
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modifierValueToModifier(key: ModifierValue): CriterionModifier {
|
||||||
|
switch (key) {
|
||||||
|
case "any":
|
||||||
|
return CriterionModifier.NotNull;
|
||||||
|
case "none":
|
||||||
|
return CriterionModifier.IsNull;
|
||||||
|
case "any_of":
|
||||||
|
return CriterionModifier.Includes;
|
||||||
|
case "only":
|
||||||
|
return CriterionModifier.Equals;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Invalid modifier value");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultModifier(singleValue: boolean) {
|
||||||
|
if (singleValue) {
|
||||||
|
return CriterionModifier.Includes;
|
||||||
|
}
|
||||||
|
return CriterionModifier.IncludesAll;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectionState(props: {
|
||||||
|
criterion: ModifierCriterion<ILabeledValueListValue>;
|
||||||
|
setCriterion: (c: ModifierCriterion<ILabeledValueListValue>) => void;
|
||||||
|
singleValue?: boolean;
|
||||||
|
hierarchical?: boolean;
|
||||||
|
includeSubMessageID?: string;
|
||||||
|
}) {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
criterion,
|
||||||
|
setCriterion,
|
||||||
|
singleValue = false,
|
||||||
|
hierarchical = false,
|
||||||
|
includeSubMessageID,
|
||||||
|
} = props;
|
||||||
|
const { modifier } = criterion;
|
||||||
|
|
||||||
|
const defaultModifier = getDefaultModifier(singleValue);
|
||||||
|
|
||||||
|
const selectedModifiers = useMemo(() => {
|
||||||
|
return {
|
||||||
|
any: modifier === CriterionModifier.NotNull,
|
||||||
|
none: modifier === CriterionModifier.IsNull,
|
||||||
|
any_of: !singleValue && modifier === CriterionModifier.Includes,
|
||||||
|
only: !singleValue && modifier === CriterionModifier.Equals,
|
||||||
|
include_subs:
|
||||||
|
hierarchical &&
|
||||||
|
modifier === defaultModifier &&
|
||||||
|
(criterion.value as IHierarchicalLabelValue).depth === -1,
|
||||||
|
};
|
||||||
|
}, [modifier, singleValue, criterion.value, defaultModifier, hierarchical]);
|
||||||
|
|
||||||
|
const selected = useMemo(() => {
|
||||||
|
const modifierValues: Option[] = Object.entries(selectedModifiers)
|
||||||
|
.filter((v) => v[1])
|
||||||
|
.map((v) => {
|
||||||
|
const messageID =
|
||||||
|
v[0] === "include_subs"
|
||||||
|
? includeSubMessageID
|
||||||
|
: `criterion_modifier_values.${v[0]}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: v[0],
|
||||||
|
label: `(${intl.formatMessage({
|
||||||
|
id: messageID,
|
||||||
|
})})`,
|
||||||
|
className: "modifier-object",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return modifierValues.concat(
|
||||||
|
criterion.value.items.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [intl, selectedModifiers, criterion.value.items, includeSubMessageID]);
|
||||||
|
|
||||||
|
const excluded = useMemo(() => {
|
||||||
|
return criterion.value.excluded.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
}));
|
||||||
|
}, [criterion.value.excluded]);
|
||||||
|
|
||||||
|
const includingOnly = modifier == CriterionModifier.Equals;
|
||||||
|
const excludingOnly =
|
||||||
|
modifier == CriterionModifier.Excludes ||
|
||||||
|
modifier == CriterionModifier.NotEquals;
|
||||||
|
|
||||||
|
const onSelect = useCallback(
|
||||||
|
(v: Option, exclude: boolean) => {
|
||||||
|
const newCriterion: ModifierCriterion<ILabeledValueListValue> =
|
||||||
|
criterion.clone();
|
||||||
|
|
||||||
|
if (v.className === "modifier-object") {
|
||||||
|
if (v.id === "include_subs") {
|
||||||
|
(newCriterion.value as IHierarchicalLabelValue).depth = -1;
|
||||||
|
setCriterion(newCriterion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
newCriterion.modifier = modifierValueToModifier(v.id as ModifierValue);
|
||||||
|
setCriterion(newCriterion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if only exclude is allowed, then add to excluded
|
||||||
|
if (excludingOnly) {
|
||||||
|
exclude = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = !exclude ? criterion.value.items : criterion.value.excluded;
|
||||||
|
const newItems = [...items, v];
|
||||||
|
|
||||||
|
if (!exclude) {
|
||||||
|
newCriterion.value.items = newItems;
|
||||||
|
} else {
|
||||||
|
newCriterion.value.excluded = newItems;
|
||||||
|
}
|
||||||
|
setCriterion(newCriterion);
|
||||||
|
},
|
||||||
|
[excludingOnly, criterion, setCriterion]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onUnselect = useCallback(
|
||||||
|
(v: Option, exclude: boolean) => {
|
||||||
|
const newCriterion = criterion.clone();
|
||||||
|
|
||||||
|
if (v.className === "modifier-object") {
|
||||||
|
if (v.id === "include_subs") {
|
||||||
|
newCriterion.value.depth = 0;
|
||||||
|
setCriterion(newCriterion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newCriterion.modifier = defaultModifier;
|
||||||
|
setCriterion(newCriterion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = !exclude ? criterion.value.items : criterion.value.excluded;
|
||||||
|
const newItems = items.filter((i) => i.id !== v.id);
|
||||||
|
|
||||||
|
if (!exclude) {
|
||||||
|
newCriterion.value.items = newItems;
|
||||||
|
} else {
|
||||||
|
newCriterion.value.excluded = newItems;
|
||||||
|
}
|
||||||
|
setCriterion(newCriterion);
|
||||||
|
},
|
||||||
|
[criterion, setCriterion, defaultModifier]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { selected, excluded, onSelect, onUnselect, includingOnly };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCriterion(
|
||||||
|
option: CriterionOption,
|
||||||
|
filter: ListFilterModel,
|
||||||
|
setFilter: (f: ListFilterModel) => void
|
||||||
|
) {
|
||||||
|
const criterion = useMemo(() => {
|
||||||
|
const ret = filter.criteria.find(
|
||||||
|
(c) => c.criterionOption.type === option.type
|
||||||
|
);
|
||||||
|
if (ret) return ret as ModifierCriterion<ILabeledValueListValue>;
|
||||||
|
|
||||||
|
const newCriterion = filter.makeCriterion(
|
||||||
|
option.type
|
||||||
|
) as ModifierCriterion<ILabeledValueListValue>;
|
||||||
|
return newCriterion;
|
||||||
|
}, [filter, option]);
|
||||||
|
|
||||||
|
const setCriterion = useCallback(
|
||||||
|
(c: ModifierCriterion<ILabeledValueListValue>) => {
|
||||||
|
const newCriteria = filter.criteria.filter(
|
||||||
|
(cc) => cc.criterionOption.type !== option.type
|
||||||
|
);
|
||||||
|
|
||||||
|
if (c.isValid()) newCriteria.push(c);
|
||||||
|
|
||||||
|
setFilter(filter.setCriteria(newCriteria));
|
||||||
|
},
|
||||||
|
[option.type, setFilter, filter]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { criterion, setCriterion };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQueryState(
|
||||||
|
useQuery: (q: string, skip: boolean) => ILoadResults<ILabeledId[]>,
|
||||||
|
skip: boolean
|
||||||
|
) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const { results: queryResults } = useCacheResults(useQuery(query, skip));
|
||||||
|
|
||||||
|
return { query, setQuery, queryResults };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCandidates(props: {
|
||||||
|
criterion: ModifierCriterion<ILabeledValueListValue>;
|
||||||
|
queryResults: ILabeledId[] | undefined;
|
||||||
|
selected: Option[];
|
||||||
|
excluded: Option[];
|
||||||
|
hierarchical?: boolean;
|
||||||
|
singleValue?: boolean;
|
||||||
|
includeSubMessageID?: string;
|
||||||
|
}) {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
criterion,
|
||||||
|
queryResults,
|
||||||
|
selected,
|
||||||
|
excluded,
|
||||||
|
hierarchical = false,
|
||||||
|
singleValue = false,
|
||||||
|
includeSubMessageID,
|
||||||
|
} = props;
|
||||||
|
const { modifier } = criterion;
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!queryResults ||
|
||||||
|
modifier === CriterionModifier.IsNull ||
|
||||||
|
modifier === CriterionModifier.NotNull
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryResults.filter(
|
||||||
|
(p) =>
|
||||||
|
selected.find((s) => s.id === p.id) === undefined &&
|
||||||
|
excluded.find((s) => s.id === p.id) === undefined
|
||||||
|
);
|
||||||
|
}, [queryResults, modifier, selected, excluded]);
|
||||||
|
|
||||||
|
const defaultModifier = getDefaultModifier(singleValue);
|
||||||
|
|
||||||
|
const candidates = useMemo(() => {
|
||||||
|
const hierarchicalCandidate =
|
||||||
|
hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1;
|
||||||
|
|
||||||
|
const modifierCandidates: Option[] = getModifierCandidates({
|
||||||
|
modifier,
|
||||||
|
defaultModifier,
|
||||||
|
hasSelected: selected.length > 0,
|
||||||
|
hasExcluded: excluded.length > 0,
|
||||||
|
singleValue,
|
||||||
|
hierarchical: hierarchicalCandidate,
|
||||||
|
}).map((v) => {
|
||||||
|
const messageID =
|
||||||
|
v === "include_subs"
|
||||||
|
? includeSubMessageID
|
||||||
|
: `criterion_modifier_values.${v}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: v,
|
||||||
|
label: `(${intl.formatMessage({
|
||||||
|
id: messageID,
|
||||||
|
})})`,
|
||||||
|
className: "modifier-object",
|
||||||
|
canExclude: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return modifierCandidates.concat(
|
||||||
|
(results ?? []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
defaultModifier,
|
||||||
|
intl,
|
||||||
|
modifier,
|
||||||
|
singleValue,
|
||||||
|
results,
|
||||||
|
selected,
|
||||||
|
excluded,
|
||||||
|
criterion.value,
|
||||||
|
hierarchical,
|
||||||
|
includeSubMessageID,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLabeledIdFilterState(props: {
|
||||||
|
option: CriterionOption;
|
||||||
|
filter: ListFilterModel;
|
||||||
|
setFilter: (f: ListFilterModel) => void;
|
||||||
|
useQuery: (q: string, skip: boolean) => ILoadResults<ILabeledId[]>;
|
||||||
|
singleValue?: boolean;
|
||||||
|
hierarchical?: boolean;
|
||||||
|
includeSubMessageID?: string;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
option,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
useQuery,
|
||||||
|
singleValue = false,
|
||||||
|
hierarchical = false,
|
||||||
|
includeSubMessageID,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// defer querying until the user opens the filter
|
||||||
|
const [skip, setSkip] = useState(true);
|
||||||
|
|
||||||
|
const { query, setQuery, queryResults } = useQueryState(useQuery, skip);
|
||||||
|
|
||||||
|
const { criterion, setCriterion } = useCriterion(option, filter, setFilter);
|
||||||
|
|
||||||
|
const { selected, excluded, onSelect, onUnselect, includingOnly } =
|
||||||
|
useSelectionState({
|
||||||
|
criterion,
|
||||||
|
setCriterion,
|
||||||
|
singleValue,
|
||||||
|
hierarchical,
|
||||||
|
includeSubMessageID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = useCandidates({
|
||||||
|
criterion,
|
||||||
|
queryResults,
|
||||||
|
selected,
|
||||||
|
excluded,
|
||||||
|
hierarchical,
|
||||||
|
singleValue,
|
||||||
|
includeSubMessageID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onOpen = useCallback(() => {
|
||||||
|
setSkip(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidates,
|
||||||
|
onSelect,
|
||||||
|
onUnselect,
|
||||||
|
selected,
|
||||||
|
excluded,
|
||||||
|
canExclude: !includingOnly,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
onOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,27 @@
|
||||||
import React, { useMemo } from "react";
|
import React, { ReactNode, useMemo } from "react";
|
||||||
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
||||||
import { useFindPerformersQuery } from "src/core/generated-graphql";
|
import { useFindPerformersForSelectQuery } from "src/core/generated-graphql";
|
||||||
import { ObjectsFilter } from "./SelectableFilter";
|
import { ObjectsFilter } from "./SelectableFilter";
|
||||||
import { sortByRelevance } from "src/utils/query";
|
import { sortByRelevance } from "src/utils/query";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||||
|
import { useLabeledIdFilterState } from "./LabeledIdFilter";
|
||||||
|
import { SidebarListFilter } from "./SidebarListFilter";
|
||||||
|
|
||||||
interface IPerformersFilter {
|
interface IPerformersFilter {
|
||||||
criterion: PerformersCriterion;
|
criterion: PerformersCriterion;
|
||||||
setCriterion: (c: PerformersCriterion) => void;
|
setCriterion: (c: PerformersCriterion) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePerformerQuery(query: string) {
|
function usePerformerQuery(query: string, skip?: boolean) {
|
||||||
const { data, loading } = useFindPerformersQuery({
|
const { data, loading } = useFindPerformersForSelectQuery({
|
||||||
variables: {
|
variables: {
|
||||||
filter: {
|
filter: {
|
||||||
q: query,
|
q: query,
|
||||||
per_page: 200,
|
per_page: 200,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
skip,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
|
|
@ -49,4 +54,20 @@ const PerformersFilter: React.FC<IPerformersFilter> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SidebarPerformersFilter: React.FC<{
|
||||||
|
title?: ReactNode;
|
||||||
|
option: CriterionOption;
|
||||||
|
filter: ListFilterModel;
|
||||||
|
setFilter: (f: ListFilterModel) => void;
|
||||||
|
}> = ({ title, option, filter, setFilter }) => {
|
||||||
|
const state = useLabeledIdFilterState({
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
option,
|
||||||
|
useQuery: usePerformerQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <SidebarListFilter {...state} title={title} />;
|
||||||
|
};
|
||||||
|
|
||||||
export default PerformersFilter;
|
export default PerformersFilter;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { CriterionModifier } from "../../../core/generated-graphql";
|
import { CriterionModifier } from "../../../core/generated-graphql";
|
||||||
import { INumberValue } from "../../../models/list-filter/types";
|
import { INumberValue } from "../../../models/list-filter/types";
|
||||||
import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion";
|
import {
|
||||||
|
CriterionOption,
|
||||||
|
ModifierCriterion,
|
||||||
|
} from "../../../models/list-filter/criteria/criterion";
|
||||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||||
|
import { RatingStars } from "src/components/Shared/Rating/RatingStars";
|
||||||
|
import {
|
||||||
|
defaultRatingStarPrecision,
|
||||||
|
defaultRatingSystemOptions,
|
||||||
|
} from "src/utils/rating";
|
||||||
|
import { ConfigurationContext } from "src/hooks/Config";
|
||||||
|
import { RatingCriterion } from "src/models/list-filter/criteria/rating";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { Option, SidebarListFilter } from "./SidebarListFilter";
|
||||||
|
|
||||||
interface IRatingFilterProps {
|
interface IRatingFilterProps {
|
||||||
criterion: ModifierCriterion<INumberValue>;
|
criterion: ModifierCriterion<INumberValue>;
|
||||||
|
|
@ -59,3 +71,136 @@ export const RatingFilter: React.FC<IRatingFilterProps> = ({
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ISidebarFilter {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
option: CriterionOption;
|
||||||
|
filter: ListFilterModel;
|
||||||
|
setFilter: (f: ListFilterModel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const any = "any";
|
||||||
|
const none = "none";
|
||||||
|
|
||||||
|
export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
|
||||||
|
title,
|
||||||
|
option,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const anyLabel = `(${intl.formatMessage({
|
||||||
|
id: "criterion_modifier_values.any",
|
||||||
|
})})`;
|
||||||
|
const noneLabel = `(${intl.formatMessage({
|
||||||
|
id: "criterion_modifier_values.none",
|
||||||
|
})})`;
|
||||||
|
|
||||||
|
const anyOption = useMemo(
|
||||||
|
() => ({
|
||||||
|
id: "any",
|
||||||
|
label: anyLabel,
|
||||||
|
className: "modifier-object",
|
||||||
|
}),
|
||||||
|
[anyLabel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const noneOption = useMemo(
|
||||||
|
() => ({
|
||||||
|
id: "none",
|
||||||
|
label: noneLabel,
|
||||||
|
className: "modifier-object",
|
||||||
|
}),
|
||||||
|
[noneLabel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { configuration: config } = React.useContext(ConfigurationContext);
|
||||||
|
const ratingSystemOptions =
|
||||||
|
config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;
|
||||||
|
|
||||||
|
const options: Option[] = useMemo(() => {
|
||||||
|
return [anyOption, noneOption];
|
||||||
|
}, [anyOption, noneOption]);
|
||||||
|
|
||||||
|
const criteria = filter.criteriaFor(option.type) as RatingCriterion[];
|
||||||
|
const criterion = criteria.length > 0 ? criteria[0] : null;
|
||||||
|
|
||||||
|
const selected: Option[] = useMemo(() => {
|
||||||
|
if (!criterion) return [];
|
||||||
|
|
||||||
|
if (criterion.modifier === CriterionModifier.NotNull) {
|
||||||
|
return [anyOption];
|
||||||
|
} else if (criterion.modifier === CriterionModifier.IsNull) {
|
||||||
|
return [noneOption];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [anyOption, noneOption, criterion]);
|
||||||
|
|
||||||
|
const ratingValue = useMemo(() => {
|
||||||
|
if (!criterion || criterion.modifier !== CriterionModifier.GreaterThan) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return criterion.value.value ?? null;
|
||||||
|
}, [criterion]);
|
||||||
|
|
||||||
|
function onSelect(item: Option) {
|
||||||
|
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
|
||||||
|
|
||||||
|
if (item.id === any) {
|
||||||
|
newCriterion.modifier = CriterionModifier.NotNull;
|
||||||
|
// newCriterion.value
|
||||||
|
} else if (item.id === none) {
|
||||||
|
newCriterion.modifier = CriterionModifier.IsNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUnselect() {
|
||||||
|
setFilter(filter.removeCriterion(option.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRatingValueChange(value: number | null) {
|
||||||
|
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
|
||||||
|
if (value === null) {
|
||||||
|
setFilter(filter.removeCriterion(option.type));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
newCriterion.modifier = CriterionModifier.GreaterThan;
|
||||||
|
newCriterion.value.value = value - 1;
|
||||||
|
|
||||||
|
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratingStars = (
|
||||||
|
<div className="no-icon-margin">
|
||||||
|
<RatingStars
|
||||||
|
value={ratingValue}
|
||||||
|
onSetRating={onRatingValueChange}
|
||||||
|
precision={
|
||||||
|
ratingSystemOptions.starPrecision ?? defaultRatingStarPrecision
|
||||||
|
}
|
||||||
|
orMore
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarListFilter
|
||||||
|
title={title}
|
||||||
|
candidates={options}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onUnselect={onUnselect}
|
||||||
|
selected={selected}
|
||||||
|
singleValue
|
||||||
|
preCandidates={ratingValue === null ? ratingStars : undefined}
|
||||||
|
preSelected={ratingValue !== null ? ratingStars : undefined}
|
||||||
|
/>
|
||||||
|
<div></div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
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 React, { ReactNode, useMemo } from "react";
|
||||||
import { useFindStudiosQuery } from "src/core/generated-graphql";
|
import { useFindStudiosForSelectQuery } from "src/core/generated-graphql";
|
||||||
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
||||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||||
import { sortByRelevance } from "src/utils/query";
|
import { sortByRelevance } from "src/utils/query";
|
||||||
|
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { useLabeledIdFilterState } from "./LabeledIdFilter";
|
||||||
|
import { SidebarListFilter } from "./SidebarListFilter";
|
||||||
|
|
||||||
interface IStudiosFilter {
|
interface IStudiosFilter {
|
||||||
criterion: StudiosCriterion;
|
criterion: StudiosCriterion;
|
||||||
setCriterion: (c: StudiosCriterion) => void;
|
setCriterion: (c: StudiosCriterion) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useStudioQuery(query: string) {
|
function useStudioQuery(query: string, skip?: boolean) {
|
||||||
const { data, loading } = useFindStudiosQuery({
|
const { data, loading } = useFindStudiosForSelectQuery({
|
||||||
variables: {
|
variables: {
|
||||||
filter: {
|
filter: {
|
||||||
q: query,
|
q: query,
|
||||||
per_page: 200,
|
per_page: 200,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
skip,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
|
|
@ -50,4 +55,23 @@ const StudiosFilter: React.FC<IStudiosFilter> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SidebarStudiosFilter: React.FC<{
|
||||||
|
title?: ReactNode;
|
||||||
|
option: CriterionOption;
|
||||||
|
filter: ListFilterModel;
|
||||||
|
setFilter: (f: ListFilterModel) => void;
|
||||||
|
}> = ({ title, option, filter, setFilter }) => {
|
||||||
|
const state = useLabeledIdFilterState({
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
option,
|
||||||
|
useQuery: useStudioQuery,
|
||||||
|
singleValue: true,
|
||||||
|
hierarchical: true,
|
||||||
|
includeSubMessageID: "subsidiary_studios",
|
||||||
|
});
|
||||||
|
|
||||||
|
return <SidebarListFilter {...state} title={title} />;
|
||||||
|
};
|
||||||
|
|
||||||
export default StudiosFilter;
|
export default StudiosFilter;
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,27 @@
|
||||||
import React, { useMemo } from "react";
|
import React, { ReactNode, useMemo } from "react";
|
||||||
import { useFindTagsQuery } from "src/core/generated-graphql";
|
import { useFindTagsForSelectQuery } from "src/core/generated-graphql";
|
||||||
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
||||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||||
import { sortByRelevance } from "src/utils/query";
|
import { sortByRelevance } from "src/utils/query";
|
||||||
|
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { useLabeledIdFilterState } from "./LabeledIdFilter";
|
||||||
|
import { SidebarListFilter } from "./SidebarListFilter";
|
||||||
|
|
||||||
interface ITagsFilter {
|
interface ITagsFilter {
|
||||||
criterion: StudiosCriterion;
|
criterion: StudiosCriterion;
|
||||||
setCriterion: (c: StudiosCriterion) => void;
|
setCriterion: (c: StudiosCriterion) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTagQuery(query: string) {
|
function useTagQuery(query: string, skip?: boolean) {
|
||||||
const { data, loading } = useFindTagsQuery({
|
const { data, loading } = useFindTagsForSelectQuery({
|
||||||
variables: {
|
variables: {
|
||||||
filter: {
|
filter: {
|
||||||
q: query,
|
q: query,
|
||||||
per_page: 200,
|
per_page: 200,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
skip,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
|
|
@ -46,4 +51,22 @@ const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SidebarTagsFilter: React.FC<{
|
||||||
|
title?: ReactNode;
|
||||||
|
option: CriterionOption;
|
||||||
|
filter: ListFilterModel;
|
||||||
|
setFilter: (f: ListFilterModel) => void;
|
||||||
|
}> = ({ title, option, filter, setFilter }) => {
|
||||||
|
const state = useLabeledIdFilterState({
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
option,
|
||||||
|
useQuery: useTagQuery,
|
||||||
|
hierarchical: true,
|
||||||
|
includeSubMessageID: "sub_tags",
|
||||||
|
});
|
||||||
|
|
||||||
|
return <SidebarListFilter {...state} title={title} />;
|
||||||
|
};
|
||||||
|
|
||||||
export default TagsFilter;
|
export default TagsFilter;
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,13 @@ export function useDebouncedSearchInput(
|
||||||
export const SearchTermInput: React.FC<{
|
export const SearchTermInput: React.FC<{
|
||||||
filter: ListFilterModel;
|
filter: ListFilterModel;
|
||||||
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
||||||
}> = ({ filter, onFilterUpdate }) => {
|
focus?: ReturnType<typeof useFocus>;
|
||||||
|
}> = ({ filter, onFilterUpdate, focus: providedFocus }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [localInput, setLocalInput] = useState(filter.searchTerm);
|
const [localInput, setLocalInput] = useState(filter.searchTerm);
|
||||||
|
|
||||||
const focus = useFocus();
|
const localFocus = useFocus();
|
||||||
|
const focus = providedFocus ?? localFocus;
|
||||||
const [, setQueryFocus] = focus;
|
const [, setQueryFocus] = focus;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -233,6 +235,7 @@ interface IListFilterProps {
|
||||||
filter: ListFilterModel;
|
filter: ListFilterModel;
|
||||||
view?: View;
|
view?: View;
|
||||||
openFilterDialog: () => void;
|
openFilterDialog: () => void;
|
||||||
|
withSidebar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ListFilter: React.FC<IListFilterProps> = ({
|
export const ListFilter: React.FC<IListFilterProps> = ({
|
||||||
|
|
@ -240,6 +243,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||||
filter,
|
filter,
|
||||||
openFilterDialog,
|
openFilterDialog,
|
||||||
view,
|
view,
|
||||||
|
withSidebar,
|
||||||
}) => {
|
}) => {
|
||||||
const filterOptions = filter.options;
|
const filterOptions = filter.options;
|
||||||
|
|
||||||
|
|
@ -313,31 +317,38 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2 d-flex">
|
{!withSidebar && (
|
||||||
<SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
|
<div className="d-flex">
|
||||||
</div>
|
<SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ButtonGroup className="mr-2 mb-2">
|
{!withSidebar && (
|
||||||
<SavedFilterDropdown
|
<ButtonGroup className="mr-2">
|
||||||
filter={filter}
|
<SavedFilterDropdown
|
||||||
onSetFilter={(f) => {
|
filter={filter}
|
||||||
onFilterUpdate(f);
|
onSetFilter={(f) => {
|
||||||
}}
|
onFilterUpdate(f);
|
||||||
view={view}
|
}}
|
||||||
/>
|
view={view}
|
||||||
<OverlayTrigger
|
/>
|
||||||
placement="top"
|
<OverlayTrigger
|
||||||
overlay={
|
placement="top"
|
||||||
<Tooltip id="filter-tooltip">
|
overlay={
|
||||||
<FormattedMessage id="search_filter.name" />
|
<Tooltip id="filter-tooltip">
|
||||||
</Tooltip>
|
<FormattedMessage id="search_filter.name" />
|
||||||
}
|
</Tooltip>
|
||||||
>
|
}
|
||||||
<FilterButton onClick={() => openFilterDialog()} filter={filter} />
|
>
|
||||||
</OverlayTrigger>
|
<FilterButton
|
||||||
</ButtonGroup>
|
onClick={() => openFilterDialog()}
|
||||||
|
filter={filter}
|
||||||
|
/>
|
||||||
|
</OverlayTrigger>
|
||||||
|
</ButtonGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dropdown as={ButtonGroup} className="mr-2 mb-2">
|
<Dropdown as={ButtonGroup} className="mr-2">
|
||||||
<InputGroup.Prepend>
|
<InputGroup.Prepend>
|
||||||
<Dropdown.Toggle variant="secondary">
|
<Dropdown.Toggle variant="secondary">
|
||||||
{currentSortBy
|
{currentSortBy
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
|
||||||
if (!children) return null;
|
if (!children) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown>
|
<Dropdown as={ButtonGroup}>
|
||||||
<Dropdown.Toggle variant="secondary" id="more-menu">
|
<Dropdown.Toggle variant="secondary" id="more-menu">
|
||||||
<Icon icon={faEllipsisH} />
|
<Icon icon={faEllipsisH} />
|
||||||
</Dropdown.Toggle>
|
</Dropdown.Toggle>
|
||||||
|
|
@ -116,7 +116,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
||||||
|
|
||||||
if (buttons.length > 0) {
|
if (buttons.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ButtonGroup className="ml-2 mb-2">
|
<ButtonGroup className="ml-2">
|
||||||
{buttons.map((button) => {
|
{buttons.map((button) => {
|
||||||
return (
|
return (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
|
|
@ -206,7 +206,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
||||||
<>
|
<>
|
||||||
{maybeRenderButtons()}
|
{maybeRenderButtons()}
|
||||||
|
|
||||||
<div className="mx-2 mb-2">{renderMore()}</div>
|
<ButtonGroup className="ml-2">{renderMore()}</ButtonGroup>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonGroup className="mb-2">
|
<ButtonGroup className="ml-2">
|
||||||
{displayModeOptions.map((option) => (
|
{displayModeOptions.map((option) => (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
key={option}
|
key={option}
|
||||||
|
|
@ -140,7 +140,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
||||||
function maybeRenderZoom() {
|
function maybeRenderZoom() {
|
||||||
if (onSetZoom && displayMode === DisplayMode.Grid) {
|
if (onSetZoom && displayMode === DisplayMode.Grid) {
|
||||||
return (
|
return (
|
||||||
<div className="ml-2 mb-2 d-none d-sm-inline-flex">
|
<div className="ml-2 d-none d-sm-inline-flex">
|
||||||
<Form.Control
|
<Form.Control
|
||||||
className="zoom-slider ml-1"
|
className="zoom-slider ml-1"
|
||||||
type="range"
|
type="range"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import React, { HTMLAttributes, useState } from "react";
|
import React, { HTMLAttributes, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
Modal,
|
Modal,
|
||||||
|
|
@ -17,12 +18,171 @@ import {
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import { useToast } from "src/hooks/Toast";
|
import { useToast } from "src/hooks/Toast";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { SavedFilterDataFragment } from "src/core/generated-graphql";
|
import {
|
||||||
|
FilterMode,
|
||||||
|
SavedFilterDataFragment,
|
||||||
|
} from "src/core/generated-graphql";
|
||||||
import { View } from "./views";
|
import { View } from "./views";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Icon } from "../Shared/Icon";
|
import { Icon } from "../Shared/Icon";
|
||||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||||
import { faBookmark, faSave, faTimes } from "@fortawesome/free-solid-svg-icons";
|
import { faBookmark, faSave, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { AlertModal } from "../Shared/Alert";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { TruncatedInlineText } from "../Shared/TruncatedText";
|
||||||
|
|
||||||
|
const ExistingSavedFilterList: React.FC<{
|
||||||
|
name: string;
|
||||||
|
setName: (name: string) => void;
|
||||||
|
existing: { name: string; id: string }[];
|
||||||
|
}> = ({ name, setName, existing }) => {
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!name) return existing;
|
||||||
|
|
||||||
|
return existing.filter((f) =>
|
||||||
|
f.name.toLowerCase().includes(name.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [existing, name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="existing-filter-list">
|
||||||
|
{filtered.map((f) => (
|
||||||
|
<li key={f.id}>
|
||||||
|
<Button
|
||||||
|
className="minimal"
|
||||||
|
variant="link"
|
||||||
|
onClick={() => setName(f.name)}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SaveFilterDialog: React.FC<{
|
||||||
|
mode: FilterMode;
|
||||||
|
onClose: (name?: string, id?: string) => void;
|
||||||
|
}> = ({ mode, onClose }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [filterName, setFilterName] = useState("");
|
||||||
|
|
||||||
|
const { data } = useFindSavedFilters(mode);
|
||||||
|
|
||||||
|
const overwritingFilter = useMemo(() => {
|
||||||
|
const savedFilters = data?.findSavedFilters ?? [];
|
||||||
|
return savedFilters.find(
|
||||||
|
(f) => f.name.toLowerCase() === filterName.toLowerCase()
|
||||||
|
);
|
||||||
|
}, [data?.findSavedFilters, filterName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show className="save-filter-dialog">
|
||||||
|
<Modal.Body>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>
|
||||||
|
<FormattedMessage id="filter_name" />
|
||||||
|
</Form.Label>
|
||||||
|
<FormControl
|
||||||
|
className="bg-secondary text-white border-secondary"
|
||||||
|
placeholder={`${intl.formatMessage({ id: "filter_name" })}…`}
|
||||||
|
value={filterName}
|
||||||
|
onChange={(e) => setFilterName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<ExistingSavedFilterList
|
||||||
|
name={filterName}
|
||||||
|
setName={setFilterName}
|
||||||
|
existing={data?.findSavedFilters ?? []}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!overwritingFilter && (
|
||||||
|
<span className="saved-filter-overwrite-warning">
|
||||||
|
<FormattedMessage
|
||||||
|
id="dialogs.overwrite_filter_warning"
|
||||||
|
values={{
|
||||||
|
entityName: overwritingFilter.name,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={() => onClose()}>
|
||||||
|
{intl.formatMessage({ id: "actions.cancel" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => onClose(filterName, overwritingFilter?.id)}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "actions.save" })}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteAlert: React.FC<{
|
||||||
|
deletingFilter: SavedFilterDataFragment | undefined;
|
||||||
|
onClose: (confirm?: boolean) => void;
|
||||||
|
}> = ({ deletingFilter, onClose }) => {
|
||||||
|
if (!deletingFilter) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show>
|
||||||
|
<Modal.Body>
|
||||||
|
<FormattedMessage
|
||||||
|
id="dialogs.delete_confirm"
|
||||||
|
values={{
|
||||||
|
entityName: deletingFilter.name,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="danger" onClick={() => onClose(true)}>
|
||||||
|
<FormattedMessage id="actions.delete" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => onClose()}>
|
||||||
|
<FormattedMessage id="actions.cancel" />
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OverwriteAlert: React.FC<{
|
||||||
|
overwritingFilter: SavedFilterDataFragment | undefined;
|
||||||
|
onClose: (confirm?: boolean) => void;
|
||||||
|
}> = ({ overwritingFilter, onClose }) => {
|
||||||
|
if (!overwritingFilter) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show>
|
||||||
|
<Modal.Body>
|
||||||
|
<FormattedMessage
|
||||||
|
id="dialogs.overwrite_filter_confirm"
|
||||||
|
values={{
|
||||||
|
entityName: overwritingFilter.name,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="primary" onClick={() => onClose(true)}>
|
||||||
|
<FormattedMessage id="actions.overwrite" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => onClose()}>
|
||||||
|
<FormattedMessage id="actions.cancel" />
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface ISavedFilterListProps {
|
interface ISavedFilterListProps {
|
||||||
filter: ListFilterModel;
|
filter: ListFilterModel;
|
||||||
|
|
@ -49,7 +209,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||||
SavedFilterDataFragment | undefined
|
SavedFilterDataFragment | undefined
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const [saveFilter] = useSaveFilter();
|
const saveFilter = useSaveFilter();
|
||||||
const [destroyFilter] = useSavedFilterDestroy();
|
const [destroyFilter] = useSavedFilterDestroy();
|
||||||
const [saveUISetting] = useConfigureUISetting();
|
const [saveUISetting] = useConfigureUISetting();
|
||||||
|
|
||||||
|
|
@ -60,18 +220,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await saveFilter({
|
await saveFilter(filterCopy, name, id);
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
id,
|
|
||||||
mode: filter.mode,
|
|
||||||
name,
|
|
||||||
find_filter: filterCopy.makeFindFilter(),
|
|
||||||
object_filter: filterCopy.makeSavedFilter(),
|
|
||||||
ui_options: filterCopy.makeSavedUIOptions(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Toast.success(
|
Toast.success(
|
||||||
intl.formatMessage(
|
intl.formatMessage(
|
||||||
|
|
@ -212,74 +361,6 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function maybeRenderDeleteAlert() {
|
|
||||||
if (!deletingFilter) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal show>
|
|
||||||
<Modal.Body>
|
|
||||||
<FormattedMessage
|
|
||||||
id="dialogs.delete_confirm"
|
|
||||||
values={{
|
|
||||||
entityName: deletingFilter.name,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onClick={() => onDeleteFilter(deletingFilter)}
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "actions.delete" })}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setDeletingFilter(undefined)}
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "actions.cancel" })}
|
|
||||||
</Button>
|
|
||||||
</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeRenderOverwriteAlert() {
|
|
||||||
if (!overwritingFilter) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal show>
|
|
||||||
<Modal.Body>
|
|
||||||
<FormattedMessage
|
|
||||||
id="dialogs.overwrite_filter_confirm"
|
|
||||||
values={{
|
|
||||||
entityName: overwritingFilter.name,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() =>
|
|
||||||
onSaveFilter(overwritingFilter.name, overwritingFilter.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "actions.overwrite" })}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setOverwritingFilter(undefined)}
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "actions.cancel" })}
|
|
||||||
</Button>
|
|
||||||
</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSavedFilters() {
|
function renderSavedFilters() {
|
||||||
if (error) return <h6 className="text-center">{error.message}</h6>;
|
if (error) return <h6 className="text-center">{error.message}</h6>;
|
||||||
|
|
||||||
|
|
@ -327,8 +408,24 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{maybeRenderDeleteAlert()}
|
<DeleteAlert
|
||||||
{maybeRenderOverwriteAlert()}
|
deletingFilter={deletingFilter}
|
||||||
|
onClose={(confirm) => {
|
||||||
|
if (confirm) {
|
||||||
|
onDeleteFilter(deletingFilter!);
|
||||||
|
}
|
||||||
|
setDeletingFilter(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<OverwriteAlert
|
||||||
|
overwritingFilter={overwritingFilter}
|
||||||
|
onClose={(confirm) => {
|
||||||
|
if (confirm) {
|
||||||
|
onSaveFilter(overwritingFilter!.name, overwritingFilter!.id);
|
||||||
|
}
|
||||||
|
setOverwritingFilter(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<FormControl
|
<FormControl
|
||||||
className="bg-secondary text-white border-secondary"
|
className="bg-secondary text-white border-secondary"
|
||||||
|
|
@ -365,6 +462,319 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ISavedFilterItem {
|
||||||
|
item: SavedFilterDataFragment;
|
||||||
|
onClick: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SavedFilterItem: React.FC<ISavedFilterItem> = ({
|
||||||
|
item,
|
||||||
|
onClick,
|
||||||
|
onDelete,
|
||||||
|
selected = false,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="saved-filter-item">
|
||||||
|
<a onClick={onClick}>
|
||||||
|
<div className="label-group">
|
||||||
|
<TruncatedInlineText
|
||||||
|
className={cx("no-icon-margin", { selected })}
|
||||||
|
text={item.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
className="delete-button"
|
||||||
|
variant="minimal"
|
||||||
|
size="sm"
|
||||||
|
title={intl.formatMessage({ id: "actions.delete" })}
|
||||||
|
onClick={(e) => {
|
||||||
|
onDelete();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon fixedWidth icon={faTimes} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SavedFilters: React.FC<{
|
||||||
|
error?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
savedFilters: SavedFilterDataFragment[];
|
||||||
|
onFilterClicked: (f: SavedFilterDataFragment) => void;
|
||||||
|
onDeleteClicked: (f: SavedFilterDataFragment) => void;
|
||||||
|
currentFilterID?: string;
|
||||||
|
}> = ({
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
savedFilters,
|
||||||
|
onFilterClicked,
|
||||||
|
onDeleteClicked,
|
||||||
|
currentFilterID,
|
||||||
|
}) => {
|
||||||
|
if (error) return <h6 className="text-center">{error}</h6>;
|
||||||
|
|
||||||
|
if (loading || saving) {
|
||||||
|
return (
|
||||||
|
<div className="loading">
|
||||||
|
<LoadingIndicator message="" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="saved-filter-list">
|
||||||
|
{savedFilters.map((f) => (
|
||||||
|
<SavedFilterItem
|
||||||
|
key={f.name}
|
||||||
|
item={f}
|
||||||
|
onClick={() => onFilterClicked(f)}
|
||||||
|
onDelete={() => onDeleteClicked(f)}
|
||||||
|
selected={currentFilterID === f.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SidebarSavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||||
|
filter,
|
||||||
|
onSetFilter,
|
||||||
|
view,
|
||||||
|
}) => {
|
||||||
|
const Toast = useToast();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [currentSavedFilter, setCurrentSavedFilter] = useState<{
|
||||||
|
id: string;
|
||||||
|
set: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { data, error, loading, refetch } = useFindSavedFilters(filter.mode);
|
||||||
|
|
||||||
|
const [filterName, setFilterName] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deletingFilter, setDeletingFilter] = useState<
|
||||||
|
SavedFilterDataFragment | undefined
|
||||||
|
>();
|
||||||
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||||
|
const [settingDefault, setSettingDefault] = useState(false);
|
||||||
|
|
||||||
|
const saveFilter = useSaveFilter();
|
||||||
|
const [destroyFilter] = useSavedFilterDestroy();
|
||||||
|
const [saveUISetting] = useConfigureUISetting();
|
||||||
|
|
||||||
|
const filteredFilters = useMemo(() => {
|
||||||
|
const savedFilters = data?.findSavedFilters ?? [];
|
||||||
|
if (!filterName) return savedFilters;
|
||||||
|
|
||||||
|
return savedFilters.filter(
|
||||||
|
(f) =>
|
||||||
|
!filterName || f.name.toLowerCase().includes(filterName.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [data?.findSavedFilters, filterName]);
|
||||||
|
|
||||||
|
// handle when filter is changed to de-select the current filter
|
||||||
|
useEffect(() => {
|
||||||
|
// HACK - first change will be from setting the filter
|
||||||
|
// second change is likely from somewhere else
|
||||||
|
setCurrentSavedFilter((v) => {
|
||||||
|
if (!v) return v;
|
||||||
|
|
||||||
|
if (v.set) {
|
||||||
|
setCurrentSavedFilter({ id: v.id, set: false });
|
||||||
|
} else {
|
||||||
|
setCurrentSavedFilter(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
async function onSaveFilter(name: string, id?: string) {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await saveFilter(filter, name, id);
|
||||||
|
|
||||||
|
Toast.success(
|
||||||
|
intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "toast.saved_entity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entity: intl.formatMessage({ id: "filter" }).toLocaleLowerCase(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setFilterName("");
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
refetch();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeleteFilter(f: SavedFilterDataFragment) {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
await destroyFilter({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: f.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(
|
||||||
|
intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "toast.delete_past_tense",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: 1,
|
||||||
|
singularEntity: intl.formatMessage({ id: "filter" }),
|
||||||
|
pluralEntity: intl.formatMessage({ id: "filters" }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setDeletingFilter(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSetDefaultFilter() {
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterCopy = filter.clone();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
await saveUISetting({
|
||||||
|
variables: {
|
||||||
|
key: `defaultFilters.${view.toString()}`,
|
||||||
|
value: {
|
||||||
|
mode: filter.mode,
|
||||||
|
find_filter: filterCopy.makeFindFilter(),
|
||||||
|
object_filter: filterCopy.makeSavedFilter(),
|
||||||
|
ui_options: filterCopy.makeSavedUIOptions(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: "toast.default_filter_set",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setSettingDefault(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterClicked(f: SavedFilterDataFragment) {
|
||||||
|
const newFilter = filter.clone();
|
||||||
|
|
||||||
|
newFilter.currentPage = 1;
|
||||||
|
// #1795 - reset search term if not present in saved filter
|
||||||
|
newFilter.searchTerm = "";
|
||||||
|
newFilter.configureFromSavedFilter(f);
|
||||||
|
// #1507 - reset random seed when loaded
|
||||||
|
newFilter.randomSeed = -1;
|
||||||
|
|
||||||
|
setCurrentSavedFilter({ id: f.id, set: true });
|
||||||
|
onSetFilter(newFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar-saved-filter-list-container">
|
||||||
|
<DeleteAlert
|
||||||
|
deletingFilter={deletingFilter}
|
||||||
|
onClose={(confirm) => {
|
||||||
|
if (confirm) {
|
||||||
|
onDeleteFilter(deletingFilter!);
|
||||||
|
}
|
||||||
|
setDeletingFilter(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showSaveDialog && (
|
||||||
|
<SaveFilterDialog
|
||||||
|
mode={filter.mode}
|
||||||
|
onClose={(name, id) => {
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
if (name) {
|
||||||
|
onSaveFilter(name, id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AlertModal
|
||||||
|
show={!!settingDefault}
|
||||||
|
text={<FormattedMessage id="dialogs.set_default_filter_confirm" />}
|
||||||
|
confirmVariant="primary"
|
||||||
|
onConfirm={() => onSetDefaultFilter()}
|
||||||
|
onCancel={() => setSettingDefault(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<Button
|
||||||
|
className="minimal save-filter-button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSaveDialog(true)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<FormattedMessage id="actions.save_filter" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="minimal set-as-default-button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSettingDefault(true)}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="actions.set_as_default" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
className="bg-secondary text-white border-secondary saved-filter-search-input"
|
||||||
|
placeholder={`${intl.formatMessage({ id: "filter_name" })}…`}
|
||||||
|
value={filterName}
|
||||||
|
onChange={(e) => setFilterName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<SavedFilters
|
||||||
|
error={error?.message}
|
||||||
|
loading={loading}
|
||||||
|
saving={saving}
|
||||||
|
savedFilters={filteredFilters}
|
||||||
|
onFilterClicked={filterClicked}
|
||||||
|
onDeleteClicked={setDeletingFilter}
|
||||||
|
currentFilterID={currentSavedFilter?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
|
export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
|
||||||
const SavedFilterDropdownRef = React.forwardRef<
|
const SavedFilterDropdownRef = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
|
|
@ -377,7 +787,7 @@ export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
|
||||||
SavedFilterDropdownRef.displayName = "SavedFilterDropdown";
|
SavedFilterDropdownRef.displayName = "SavedFilterDropdown";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown>
|
<Dropdown as={ButtonGroup}>
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
placement="top"
|
placement="top"
|
||||||
overlay={
|
overlay={
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ input[type="range"].zoom-slider {
|
||||||
max-width: 60px;
|
max-width: 60px;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
|
||||||
|
// width is set to 100% by default, but in a flex container, it gets a very small width
|
||||||
|
width: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.query-text-field-group {
|
.query-text-field-group {
|
||||||
|
|
@ -103,7 +106,7 @@ input[type="range"].zoom-slider {
|
||||||
|
|
||||||
.saved-filter-list {
|
.saved-filter-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0.25rem;
|
||||||
max-height: 230px;
|
max-height: 230px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
|
@ -113,11 +116,18 @@ input[type="range"].zoom-slider {
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
color: $text-color;
|
||||||
display: inline;
|
display: inline;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
padding-right: 0.25rem;
|
padding-right: 0.25rem;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
background-color: #8a9ba826;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-group {
|
.btn-group {
|
||||||
|
|
@ -134,6 +144,87 @@ input[type="range"].zoom-slider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-saved-filter-list-container .toolbar {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-saved-filter-list-container {
|
||||||
|
.label-group {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-filter-item {
|
||||||
|
cursor: pointer;
|
||||||
|
height: 2em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 2em;
|
||||||
|
justify-content: space-between;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: rgba(138, 155, 168, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-object-label,
|
||||||
|
.excluded-object-label {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-group {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-filter-search-input {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-filter-dialog {
|
||||||
|
.existing-filter-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-filter-button {
|
||||||
|
color: $text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-filter-overwrite-warning {
|
||||||
|
color: $danger;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-filter-dialog .rating-stars {
|
.edit-filter-dialog .rating-stars {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
margin-left: 0.25em;
|
margin-left: 0.25em;
|
||||||
|
|
@ -267,13 +358,16 @@ input[type="range"].zoom-slider {
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-button {
|
.filter-button {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.fa-icon {
|
.fa-icon {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
|
font-size: 60%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -3px;
|
right: 0;
|
||||||
|
|
||||||
// button group has a z-index of 1
|
// button group has a z-index of 1
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
@ -390,6 +484,133 @@ input[type="range"].zoom-slider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// used to align list text without icons to those that do
|
||||||
|
.sidebar .no-icon-margin {
|
||||||
|
// icon width is 17.5px + 5.6px margin each side
|
||||||
|
margin-left: 28.7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-list-filter .clearable-input-group {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-list-filter ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
// to prevent unnecessary vertical scrollbar
|
||||||
|
padding-bottom: 0.15rem;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
|
||||||
|
.modifier-object {
|
||||||
|
font-style: italic;
|
||||||
|
|
||||||
|
.selected-object-label,
|
||||||
|
.unselected-object-label {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unselected-object {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-object,
|
||||||
|
.excluded-object,
|
||||||
|
.unselected-object {
|
||||||
|
cursor: pointer;
|
||||||
|
height: 2em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 2em;
|
||||||
|
justify-content: space-between;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: rgba(138, 155, 168, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-object-label,
|
||||||
|
.excluded-object-label {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.include-button {
|
||||||
|
color: $success;
|
||||||
|
|
||||||
|
&.single-value {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-icon {
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-button {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
|
||||||
|
.exclude-button-text {
|
||||||
|
color: $danger;
|
||||||
|
display: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .exclude-button-text,
|
||||||
|
&:focus .exclude-button-text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-count {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-object:hover,
|
||||||
|
.selected-object a:focus-visible,
|
||||||
|
.excluded-object:hover,
|
||||||
|
.excluded-object a:focus-visible {
|
||||||
|
.include-button,
|
||||||
|
.exclude-icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-object,
|
||||||
|
.unselected-object {
|
||||||
|
.label-group {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-list-filter > .extra {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-list-filter .extra {
|
||||||
|
min-height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
.tilted {
|
.tilted {
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
@ -582,6 +803,21 @@ input[type="range"].zoom-slider {
|
||||||
|
|
||||||
.filtered-list-toolbar {
|
.filtered-list-toolbar {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
& > .btn-group {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
row-gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-pane .filtered-list-toolbar {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
|
& > .btn-group {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-term-input {
|
.search-term-input {
|
||||||
|
|
@ -613,3 +849,30 @@ input[type="range"].zoom-slider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-list-container .sidebar-pane {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
.sidebar-search-container {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-term-input {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
|
||||||
|
.clearable-text-field {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-down(xs) {
|
||||||
|
.sidebar .search-term-input {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useCallback, useContext, useEffect, useMemo } from "react";
|
import React, { useCallback, useContext, useEffect, useMemo } from "react";
|
||||||
import cloneDeep from "lodash-es/cloneDeep";
|
import cloneDeep from "lodash-es/cloneDeep";
|
||||||
import { useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
|
@ -31,6 +31,23 @@ import { IListFilterOperation } from "../List/ListOperationButtons";
|
||||||
import { FilteredListToolbar } from "../List/FilteredListToolbar";
|
import { FilteredListToolbar } from "../List/FilteredListToolbar";
|
||||||
import { useFilteredItemList } from "../List/ItemList";
|
import { useFilteredItemList } from "../List/ItemList";
|
||||||
import { FilterTags } from "../List/FilterTags";
|
import { FilterTags } from "../List/FilterTags";
|
||||||
|
import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar";
|
||||||
|
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
|
||||||
|
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
|
||||||
|
import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers";
|
||||||
|
import { StudiosCriterionOption } from "src/models/list-filter/criteria/studios";
|
||||||
|
import { TagsCriterionOption } from "src/models/list-filter/criteria/tags";
|
||||||
|
import { SidebarTagsFilter } from "../List/Filters/TagsFilter";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { RatingCriterionOption } from "src/models/list-filter/criteria/rating";
|
||||||
|
import { SidebarRatingFilter } from "../List/Filters/RatingFilter";
|
||||||
|
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
|
||||||
|
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
|
||||||
|
import {
|
||||||
|
FilteredSidebarHeader,
|
||||||
|
useFilteredSidebarKeybinds,
|
||||||
|
} from "../List/Filters/FilterSidebar";
|
||||||
|
import { PatchContainerComponent } from "src/patch";
|
||||||
|
|
||||||
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||||
const duration = result?.data?.findScenes?.duration;
|
const duration = result?.data?.findScenes?.duration;
|
||||||
|
|
@ -184,6 +201,70 @@ const SceneList: React.FC<{
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ScenesFilterSidebarSections = PatchContainerComponent(
|
||||||
|
"FilteredSceneList.SidebarSections"
|
||||||
|
);
|
||||||
|
|
||||||
|
const SidebarContent: React.FC<{
|
||||||
|
filter: ListFilterModel;
|
||||||
|
setFilter: (filter: ListFilterModel) => void;
|
||||||
|
view?: View;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
showEditFilter: (editingCriterion?: string) => void;
|
||||||
|
}> = ({ filter, setFilter, view, showEditFilter, sidebarOpen, onClose }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilteredSidebarHeader
|
||||||
|
sidebarOpen={sidebarOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
showEditFilter={showEditFilter}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
view={view}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScenesFilterSidebarSections>
|
||||||
|
<SidebarStudiosFilter
|
||||||
|
title={<FormattedMessage id="studios" />}
|
||||||
|
data-type={StudiosCriterionOption.type}
|
||||||
|
option={StudiosCriterionOption}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
/>
|
||||||
|
<SidebarPerformersFilter
|
||||||
|
title={<FormattedMessage id="performers" />}
|
||||||
|
data-type={PerformersCriterionOption.type}
|
||||||
|
option={PerformersCriterionOption}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
/>
|
||||||
|
<SidebarTagsFilter
|
||||||
|
title={<FormattedMessage id="tags" />}
|
||||||
|
data-type={TagsCriterionOption.type}
|
||||||
|
option={TagsCriterionOption}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
/>
|
||||||
|
<SidebarRatingFilter
|
||||||
|
title={<FormattedMessage id="rating" />}
|
||||||
|
data-type={RatingCriterionOption.type}
|
||||||
|
option={RatingCriterionOption}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
/>
|
||||||
|
<SidebarBooleanFilter
|
||||||
|
title={<FormattedMessage id="organized" />}
|
||||||
|
data-type={OrganizedCriterionOption.type}
|
||||||
|
option={OrganizedCriterionOption}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
/>
|
||||||
|
</ScenesFilterSidebarSections>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface IFilteredScenes {
|
interface IFilteredScenes {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
defaultSort?: string;
|
defaultSort?: string;
|
||||||
|
|
@ -199,6 +280,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||||
const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
|
const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
|
||||||
|
|
||||||
// States
|
// States
|
||||||
|
const {
|
||||||
|
showSidebar,
|
||||||
|
setShowSidebar,
|
||||||
|
loading: sidebarStateLoading,
|
||||||
|
} = useSidebarState(view);
|
||||||
|
|
||||||
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
|
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
|
||||||
useFilteredItemList({
|
useFilteredItemList({
|
||||||
filterStateProps: {
|
filterStateProps: {
|
||||||
|
|
@ -237,6 +324,10 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
useAddKeybinds(filter, totalCount);
|
useAddKeybinds(filter, totalCount);
|
||||||
|
useFilteredSidebarKeybinds({
|
||||||
|
showSidebar,
|
||||||
|
setShowSidebar,
|
||||||
|
});
|
||||||
|
|
||||||
const onCloseEditDelete = useCloseEditDelete({
|
const onCloseEditDelete = useCloseEditDelete({
|
||||||
closeModal,
|
closeModal,
|
||||||
|
|
@ -340,62 +431,81 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||||
];
|
];
|
||||||
|
|
||||||
// render
|
// render
|
||||||
if (filterLoading) return null;
|
if (filterLoading || sidebarStateLoading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TaggerContext>
|
<TaggerContext>
|
||||||
<div className="item-list-container">
|
<div
|
||||||
|
className={cx("item-list-container scene-list", {
|
||||||
|
"hide-sidebar": !showSidebar,
|
||||||
|
})}
|
||||||
|
>
|
||||||
{modal}
|
{modal}
|
||||||
|
|
||||||
<FilteredListToolbar
|
<SidebarPane hideSidebar={!showSidebar}>
|
||||||
filter={filter}
|
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
|
||||||
setFilter={setFilter}
|
<SidebarContent
|
||||||
showEditFilter={showEditFilter}
|
filter={filter}
|
||||||
view={view}
|
setFilter={setFilter}
|
||||||
listSelect={listSelect}
|
showEditFilter={showEditFilter}
|
||||||
onEdit={() =>
|
view={view}
|
||||||
showModal(
|
sidebarOpen={showSidebar}
|
||||||
<EditScenesDialog
|
onClose={() => setShowSidebar(false)}
|
||||||
selected={selectedItems}
|
/>
|
||||||
onClose={onCloseEditDelete}
|
</Sidebar>
|
||||||
/>
|
<div>
|
||||||
)
|
<FilteredListToolbar
|
||||||
}
|
filter={filter}
|
||||||
onDelete={() => {
|
setFilter={setFilter}
|
||||||
showModal(
|
showEditFilter={showEditFilter}
|
||||||
<DeleteScenesDialog
|
view={view}
|
||||||
selected={selectedItems}
|
listSelect={listSelect}
|
||||||
onClose={onCloseEditDelete}
|
onEdit={() =>
|
||||||
/>
|
showModal(
|
||||||
);
|
<EditScenesDialog
|
||||||
}}
|
selected={selectedItems}
|
||||||
operations={otherOperations}
|
onClose={onCloseEditDelete}
|
||||||
zoomable
|
/>
|
||||||
/>
|
)
|
||||||
|
}
|
||||||
|
onDelete={() => {
|
||||||
|
showModal(
|
||||||
|
<DeleteScenesDialog
|
||||||
|
selected={selectedItems}
|
||||||
|
onClose={onCloseEditDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
operations={otherOperations}
|
||||||
|
onToggleSidebar={() => setShowSidebar((v) => !v)}
|
||||||
|
zoomable
|
||||||
|
/>
|
||||||
|
|
||||||
<FilterTags
|
<FilterTags
|
||||||
criteria={filter.criteria}
|
criteria={filter.criteria}
|
||||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||||
onRemoveCriterion={removeCriterion}
|
onRemoveCriterion={removeCriterion}
|
||||||
onRemoveAll={() => clearAllCriteria()}
|
onRemoveAll={() => clearAllCriteria()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PagedList
|
<PagedList
|
||||||
result={result}
|
result={result}
|
||||||
cachedResult={cachedResult}
|
cachedResult={cachedResult}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
onChangePage={setPage}
|
onChangePage={setPage}
|
||||||
metadataByline={metadataByline}
|
metadataByline={metadataByline}
|
||||||
>
|
>
|
||||||
<SceneList
|
<SceneList
|
||||||
filter={effectiveFilter}
|
filter={effectiveFilter}
|
||||||
scenes={items}
|
scenes={items}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onSelectChange={onSelectChange}
|
onSelectChange={onSelectChange}
|
||||||
fromGroupId={fromGroupId}
|
fromGroupId={fromGroupId}
|
||||||
/>
|
/>
|
||||||
</PagedList>
|
</PagedList>
|
||||||
|
</div>
|
||||||
|
</SidebarPane>
|
||||||
</div>
|
</div>
|
||||||
</TaggerContext>
|
</TaggerContext>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -902,6 +902,40 @@ input[type="range"].blue-slider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scene-list .filtered-list-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 1rem;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-list.hide-sidebar .sidebar-toggle-button {
|
||||||
|
transition-delay: 0.1s;
|
||||||
|
transition-duration: 0;
|
||||||
|
transition-property: opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-list:not(.hide-sidebar) .sidebar-toggle-button {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.scene-wall,
|
.scene-wall,
|
||||||
.marker-wall {
|
.marker-wall {
|
||||||
.wall-item {
|
.wall-item {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
export interface IAlertModalProps {
|
export interface IAlertModalProps {
|
||||||
text: JSX.Element | string;
|
text: JSX.Element | string;
|
||||||
|
confirmVariant?: string;
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
confirmButtonText?: string;
|
confirmButtonText?: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
|
@ -13,6 +14,7 @@ export interface IAlertModalProps {
|
||||||
export const AlertModal: React.FC<IAlertModalProps> = ({
|
export const AlertModal: React.FC<IAlertModalProps> = ({
|
||||||
text,
|
text,
|
||||||
show,
|
show,
|
||||||
|
confirmVariant = "danger",
|
||||||
confirmButtonText,
|
confirmButtonText,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
|
@ -21,7 +23,7 @@ export const AlertModal: React.FC<IAlertModalProps> = ({
|
||||||
<Modal show={show}>
|
<Modal show={show}>
|
||||||
<Modal.Body>{text}</Modal.Body>
|
<Modal.Body>{text}</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="danger" onClick={() => onConfirm()}>
|
<Button variant={confirmVariant} onClick={() => onConfirm()}>
|
||||||
{confirmButtonText ?? <FormattedMessage id="actions.confirm" />}
|
{confirmButtonText ?? <FormattedMessage id="actions.confirm" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => onCancel()}>
|
<Button variant="secondary" onClick={() => onCancel()}>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ export const ClearableInput: React.FC<IClearableInput> = ({
|
||||||
setQueryFocus();
|
setQueryFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
queryRef.current?.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx("clearable-input-group", className)}>
|
<div className={cx("clearable-input-group", className)}>
|
||||||
<FormControl
|
<FormControl
|
||||||
|
|
@ -46,6 +52,7 @@ export const ClearableInput: React.FC<IClearableInput> = ({
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onInput={onChangeQuery}
|
onInput={onChangeQuery}
|
||||||
|
onKeyDown={onInputKeyDown}
|
||||||
className="clearable-text-field"
|
className="clearable-text-field"
|
||||||
/>
|
/>
|
||||||
{queryClearShowing && (
|
{queryClearShowing && (
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ import {
|
||||||
faChevronUp,
|
faChevronUp,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Collapse } from "react-bootstrap";
|
import { Button, Collapse, CollapseProps } from "react-bootstrap";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
text: React.ReactNode;
|
text: React.ReactNode;
|
||||||
|
collapseProps?: Partial<CollapseProps>;
|
||||||
|
outsideCollapse?: React.ReactNode;
|
||||||
|
onOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
|
export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
|
||||||
|
|
@ -17,16 +20,27 @@ export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
|
||||||
) => {
|
) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
function toggleOpen() {
|
||||||
|
const nv = !open;
|
||||||
|
setOpen(nv);
|
||||||
|
if (props.onOpen && nv) {
|
||||||
|
props.onOpen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={props.className}>
|
<div className={props.className}>
|
||||||
<Button
|
<div className="collapse-header">
|
||||||
onClick={() => setOpen(!open)}
|
<Button
|
||||||
className="minimal collapse-button"
|
onClick={() => toggleOpen()}
|
||||||
>
|
className="minimal collapse-button"
|
||||||
<Icon icon={open ? faChevronDown : faChevronRight} fixedWidth />
|
>
|
||||||
<span>{props.text}</span>
|
<Icon icon={open ? faChevronDown : faChevronRight} fixedWidth />
|
||||||
</Button>
|
<span>{props.text}</span>
|
||||||
<Collapse in={open}>
|
</Button>
|
||||||
|
</div>
|
||||||
|
{props.outsideCollapse}
|
||||||
|
<Collapse in={open} {...props.collapseProps}>
|
||||||
<div>{props.children}</div>
|
<div>{props.children}</div>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface IRatingStarsProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
precision: RatingStarPrecision;
|
precision: RatingStarPrecision;
|
||||||
valueRequired?: boolean;
|
valueRequired?: boolean;
|
||||||
|
orMore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RatingStars = PatchComponent(
|
export const RatingStars = PatchComponent(
|
||||||
|
|
@ -199,6 +200,8 @@ export const RatingStars = PatchComponent(
|
||||||
return `star-fill-${w}`;
|
return `star-fill-${w}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const suffix = props.orMore ? "+" : "";
|
||||||
|
|
||||||
const renderRatingButton = (thisStar: number) => {
|
const renderRatingButton = (thisStar: number) => {
|
||||||
const ratingFraction = getCurrentSelectedRating();
|
const ratingFraction = getCurrentSelectedRating();
|
||||||
|
|
||||||
|
|
@ -237,6 +240,7 @@ export const RatingStars = PatchComponent(
|
||||||
return (
|
return (
|
||||||
<span className="star-rating-number">
|
<span className="star-rating-number">
|
||||||
{ratingFraction.rating + ratingFraction.fraction}
|
{ratingFraction.rating + ratingFraction.fraction}
|
||||||
|
{suffix}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
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>
|
</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 > & {
|
.file-info-panel a > & {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.inline {
|
||||||
|
display: inline;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.RatingStars {
|
.RatingStars {
|
||||||
|
|
@ -728,3 +734,193 @@ button.btn.favorite-button {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$sidebar-width: 250px;
|
||||||
|
|
||||||
|
.sidebar-pane {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
// TODO - use different colours for sidebar and toolbar
|
||||||
|
background-color: $body-bg;
|
||||||
|
border-right: 1px solid $secondary;
|
||||||
|
flex: $sidebar-width;
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 15px;
|
||||||
|
transition: margin-left 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: fixed;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
top: 0;
|
||||||
|
width: $sidebar-width;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hide-sidebar .sidebar {
|
||||||
|
margin-left: -$sidebar-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
> :nth-child(2) {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hide-sidebar {
|
||||||
|
> :nth-child(2) {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xl) {
|
||||||
|
transition: margin-left 0.1s;
|
||||||
|
|
||||||
|
&:not(.hide-sidebar) {
|
||||||
|
> :nth-child(2) {
|
||||||
|
margin-left: calc($sidebar-width - 15px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@include media-breakpoint-down(xs) {
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hide-sidebar .sidebar {
|
||||||
|
margin-left: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hide-sidebar > :nth-child(2) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@include media-breakpoint-down(xs) {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
margin-bottom: $navbar-height;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toolbar {
|
||||||
|
// TODO - use different colours for sidebar and toolbar
|
||||||
|
background-color: $body-bg;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 101;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-down(xs) {
|
||||||
|
.sidebar-toolbar {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
border-bottom: 1px solid $secondary;
|
||||||
|
|
||||||
|
.collapse-header {
|
||||||
|
// background-color: $secondary;
|
||||||
|
|
||||||
|
padding: 0.25rem;
|
||||||
|
|
||||||
|
.collapse-button {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse,
|
||||||
|
// include collapsing to allow for the transition
|
||||||
|
.collapsing {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section:first-child .collapse-header {
|
||||||
|
border-top: 1px solid $secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sticky-header-height: calc(50px + 3.3rem);
|
||||||
|
|
||||||
|
// special case for sidebar in details view
|
||||||
|
.detail-body {
|
||||||
|
.sidebar {
|
||||||
|
// required for sticky to work
|
||||||
|
align-self: flex-start;
|
||||||
|
|
||||||
|
// take a further 15px padding to match the detail body
|
||||||
|
height: calc(100vh - $sticky-header-height - 15px);
|
||||||
|
margin-top: -15px;
|
||||||
|
max-height: calc(100vh - $sticky-header-height - 15px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-left: 0;
|
||||||
|
position: sticky;
|
||||||
|
|
||||||
|
// sticky detail header is 50px + 3.3rem
|
||||||
|
top: calc(50px + 3.3rem);
|
||||||
|
|
||||||
|
.sidebar-toolbar {
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-pane.hide-sidebar .sidebar {
|
||||||
|
left: -$sidebar-width;
|
||||||
|
margin-left: calc(-15px - $sidebar-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// on smaller viewports we want the sidebar to overlap content
|
||||||
|
@include media-breakpoint-down(lg) {
|
||||||
|
.sidebar-pane:not(.hide-sidebar) .sidebar {
|
||||||
|
margin-right: -$sidebar-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-pane > :nth-child(2) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@include media-breakpoint-down(xs) {
|
||||||
|
.sidebar {
|
||||||
|
flex: 100% 0 0;
|
||||||
|
height: calc(100vh - 4rem);
|
||||||
|
max-height: calc(100vh - 4rem);
|
||||||
|
padding-top: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-pane:not(.hide-sidebar) .sidebar {
|
||||||
|
margin-right: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-pane.hide-sidebar .sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@include media-breakpoint-up(xl) {
|
||||||
|
.sidebar-pane:not(.hide-sidebar) {
|
||||||
|
> :nth-child(2) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-pane.hide-sidebar {
|
||||||
|
> :nth-child(2) {
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2061,8 +2061,8 @@ export const useTagsMerge = () =>
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useSaveFilter = () =>
|
export const useSaveFilter = () => {
|
||||||
GQL.useSaveFilterMutation({
|
const [saveFilterMutation] = GQL.useSaveFilterMutation({
|
||||||
update(cache, result) {
|
update(cache, result) {
|
||||||
if (!result.data?.saveFilter) return;
|
if (!result.data?.saveFilter) return;
|
||||||
|
|
||||||
|
|
@ -2070,6 +2070,26 @@ export const useSaveFilter = () =>
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function saveFilter(filter: ListFilterModel, name: string, id?: string) {
|
||||||
|
const filterCopy = filter.clone();
|
||||||
|
|
||||||
|
return saveFilterMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id,
|
||||||
|
mode: filter.mode,
|
||||||
|
name,
|
||||||
|
find_filter: filterCopy.makeFindFilter(),
|
||||||
|
object_filter: filterCopy.makeSavedFilter(),
|
||||||
|
ui_options: filterCopy.makeSavedUIOptions(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveFilter;
|
||||||
|
};
|
||||||
|
|
||||||
export const useSavedFilterDestroy = () =>
|
export const useSavedFilterDestroy = () =>
|
||||||
GQL.useDestroySavedFilterMutation({
|
GQL.useDestroySavedFilterMutation({
|
||||||
update(cache, result, { variables }) {
|
update(cache, result, { variables }) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import localForage from "localforage";
|
import localForage from "localforage";
|
||||||
import isEqual from "lodash-es/isEqual";
|
import isEqual from "lodash-es/isEqual";
|
||||||
import React, { Dispatch, SetStateAction, useEffect } from "react";
|
import React, { Dispatch, SetStateAction, useEffect } from "react";
|
||||||
|
import { View } from "src/components/List/views";
|
||||||
import { ConfigImageLightboxInput } from "src/core/generated-graphql";
|
import { ConfigImageLightboxInput } from "src/core/generated-graphql";
|
||||||
|
|
||||||
interface IInterfaceQueryConfig {
|
interface IInterfaceQueryConfig {
|
||||||
|
|
@ -9,11 +10,17 @@ interface IInterfaceQueryConfig {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IViewConfig {
|
||||||
|
showSidebar?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type IQueryConfig = Record<string, IInterfaceQueryConfig>;
|
type IQueryConfig = Record<string, IInterfaceQueryConfig>;
|
||||||
|
|
||||||
interface IInterfaceConfig {
|
interface IInterfaceConfig {
|
||||||
queryConfig: IQueryConfig;
|
queryConfig: IQueryConfig;
|
||||||
imageLightbox: ConfigImageLightboxInput;
|
imageLightbox: ConfigImageLightboxInput;
|
||||||
|
// Partial is required because using View makes the key mandatory
|
||||||
|
viewConfig: Partial<Record<View, IViewConfig>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IChangelogConfig {
|
export interface IChangelogConfig {
|
||||||
|
|
|
||||||
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/theme";
|
||||||
@import "styles/range";
|
@import "styles/range";
|
||||||
@import "styles/scrollbars";
|
@import "styles/scrollbars";
|
||||||
|
|
@ -258,6 +264,10 @@ dd {
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.item-list-header {
|
.item-list-header {
|
||||||
align-content: center;
|
align-content: center;
|
||||||
// border-bottom: solid 2px #192127;
|
// border-bottom: solid 2px #192127;
|
||||||
|
|
@ -270,9 +280,10 @@ dd {
|
||||||
.item-list-container {
|
.item-list-container {
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
// this breaks sticky sidebar - need to work out why this is here
|
||||||
overflow-x: hidden;
|
// @media (max-width: 576px) {
|
||||||
}
|
// overflow-x: hidden;
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,10 @@
|
||||||
"set_image": "Set image…",
|
"set_image": "Set image…",
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"show_configuration": "Show Configuration",
|
"show_configuration": "Show Configuration",
|
||||||
|
"sidebar": {
|
||||||
|
"close": "Close sidebar",
|
||||||
|
"open": "Open sidebar"
|
||||||
|
},
|
||||||
"skip": "Skip",
|
"skip": "Skip",
|
||||||
"split": "Split",
|
"split": "Split",
|
||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
|
|
@ -932,7 +936,7 @@
|
||||||
"destination": "Destination",
|
"destination": "Destination",
|
||||||
"source": "Source"
|
"source": "Source"
|
||||||
},
|
},
|
||||||
"overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?",
|
"overwrite_filter_warning": "Saved filter \"{entityName}\" will be overwritten.",
|
||||||
"performers_found": "{count} performers found",
|
"performers_found": "{count} performers found",
|
||||||
"reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}",
|
"reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}",
|
||||||
"reassign_files": {
|
"reassign_files": {
|
||||||
|
|
@ -982,6 +986,7 @@
|
||||||
"scrape_entity_title": "{entity_type} Scrape Results",
|
"scrape_entity_title": "{entity_type} Scrape Results",
|
||||||
"scrape_results_existing": "Existing",
|
"scrape_results_existing": "Existing",
|
||||||
"scrape_results_scraped": "Scraped",
|
"scrape_results_scraped": "Scraped",
|
||||||
|
"set_default_filter_confirm": "Are you sure you want to set this filter as the default?",
|
||||||
"set_image_url_title": "Image URL",
|
"set_image_url_title": "Image URL",
|
||||||
"unsaved_changes": "Unsaved changes. Are you sure you want to leave?"
|
"unsaved_changes": "Unsaved changes. Are you sure you want to leave?"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,19 @@ export class ListFilterModel {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public criteriaFor(type: CriterionType) {
|
||||||
|
return this.criteria.filter((c) => c.criterionOption.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public replaceCriteria(type: CriterionType, newCriteria: Criterion[]) {
|
||||||
|
const criteria = [
|
||||||
|
...this.criteria.filter((c) => c.criterionOption.type !== type),
|
||||||
|
...newCriteria,
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.setCriteria(criteria);
|
||||||
|
}
|
||||||
|
|
||||||
public clearCriteria() {
|
public clearCriteria() {
|
||||||
const ret = this.clone();
|
const ret = this.clone();
|
||||||
ret.criteria = [];
|
ret.criteria = [];
|
||||||
|
|
@ -470,6 +483,12 @@ export class ListFilterModel {
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setCriteria(criteria: Criterion[]) {
|
||||||
|
const ret = this.clone();
|
||||||
|
ret.criteria = criteria;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
public removeCriterion(type: CriterionType) {
|
public removeCriterion(type: CriterionType) {
|
||||||
const ret = this.clone();
|
const ret = this.clone();
|
||||||
const c = ret.criteria.find((cc) => cc.criterionOption.type === type);
|
const c = ret.criteria.find((cc) => cc.criterionOption.type === type);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
const useFocus = () => {
|
const useFocus = () => {
|
||||||
const htmlElRef = useRef<HTMLInputElement | null>(null);
|
const htmlElRef = useRef<HTMLInputElement | null>(null);
|
||||||
const setFocus = () => {
|
const setFocus = useCallback(() => {
|
||||||
const currentEl = htmlElRef.current;
|
const currentEl = htmlElRef.current;
|
||||||
if (currentEl) {
|
if (currentEl) {
|
||||||
currentEl.focus();
|
currentEl.focus();
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
return [htmlElRef, setFocus] as const;
|
return [htmlElRef, setFocus] as const;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,39 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const isMobile = () =>
|
const isMobile = () =>
|
||||||
window.matchMedia("only screen and (max-width: 576px)").matches;
|
window.matchMedia("only screen and (max-width: 576px)").matches;
|
||||||
|
|
||||||
const isTouch = () => window.matchMedia("(pointer: coarse)").matches;
|
const isTouch = () => window.matchMedia("(pointer: coarse)").matches;
|
||||||
|
|
||||||
|
function matchesMediaQuery(query: string) {
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// from: https://dev.to/salimzade/handle-media-query-in-react-with-hooks-3cp3
|
||||||
|
export const useMediaQuery = (query: string): boolean => {
|
||||||
|
const [matches, setMatches] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
setMatches(media.matches);
|
||||||
|
|
||||||
|
// Define the listener as a separate function to avoid recreating it on each render
|
||||||
|
const listener = () => setMatches(media.matches);
|
||||||
|
|
||||||
|
// Use 'change' instead of 'resize' for better performance
|
||||||
|
media.addEventListener("change", listener);
|
||||||
|
|
||||||
|
// Cleanup function to remove the event listener
|
||||||
|
return () => media.removeEventListener("change", listener);
|
||||||
|
}, [query]); // Only recreate the listener when 'matches' or 'query' changes
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
};
|
||||||
|
|
||||||
const ScreenUtils = {
|
const ScreenUtils = {
|
||||||
isMobile,
|
isMobile,
|
||||||
isTouch,
|
isTouch,
|
||||||
|
matchesMediaQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ScreenUtils;
|
export default ScreenUtils;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue