diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx index 7464c25ac..71aa89a50 100644 --- a/ui/v2.5/src/components/List/FilteredListToolbar.tsx +++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx @@ -8,9 +8,11 @@ import { IListFilterOperation, ListOperationButtons, } from "./ListOperationButtons"; -import { ButtonToolbar } from "react-bootstrap"; +import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap"; import { View } from "./views"; import { IListSelect, useFilterOperations } from "./util"; +import { SidebarIcon } from "../Shared/Sidebar"; +import { useIntl } from "react-intl"; export interface IItemListOperation { text: string; @@ -41,6 +43,7 @@ export interface IFilteredListToolbar { onDelete?: () => void; operations?: IListFilterOperation[]; zoomable?: boolean; + onToggleSidebar?: () => void; } export const FilteredListToolbar: React.FC = ({ @@ -53,7 +56,9 @@ export const FilteredListToolbar: React.FC = ({ onDelete, operations, zoomable = false, + onToggleSidebar, }) => { + const intl = useIntl(); const filterOptions = filter.options; const { setDisplayMode, setZoom } = useFilterOperations({ filter, @@ -63,29 +68,52 @@ export const FilteredListToolbar: React.FC = ({ return ( - {showEditFilter && ( - showEditFilter()} - view={view} - /> + {onToggleSidebar && ( +
+ + + +
)} - 0} - onEdit={onEdit} - onDelete={onDelete} - /> - + +
+ + {showEditFilter && ( + showEditFilter()} + view={view} + withSidebar={!!onToggleSidebar} + /> + )} + 0} + onEdit={onEdit} + onDelete={onDelete} + /> + + +
+
+ +
); }; diff --git a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx index e9e2da084..18df1b9f1 100644 --- a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx @@ -1,8 +1,13 @@ import cloneDeep from "lodash-es/cloneDeep"; -import React from "react"; +import React, { useMemo } from "react"; import { Form } from "react-bootstrap"; -import { BooleanCriterion } from "src/models/list-filter/criteria/criterion"; -import { FormattedMessage } from "react-intl"; +import { + BooleanCriterion, + CriterionOption, +} from "src/models/list-filter/criteria/criterion"; +import { FormattedMessage, useIntl } from "react-intl"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { Option, SidebarListFilter } from "./SidebarListFilter"; interface IBooleanFilter { criterion: BooleanCriterion; @@ -43,3 +48,86 @@ export const BooleanFilter: React.FC = ({ ); }; + +interface ISidebarFilter { + title?: React.ReactNode; + option: CriterionOption; + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; +} + +export const SidebarBooleanFilter: React.FC = ({ + 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 ( + <> + + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/FilterButton.tsx b/ui/v2.5/src/components/List/Filters/FilterButton.tsx index 0b4d4453d..b92ddcf0d 100644 --- a/ui/v2.5/src/components/List/Filters/FilterButton.tsx +++ b/ui/v2.5/src/components/List/Filters/FilterButton.tsx @@ -3,6 +3,7 @@ import { Badge, Button } from "react-bootstrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { faFilter } from "@fortawesome/free-solid-svg-icons"; import { Icon } from "src/components/Shared/Icon"; +import { useIntl } from "react-intl"; interface IFilterButtonProps { filter: ListFilterModel; @@ -13,10 +14,16 @@ export const FilterButton: React.FC = ({ filter, onClick, }) => { + const intl = useIntl(); const count = useMemo(() => filter.count(), [filter]); return ( - + )} + + + + ); +}; + +export type Option = { + 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 ( +
    + {items.map((p) => ( + onUnselect(p)} + /> + ))} +
+ ); +}; + +const QueryField: React.FC<{ + focus: ReturnType; + 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 ( + onQueryChange(v)} + placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + /> + ); +}; + +interface IQueryableProps { + inputFocus?: ReturnType; + 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 ( +
+ {showQueryField && ( + setQuery(v)} + /> + )} +
    + {items.map((p) => ( + onSelect(p, exclude)} + label={p.label} + canExclude={canExclude && (p.canExclude ?? true)} + singleValue={singleValue} + /> + ))} +
+
+ ); +}; + +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 ( + + {preSelected ?
{preSelected}
: null} + unselectHook(i, false)} + /> + {excluded && ( + unselectHook(i, true)} + excluded + /> + )} + {postSelected ?
{postSelected}
: null} + + } + onOpen={onOpen} + > + {preCandidates ?
{preCandidates}
: null} + + {postCandidates ?
{postCandidates}
: null} +
+ ); +}; + +export function useStaticResults(r: T) { + return () => ({ results: r, loading: false }); +} diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx index 507658474..ade8d9b56 100644 --- a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -1,22 +1,27 @@ -import React, { useMemo } from "react"; -import { useFindStudiosQuery } from "src/core/generated-graphql"; +import React, { ReactNode, useMemo } from "react"; +import { useFindStudiosForSelectQuery } from "src/core/generated-graphql"; import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { sortByRelevance } from "src/utils/query"; +import { CriterionOption } from "src/models/list-filter/criteria/criterion"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { useLabeledIdFilterState } from "./LabeledIdFilter"; +import { SidebarListFilter } from "./SidebarListFilter"; interface IStudiosFilter { criterion: StudiosCriterion; setCriterion: (c: StudiosCriterion) => void; } -function useStudioQuery(query: string) { - const { data, loading } = useFindStudiosQuery({ +function useStudioQuery(query: string, skip?: boolean) { + const { data, loading } = useFindStudiosForSelectQuery({ variables: { filter: { q: query, per_page: 200, }, }, + skip, }); const results = useMemo(() => { @@ -50,4 +55,23 @@ const StudiosFilter: React.FC = ({ ); }; +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 ; +}; + export default StudiosFilter; diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx index 177357bf9..b8d4eddc6 100644 --- a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx @@ -1,22 +1,27 @@ -import React, { useMemo } from "react"; -import { useFindTagsQuery } from "src/core/generated-graphql"; +import React, { ReactNode, useMemo } from "react"; +import { useFindTagsForSelectQuery } from "src/core/generated-graphql"; import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { sortByRelevance } from "src/utils/query"; +import { CriterionOption } from "src/models/list-filter/criteria/criterion"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { useLabeledIdFilterState } from "./LabeledIdFilter"; +import { SidebarListFilter } from "./SidebarListFilter"; interface ITagsFilter { criterion: StudiosCriterion; setCriterion: (c: StudiosCriterion) => void; } -function useTagQuery(query: string) { - const { data, loading } = useFindTagsQuery({ +function useTagQuery(query: string, skip?: boolean) { + const { data, loading } = useFindTagsForSelectQuery({ variables: { filter: { q: query, per_page: 200, }, }, + skip, }); const results = useMemo(() => { @@ -46,4 +51,22 @@ const TagsFilter: React.FC = ({ 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 ; +}; + export default TagsFilter; diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 109ac127a..4933c7e75 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -61,11 +61,13 @@ export function useDebouncedSearchInput( export const SearchTermInput: React.FC<{ filter: ListFilterModel; onFilterUpdate: (newFilter: ListFilterModel) => void; -}> = ({ filter, onFilterUpdate }) => { + focus?: ReturnType; +}> = ({ filter, onFilterUpdate, focus: providedFocus }) => { const intl = useIntl(); const [localInput, setLocalInput] = useState(filter.searchTerm); - const focus = useFocus(); + const localFocus = useFocus(); + const focus = providedFocus ?? localFocus; const [, setQueryFocus] = focus; useEffect(() => { @@ -233,6 +235,7 @@ interface IListFilterProps { filter: ListFilterModel; view?: View; openFilterDialog: () => void; + withSidebar?: boolean; } export const ListFilter: React.FC = ({ @@ -240,6 +243,7 @@ export const ListFilter: React.FC = ({ filter, openFilterDialog, view, + withSidebar, }) => { const filterOptions = filter.options; @@ -313,31 +317,38 @@ export const ListFilter: React.FC = ({ return ( <> -
- -
+ {!withSidebar && ( +
+ +
+ )} - - { - onFilterUpdate(f); - }} - view={view} - /> - - - - } - > - openFilterDialog()} filter={filter} /> - - + {!withSidebar && ( + + { + onFilterUpdate(f); + }} + view={view} + /> + + + + } + > + openFilterDialog()} + filter={filter} + /> + + + )} - + {currentSortBy diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index 92bcf9ebc..8ea21df98 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -22,7 +22,7 @@ export const OperationDropdown: React.FC> = ({ if (!children) return null; return ( - + @@ -116,7 +116,7 @@ export const ListOperationButtons: React.FC = ({ if (buttons.length > 0) { return ( - + {buttons.map((button) => { return ( = ({ <> {maybeRenderButtons()} -
{renderMore()}
+ {renderMore()} ); }; diff --git a/ui/v2.5/src/components/List/ListViewOptions.tsx b/ui/v2.5/src/components/List/ListViewOptions.tsx index 2dc84d09a..3df8640a8 100644 --- a/ui/v2.5/src/components/List/ListViewOptions.tsx +++ b/ui/v2.5/src/components/List/ListViewOptions.tsx @@ -110,7 +110,7 @@ export const ListViewOptions: React.FC = ({ } return ( - + {displayModeOptions.map((option) => ( = ({ function maybeRenderZoom() { if (onSetZoom && displayMode === DisplayMode.Grid) { return ( -
+
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 ( +
    + {filtered.map((f) => ( +
  • + +
  • + ))} +
+ ); +}; + +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 ( + + + + + + + setFilterName(e.target.value)} + /> + + + + + {!!overwritingFilter && ( + + + + )} + + + + + + + ); +}; + +const DeleteAlert: React.FC<{ + deletingFilter: SavedFilterDataFragment | undefined; + onClose: (confirm?: boolean) => void; +}> = ({ deletingFilter, onClose }) => { + if (!deletingFilter) { + return null; + } + + return ( + + + + + + + + + + ); +}; + +const OverwriteAlert: React.FC<{ + overwritingFilter: SavedFilterDataFragment | undefined; + onClose: (confirm?: boolean) => void; +}> = ({ overwritingFilter, onClose }) => { + if (!overwritingFilter) { + return null; + } + + return ( + + + + + + + + + + ); +}; interface ISavedFilterListProps { filter: ListFilterModel; @@ -49,7 +209,7 @@ export const SavedFilterList: React.FC = ({ SavedFilterDataFragment | undefined >(); - const [saveFilter] = useSaveFilter(); + const saveFilter = useSaveFilter(); const [destroyFilter] = useSavedFilterDestroy(); const [saveUISetting] = useConfigureUISetting(); @@ -60,18 +220,7 @@ export const SavedFilterList: React.FC = ({ try { setSaving(true); - await saveFilter({ - variables: { - input: { - id, - mode: filter.mode, - name, - find_filter: filterCopy.makeFindFilter(), - object_filter: filterCopy.makeSavedFilter(), - ui_options: filterCopy.makeSavedUIOptions(), - }, - }, - }); + await saveFilter(filterCopy, name, id); Toast.success( intl.formatMessage( @@ -212,74 +361,6 @@ export const SavedFilterList: React.FC = ({ ); }; - function maybeRenderDeleteAlert() { - if (!deletingFilter) { - return; - } - - return ( - - - - - - - - - - ); - } - - function maybeRenderOverwriteAlert() { - if (!overwritingFilter) { - return; - } - - return ( - - - - - - - - - - ); - } - function renderSavedFilters() { if (error) return
{error.message}
; @@ -327,8 +408,24 @@ export const SavedFilterList: React.FC = ({ return ( <> - {maybeRenderDeleteAlert()} - {maybeRenderOverwriteAlert()} + { + if (confirm) { + onDeleteFilter(deletingFilter!); + } + setDeletingFilter(undefined); + }} + /> + { + if (confirm) { + onSaveFilter(overwritingFilter!.name, overwritingFilter!.id); + } + setOverwritingFilter(undefined); + }} + /> = ({ ); }; +interface ISavedFilterItem { + item: SavedFilterDataFragment; + onClick: () => void; + onDelete: () => void; + selected?: boolean; +} + +const SavedFilterItem: React.FC = ({ + item, + onClick, + onDelete, + selected = false, +}) => { + const intl = useIntl(); + + return ( +
  • + +
    + +
    +
    + +
    +
    +
  • + ); +}; + +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
    {error}
    ; + + if (loading || saving) { + return ( +
    + +
    + ); + } + + return ( +
      + {savedFilters.map((f) => ( + onFilterClicked(f)} + onDelete={() => onDeleteClicked(f)} + selected={currentFilterID === f.id} + /> + ))} +
    + ); +}; + +export const SidebarSavedFilterList: React.FC = ({ + 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 ( +
    + { + if (confirm) { + onDeleteFilter(deletingFilter!); + } + setDeletingFilter(undefined); + }} + /> + {showSaveDialog && ( + { + setShowSaveDialog(false); + if (name) { + onSaveFilter(name, id); + } + }} + /> + )} + } + confirmVariant="primary" + onConfirm={() => onSetDefaultFilter()} + onCancel={() => setSettingDefault(false)} + /> + +
    + + +
    + + setFilterName(e.target.value)} + /> + +
    + ); +}; + export const SavedFilterDropdown: React.FC = (props) => { const SavedFilterDropdownRef = React.forwardRef< HTMLDivElement, @@ -377,7 +787,7 @@ export const SavedFilterDropdown: React.FC = (props) => { SavedFilterDropdownRef.displayName = "SavedFilterDropdown"; return ( - + .extra { + padding-top: 0.25rem; +} + +.sidebar-list-filter .extra { + min-height: 2em; +} + .tilted { transform: rotate(45deg); } @@ -582,6 +803,21 @@ input[type="range"].zoom-slider { .filtered-list-toolbar { justify-content: center; + margin-bottom: 0.5rem; + + & > .btn-group { + flex-wrap: wrap; + justify-content: center; + row-gap: 0.5rem; + } +} + +.sidebar-pane .filtered-list-toolbar { + flex-wrap: nowrap; + + & > .btn-group { + align-items: baseline; + } } .search-term-input { @@ -613,3 +849,30 @@ input[type="range"].zoom-slider { } } } + +.item-list-container .sidebar-pane { + width: 100%; +} + +.sidebar { + .sidebar-search-container { + display: flex; + margin-bottom: 0.5rem; + margin-top: 0.25rem; + } + + .search-term-input { + flex-grow: 1; + margin-right: 0.25rem; + + .clearable-text-field { + height: 100%; + } + } +} + +@include media-breakpoint-down(xs) { + .sidebar .search-term-input { + margin-right: 0.5rem; + } +} diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 2492e5599..fd2ad1657 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useEffect, useMemo } from "react"; import cloneDeep from "lodash-es/cloneDeep"; -import { useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; @@ -31,6 +31,23 @@ import { IListFilterOperation } from "../List/ListOperationButtons"; import { FilteredListToolbar } from "../List/FilteredListToolbar"; import { useFilteredItemList } from "../List/ItemList"; import { FilterTags } from "../List/FilterTags"; +import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar"; +import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; +import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; +import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers"; +import { StudiosCriterionOption } from "src/models/list-filter/criteria/studios"; +import { TagsCriterionOption } from "src/models/list-filter/criteria/tags"; +import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; +import cx from "classnames"; +import { RatingCriterionOption } from "src/models/list-filter/criteria/rating"; +import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; +import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; +import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; +import { + FilteredSidebarHeader, + useFilteredSidebarKeybinds, +} from "../List/Filters/FilterSidebar"; +import { PatchContainerComponent } from "src/patch"; function renderMetadataByline(result: GQL.FindScenesQueryResult) { const duration = result?.data?.findScenes?.duration; @@ -184,6 +201,70 @@ const SceneList: React.FC<{ return null; }; +const ScenesFilterSidebarSections = PatchContainerComponent( + "FilteredSceneList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; +}> = ({ filter, setFilter, view, showEditFilter, sidebarOpen, onClose }) => { + return ( + <> + + + + } + data-type={StudiosCriterionOption.type} + option={StudiosCriterionOption} + filter={filter} + setFilter={setFilter} + /> + } + data-type={PerformersCriterionOption.type} + option={PerformersCriterionOption} + filter={filter} + setFilter={setFilter} + /> + } + data-type={TagsCriterionOption.type} + option={TagsCriterionOption} + filter={filter} + setFilter={setFilter} + /> + } + data-type={RatingCriterionOption.type} + option={RatingCriterionOption} + filter={filter} + setFilter={setFilter} + /> + } + data-type={OrganizedCriterionOption.type} + option={OrganizedCriterionOption} + filter={filter} + setFilter={setFilter} + /> + + + ); +}; + interface IFilteredScenes { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultSort?: string; @@ -199,6 +280,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => { const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; // States + const { + showSidebar, + setShowSidebar, + loading: sidebarStateLoading, + } = useSidebarState(view); + const { filterState, queryResult, modalState, listSelect, showEditFilter } = useFilteredItemList({ filterStateProps: { @@ -237,6 +324,10 @@ export const FilteredSceneList = (props: IFilteredScenes) => { }); useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); const onCloseEditDelete = useCloseEditDelete({ closeModal, @@ -340,62 +431,81 @@ export const FilteredSceneList = (props: IFilteredScenes) => { ]; // render - if (filterLoading) return null; + if (filterLoading || sidebarStateLoading) return null; return ( -
    +
    {modal} - - showModal( - - ) - } - onDelete={() => { - showModal( - - ); - }} - operations={otherOperations} - zoomable - /> + + setShowSidebar(false)}> + setShowSidebar(false)} + /> + +
    + + showModal( + + ) + } + onDelete={() => { + showModal( + + ); + }} + operations={otherOperations} + onToggleSidebar={() => setShowSidebar((v) => !v)} + zoomable + /> - showEditFilter(c.criterionOption.type)} - onRemoveCriterion={removeCriterion} - onRemoveAll={() => clearAllCriteria()} - /> + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={() => clearAllCriteria()} + /> - - - + + + +
    +
    ); diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index e7c5af22a..20647bf17 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -902,6 +902,40 @@ input[type="range"].blue-slider { } } +.scene-list .filtered-list-toolbar { + display: flex; + flex-wrap: wrap; + row-gap: 1rem; + + & > div { + display: flex; + flex: 1; + + &:first-child { + justify-content: flex-start; + } + + &:nth-child(2) { + justify-content: center; + } + + &:last-child { + justify-content: flex-end; + } + } +} + +.scene-list.hide-sidebar .sidebar-toggle-button { + transition-delay: 0.1s; + transition-duration: 0; + transition-property: opacity; +} + +.scene-list:not(.hide-sidebar) .sidebar-toggle-button { + opacity: 0; + pointer-events: none; +} + .scene-wall, .marker-wall { .wall-item { diff --git a/ui/v2.5/src/components/Shared/Alert.tsx b/ui/v2.5/src/components/Shared/Alert.tsx index 6101c3d2b..c4ada36f3 100644 --- a/ui/v2.5/src/components/Shared/Alert.tsx +++ b/ui/v2.5/src/components/Shared/Alert.tsx @@ -4,6 +4,7 @@ import { FormattedMessage } from "react-intl"; export interface IAlertModalProps { text: JSX.Element | string; + confirmVariant?: string; show?: boolean; confirmButtonText?: string; onConfirm: () => void; @@ -13,6 +14,7 @@ export interface IAlertModalProps { export const AlertModal: React.FC = ({ text, show, + confirmVariant = "danger", confirmButtonText, onConfirm, onCancel, @@ -21,7 +23,7 @@ export const AlertModal: React.FC = ({ {text} - - +
    + +
    + {props.outsideCollapse} +
    {props.children}
    diff --git a/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx b/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx index 8b4aa1e4e..5339044a1 100644 --- a/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx +++ b/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx @@ -19,6 +19,7 @@ export interface IRatingStarsProps { disabled?: boolean; precision: RatingStarPrecision; valueRequired?: boolean; + orMore?: boolean; } export const RatingStars = PatchComponent( @@ -199,6 +200,8 @@ export const RatingStars = PatchComponent( return `star-fill-${w}`; } + const suffix = props.orMore ? "+" : ""; + const renderRatingButton = (thisStar: number) => { const ratingFraction = getCurrentSelectedRating(); @@ -237,6 +240,7 @@ export const RatingStars = PatchComponent( return ( {ratingFraction.rating + ratingFraction.fraction} + {suffix} ); }; diff --git a/ui/v2.5/src/components/Shared/Sidebar.tsx b/ui/v2.5/src/components/Shared/Sidebar.tsx new file mode 100644 index 000000000..cd4848dab --- /dev/null +++ b/ui/v2.5/src/components/Shared/Sidebar.tsx @@ -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(null); + + const closeOnOutsideClick = useMediaQuery(fixedSidebarMediaQuery) && !hide; + + useOnOutsideClick( + ref, + !closeOnOutsideClick ? undefined : onHide, + "ignore-sidebar-outside-click" + ); + + return ( +
    + {children} +
    + ); +}; + +// 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 ( +
    + {children} +
    + ); +}; + +export const SidebarSection: React.FC< + PropsWithChildren<{ + text: React.ReactNode; + className?: string; + outsideCollapse?: React.ReactNode; + onOpen?: () => void; + }> +> = ({ className = "", text, outsideCollapse, onOpen, children }) => { + const collapseProps: Partial = { + mountOnEnter: true, + unmountOnExit: true, + }; + return ( + + {children} + + ); +}; + +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. */} + + + + + +); + +export const SidebarToolbar: React.FC<{ + onClose?: () => void; +}> = ({ onClose, children }) => { + const intl = useIntl(); + + return ( + + {onClose ? ( + + ) : null} + {children} + + ); +}; + +// 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(); + + // 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, + }; +} diff --git a/ui/v2.5/src/components/Shared/TruncatedText.tsx b/ui/v2.5/src/components/Shared/TruncatedText.tsx index 6ffe9d78e..c06686eff 100644 --- a/ui/v2.5/src/components/Shared/TruncatedText.tsx +++ b/ui/v2.5/src/components/Shared/TruncatedText.tsx @@ -66,3 +66,53 @@ export const TruncatedText: React.FC = ({
    ); }; + +export const TruncatedInlineText: React.FC = ({ + 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 = ( + + + {text} + + + ); + + return ( + handleFocus(e.currentTarget)} + onFocus={(e) => handleFocus(e.currentTarget)} + onMouseLeave={handleBlur} + onBlur={handleBlur} + > + {text} + {overlay} + + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 50777fff3..4b0748fe6 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -303,6 +303,12 @@ button.collapse-button { .file-info-panel a > & { word-break: break-all; } + + &.inline { + display: inline; + text-overflow: ellipsis; + white-space: nowrap; + } } .RatingStars { @@ -728,3 +734,193 @@ button.btn.favorite-button { padding-right: 0; } } + +$sidebar-width: 250px; + +.sidebar-pane { + display: flex; + + .sidebar { + // TODO - use different colours for sidebar and toolbar + background-color: $body-bg; + border-right: 1px solid $secondary; + flex: $sidebar-width; + flex-grow: 0; + flex-shrink: 0; + padding-left: 15px; + transition: margin-left 0.1s; + } + + .sidebar { + bottom: 0; + left: 0; + margin-top: 4rem; + overflow-y: auto; + position: fixed; + scrollbar-gutter: stable; + top: 0; + width: $sidebar-width; + z-index: 100; + } + + &.hide-sidebar .sidebar { + margin-left: -$sidebar-width; + } + + > :nth-child(2) { + flex-grow: 1; + padding-left: 0.5rem; + } + + &.hide-sidebar { + > :nth-child(2) { + padding-left: 0; + } + } + + @include media-breakpoint-up(xl) { + transition: margin-left 0.1s; + + &:not(.hide-sidebar) { + > :nth-child(2) { + margin-left: calc($sidebar-width - 15px); + } + } + } + @include media-breakpoint-down(xs) { + .sidebar { + width: 100%; + } + + &.hide-sidebar .sidebar { + margin-left: -100%; + } + + &.hide-sidebar > :nth-child(2) { + width: 100%; + } + } + @include media-breakpoint-down(xs) { + display: block; + + .sidebar { + margin-bottom: $navbar-height; + margin-top: 0; + } + } +} + +.sidebar-toolbar { + // TODO - use different colours for sidebar and toolbar + background-color: $body-bg; + display: flex; + justify-content: space-between; + margin-bottom: 0; + padding-bottom: 1rem; + position: sticky; + top: 0; + z-index: 101; +} + +@include media-breakpoint-down(xs) { + .sidebar-toolbar { + padding-top: 1rem; + } +} + +.sidebar-section { + border-bottom: 1px solid $secondary; + + .collapse-header { + // background-color: $secondary; + + padding: 0.25rem; + + .collapse-button { + font-weight: bold; + text-align: left; + width: 100%; + } + } + + .collapse, + // include collapsing to allow for the transition + .collapsing { + padding-top: 0.25rem; + } +} + +.sidebar-section:first-child .collapse-header { + border-top: 1px solid $secondary; +} + +$sticky-header-height: calc(50px + 3.3rem); + +// special case for sidebar in details view +.detail-body { + .sidebar { + // required for sticky to work + align-self: flex-start; + + // take a further 15px padding to match the detail body + height: calc(100vh - $sticky-header-height - 15px); + margin-top: -15px; + max-height: calc(100vh - $sticky-header-height - 15px); + overflow-y: auto; + padding-left: 0; + position: sticky; + + // sticky detail header is 50px + 3.3rem + top: calc(50px + 3.3rem); + + .sidebar-toolbar { + padding-top: 15px; + } + } + + .sidebar-pane.hide-sidebar .sidebar { + left: -$sidebar-width; + margin-left: calc(-15px - $sidebar-width); + } + + // on smaller viewports we want the sidebar to overlap content + @include media-breakpoint-down(lg) { + .sidebar-pane:not(.hide-sidebar) .sidebar { + margin-right: -$sidebar-width; + } + + .sidebar-pane > :nth-child(2) { + transition: none; + } + } + @include media-breakpoint-down(xs) { + .sidebar { + flex: 100% 0 0; + height: calc(100vh - 4rem); + max-height: calc(100vh - 4rem); + padding-top: 0; + top: 0; + } + + .sidebar-pane:not(.hide-sidebar) .sidebar { + margin-right: -100%; + } + + .sidebar-pane.hide-sidebar .sidebar { + display: none; + } + } + @include media-breakpoint-up(xl) { + .sidebar-pane:not(.hide-sidebar) { + > :nth-child(2) { + margin-left: 0; + } + } + + .sidebar-pane.hide-sidebar { + > :nth-child(2) { + padding-left: 15px; + } + } + } +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index a92b4e6e8..a7679a5d5 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2061,8 +2061,8 @@ export const useTagsMerge = () => }, }); -export const useSaveFilter = () => - GQL.useSaveFilterMutation({ +export const useSaveFilter = () => { + const [saveFilterMutation] = GQL.useSaveFilterMutation({ update(cache, result) { if (!result.data?.saveFilter) return; @@ -2070,6 +2070,26 @@ export const useSaveFilter = () => }, }); + function saveFilter(filter: ListFilterModel, name: string, id?: string) { + const filterCopy = filter.clone(); + + return saveFilterMutation({ + variables: { + input: { + id, + mode: filter.mode, + name, + find_filter: filterCopy.makeFindFilter(), + object_filter: filterCopy.makeSavedFilter(), + ui_options: filterCopy.makeSavedUIOptions(), + }, + }, + }); + } + + return saveFilter; +}; + export const useSavedFilterDestroy = () => GQL.useDestroySavedFilterMutation({ update(cache, result, { variables }) { diff --git a/ui/v2.5/src/hooks/LocalForage.ts b/ui/v2.5/src/hooks/LocalForage.ts index cf3d43ea0..cbb1ff64c 100644 --- a/ui/v2.5/src/hooks/LocalForage.ts +++ b/ui/v2.5/src/hooks/LocalForage.ts @@ -1,6 +1,7 @@ import localForage from "localforage"; import isEqual from "lodash-es/isEqual"; import React, { Dispatch, SetStateAction, useEffect } from "react"; +import { View } from "src/components/List/views"; import { ConfigImageLightboxInput } from "src/core/generated-graphql"; interface IInterfaceQueryConfig { @@ -9,11 +10,17 @@ interface IInterfaceQueryConfig { currentPage: number; } +export interface IViewConfig { + showSidebar?: boolean; +} + type IQueryConfig = Record; interface IInterfaceConfig { queryConfig: IQueryConfig; imageLightbox: ConfigImageLightboxInput; + // Partial is required because using View makes the key mandatory + viewConfig: Partial>; } export interface IChangelogConfig { diff --git a/ui/v2.5/src/hooks/OutsideClick.tsx b/ui/v2.5/src/hooks/OutsideClick.tsx new file mode 100644 index 000000000..1a09b2c28 --- /dev/null +++ b/ui/v2.5/src/hooks/OutsideClick.tsx @@ -0,0 +1,34 @@ +import React, { useEffect } from "react"; + +export const useOnOutsideClick = ( + ref: React.RefObject, + 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]); +}; diff --git a/ui/v2.5/src/hooks/data.ts b/ui/v2.5/src/hooks/data.ts new file mode 100644 index 000000000..9b10dcf6e --- /dev/null +++ b/ui/v2.5/src/hooks/data.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +export interface ILoadResults { + results: T; + loading: boolean; +} + +export function useCacheResults(data: ILoadResults) { + const [results, setResults] = useState( + !data.loading ? data.results : undefined + ); + + useEffect(() => { + if (!data.loading) { + setResults(data.results); + } + }, [data.loading, data.results]); + + return { loading: data.loading, results }; +} diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 8ad3ee178..730ad9eb2 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -1,3 +1,9 @@ +// variables required by other scss files + +// this is calculated from the existing height +// TODO: we should set this explicitly in the navbar +$navbar-height: 48.75px; + @import "styles/theme"; @import "styles/range"; @import "styles/scrollbars"; @@ -258,6 +264,10 @@ dd { padding: 5px 0; } + .tab-content { + padding-bottom: 0; + } + .item-list-header { align-content: center; // border-bottom: solid 2px #192127; @@ -270,9 +280,10 @@ dd { .item-list-container { padding-top: 15px; - @media (max-width: 576px) { - overflow-x: hidden; - } + // this breaks sticky sidebar - need to work out why this is here + // @media (max-width: 576px) { + // overflow-x: hidden; + // } } } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 17d7cbc79..4b9b980da 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -124,6 +124,10 @@ "set_image": "Set image…", "show": "Show", "show_configuration": "Show Configuration", + "sidebar": { + "close": "Close sidebar", + "open": "Open sidebar" + }, "skip": "Skip", "split": "Split", "stop": "Stop", @@ -932,7 +936,7 @@ "destination": "Destination", "source": "Source" }, - "overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?", + "overwrite_filter_warning": "Saved filter \"{entityName}\" will be overwritten.", "performers_found": "{count} performers found", "reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}", "reassign_files": { @@ -982,6 +986,7 @@ "scrape_entity_title": "{entity_type} Scrape Results", "scrape_results_existing": "Existing", "scrape_results_scraped": "Scraped", + "set_default_filter_confirm": "Are you sure you want to set this filter as the default?", "set_image_url_title": "Image URL", "unsaved_changes": "Unsaved changes. Are you sure you want to leave?" }, diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index e63910ca8..e7cf6a6eb 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -463,6 +463,19 @@ export class ListFilterModel { }; } + public criteriaFor(type: CriterionType) { + return this.criteria.filter((c) => c.criterionOption.type === type); + } + + public replaceCriteria(type: CriterionType, newCriteria: Criterion[]) { + const criteria = [ + ...this.criteria.filter((c) => c.criterionOption.type !== type), + ...newCriteria, + ]; + + return this.setCriteria(criteria); + } + public clearCriteria() { const ret = this.clone(); ret.criteria = []; @@ -470,6 +483,12 @@ export class ListFilterModel { return ret; } + public setCriteria(criteria: Criterion[]) { + const ret = this.clone(); + ret.criteria = criteria; + return ret; + } + public removeCriterion(type: CriterionType) { const ret = this.clone(); const c = ret.criteria.find((cc) => cc.criterionOption.type === type); diff --git a/ui/v2.5/src/utils/focus.ts b/ui/v2.5/src/utils/focus.ts index 68db7d772..f1ede47f9 100644 --- a/ui/v2.5/src/utils/focus.ts +++ b/ui/v2.5/src/utils/focus.ts @@ -1,13 +1,13 @@ -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useCallback } from "react"; const useFocus = () => { const htmlElRef = useRef(null); - const setFocus = () => { + const setFocus = useCallback(() => { const currentEl = htmlElRef.current; if (currentEl) { currentEl.focus(); } - }; + }, []); // eslint-disable-next-line no-undef return [htmlElRef, setFocus] as const; diff --git a/ui/v2.5/src/utils/screen.ts b/ui/v2.5/src/utils/screen.ts index 0f4a0a3c6..56512c6c4 100644 --- a/ui/v2.5/src/utils/screen.ts +++ b/ui/v2.5/src/utils/screen.ts @@ -1,11 +1,39 @@ +import { useEffect, useState } from "react"; + const isMobile = () => window.matchMedia("only screen and (max-width: 576px)").matches; const isTouch = () => window.matchMedia("(pointer: coarse)").matches; +function matchesMediaQuery(query: string) { + return window.matchMedia(query).matches; +} + +// from: https://dev.to/salimzade/handle-media-query-in-react-with-hooks-3cp3 +export const useMediaQuery = (query: string): boolean => { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const media = window.matchMedia(query); + setMatches(media.matches); + + // Define the listener as a separate function to avoid recreating it on each render + const listener = () => setMatches(media.matches); + + // Use 'change' instead of 'resize' for better performance + media.addEventListener("change", listener); + + // Cleanup function to remove the event listener + return () => media.removeEventListener("change", listener); + }, [query]); // Only recreate the listener when 'matches' or 'query' changes + + return matches; +}; + const ScreenUtils = { isMobile, isTouch, + matchesMediaQuery, }; export default ScreenUtils;