From f17c665d46d335458ab8df7d99b6750da116e581 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 9 Feb 2024 16:42:07 +1100 Subject: [PATCH] Add gallery select filter and fix image gallery filtering (#4535) * Accept gallery ids in findGalleries * Add gallery select component * Add and fix image gallery filter * Show gallery path as alias --- graphql/schema/schema.graphql | 1 + internal/api/resolver_query_find_gallery.go | 20 +- ui/v2.5/graphql/data/gallery.graphql | 11 + ui/v2.5/graphql/queries/gallery.graphql | 13 + .../components/Galleries/GallerySelect.tsx | 222 ++++++++++++++++++ ui/v2.5/src/components/Galleries/styles.scss | 6 + .../List/Filters/LabeledIdFilter.tsx | 14 +- .../List/Filters/PerformersFilter.tsx | 1 + .../components/List/Filters/StudiosFilter.tsx | 1 + .../components/List/Filters/TagsFilter.tsx | 1 + .../components/Performers/PerformerSelect.tsx | 1 + .../Scenes/SceneDetails/SceneEditPanel.tsx | 18 +- .../components/Scenes/SceneMergeDialog.tsx | 41 +--- .../src/components/Shared/FilterSelect.tsx | 105 +++++---- ui/v2.5/src/components/Shared/Select.tsx | 77 ++---- .../src/components/Studios/StudioSelect.tsx | 7 +- ui/v2.5/src/components/Tags/TagSelect.tsx | 7 +- ui/v2.5/src/core/StashService.ts | 8 + ui/v2.5/src/models/list-filter/images.ts | 2 + ui/v2.5/src/utils/query.ts | 8 +- 20 files changed, 403 insertions(+), 161 deletions(-) create mode 100644 ui/v2.5/src/components/Galleries/GallerySelect.tsx diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index f052d78ed19..e60cdb6830e 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -84,6 +84,7 @@ type Query { findGalleries( gallery_filter: GalleryFilterType filter: FindFilterType + ids: [ID!] ): FindGalleriesResultType! findTag(id: ID!): Tag diff --git a/internal/api/resolver_query_find_gallery.go b/internal/api/resolver_query_find_gallery.go index 6474cc03ef1..724a48b1202 100644 --- a/internal/api/resolver_query_find_gallery.go +++ b/internal/api/resolver_query_find_gallery.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models.Gallery, err error) { @@ -23,9 +24,24 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models return ret, nil } -func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType) (ret *FindGalleriesResultType, err error) { +func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) { + idInts, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + if err := r.withReadTxn(ctx, func(ctx context.Context) error { - galleries, total, err := r.repository.Gallery.Query(ctx, galleryFilter, filter) + var galleries []*models.Gallery + var err error + var total int + + if len(idInts) > 0 { + galleries, err = r.repository.Gallery.FindMany(ctx, idInts) + total = len(galleries) + } else { + galleries, total, err = r.repository.Gallery.Query(ctx, galleryFilter, filter) + } + if err != nil { return err } diff --git a/ui/v2.5/graphql/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql index 5a97f77c512..6f25599b9bd 100644 --- a/ui/v2.5/graphql/data/gallery.graphql +++ b/ui/v2.5/graphql/data/gallery.graphql @@ -38,3 +38,14 @@ fragment GalleryData on Gallery { ...SlimSceneData } } + +fragment SelectGalleryData on Gallery { + id + title + files { + path + } + folder { + path + } +} diff --git a/ui/v2.5/graphql/queries/gallery.graphql b/ui/v2.5/graphql/queries/gallery.graphql index 22eb7281d61..6c33b9910d9 100644 --- a/ui/v2.5/graphql/queries/gallery.graphql +++ b/ui/v2.5/graphql/queries/gallery.graphql @@ -15,3 +15,16 @@ query FindGallery($id: ID!) { ...GalleryData } } + +query FindGalleriesForSelect( + $filter: FindFilterType + $gallery_filter: GalleryFilterType + $ids: [ID!] +) { + findGalleries(filter: $filter, gallery_filter: $gallery_filter, ids: $ids) { + count + galleries { + ...SelectGalleryData + } + } +} diff --git a/ui/v2.5/src/components/Galleries/GallerySelect.tsx b/ui/v2.5/src/components/Galleries/GallerySelect.tsx new file mode 100644 index 00000000000..1492e669c8a --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GallerySelect.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + OptionProps, + components as reactSelectComponents, + MultiValueGenericProps, + SingleValueProps, +} from "react-select"; +import cx from "classnames"; + +import * as GQL from "src/core/generated-graphql"; +import { + queryFindGalleries, + queryFindGalleriesByIDForSelect, +} from "src/core/StashService"; +import { ConfigurationContext } from "src/hooks/Config"; +import { useIntl } from "react-intl"; +import { defaultMaxOptionsShown } from "src/core/config"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { + FilterSelectComponent, + IFilterIDProps, + IFilterProps, + IFilterValueProps, + Option as SelectOption, +} from "../Shared/FilterSelect"; +import { useCompare } from "src/hooks/state"; +import { Placement } from "react-bootstrap/esm/Overlay"; +import { sortByRelevance } from "src/utils/query"; +import { galleryTitle } from "src/core/galleries"; + +export type Gallery = Pick & { + files: Pick[]; + folder?: Pick | null; +}; +type Option = SelectOption; + +export const GallerySelect: React.FC< + IFilterProps & + IFilterValueProps & { + hoverPlacement?: Placement; + excludeIds?: string[]; + } +> = (props) => { + const { configuration } = React.useContext(ConfigurationContext); + const intl = useIntl(); + const maxOptionsShown = + configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; + + const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + + async function loadGalleries(input: string): Promise { + const filter = new ListFilterModel(GQL.FilterMode.Galleries); + filter.searchTerm = input; + filter.currentPage = 1; + filter.itemsPerPage = maxOptionsShown; + filter.sortBy = "title"; + filter.sortDirection = GQL.SortDirectionEnum.Asc; + const query = await queryFindGalleries(filter); + let ret = query.data.findGalleries.galleries.filter((gallery) => { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(gallery.id.toString()); + }); + + return sortByRelevance(input, ret, galleryTitle, (g) => { + return g.files.map((f) => f.path).concat(g.folder?.path ?? []); + }).map((gallery) => ({ + value: gallery.id, + object: gallery, + })); + } + + const GalleryOption: React.FC> = ( + optionProps + ) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + const title = galleryTitle(object); + + // if title does not match the input value but the path does, show the path + const { inputValue } = optionProps.selectProps; + let matchedPath: string | undefined = ""; + if (!title.toLowerCase().includes(inputValue.toLowerCase())) { + matchedPath = object.files?.find((a) => + a.path.toLowerCase().includes(inputValue.toLowerCase()) + )?.path; + + if ( + !matchedPath && + object.folder?.path.toLowerCase().includes(inputValue.toLowerCase()) + ) { + matchedPath = object.folder?.path; + } + } + + thisOptionProps = { + ...optionProps, + children: ( + + {title} + {matchedPath && ( + {` (${matchedPath})`} + )} + + ), + }; + + return ; + }; + + const GalleryMultiValueLabel: React.FC< + MultiValueGenericProps + > = (optionProps) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + thisOptionProps = { + ...optionProps, + children: galleryTitle(object), + }; + + return ; + }; + + const GalleryValueLabel: React.FC> = ( + optionProps + ) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + thisOptionProps = { + ...optionProps, + children: <>{galleryTitle(object)}, + }; + + return ; + }; + + return ( + + {...props} + className={cx( + "gallery-select", + { + "gallery-select-active": props.active, + }, + props.className + )} + loadOptions={loadGalleries} + components={{ + Option: GalleryOption, + MultiValueLabel: GalleryMultiValueLabel, + SingleValue: GalleryValueLabel, + }} + isMulti={props.isMulti ?? false} + placeholder={ + props.noSelectionString ?? + intl.formatMessage( + { id: "actions.select_entity" }, + { + entityType: intl.formatMessage({ + id: props.isMulti ? "galleries" : "gallery", + }), + } + ) + } + closeMenuOnSelect={!props.isMulti} + /> + ); +}; + +export const GalleryIDSelect: React.FC< + IFilterProps & IFilterIDProps +> = (props) => { + const { ids, onSelect: onSelectValues } = props; + + const [values, setValues] = useState([]); + const idsChanged = useCompare(ids); + + function onSelect(items: Gallery[]) { + setValues(items); + onSelectValues?.(items); + } + + async function loadObjectsByID(idsToLoad: string[]): Promise { + const galleryIDs = idsToLoad.map((id) => parseInt(id)); + const query = await queryFindGalleriesByIDForSelect(galleryIDs); + const { galleries: loadedGalleries } = query.data.findGalleries; + + return loadedGalleries; + } + + useEffect(() => { + if (!idsChanged) { + return; + } + + if (!ids || ids?.length === 0) { + setValues([]); + return; + } + + // load the values if we have ids and they haven't been loaded yet + const filteredValues = values.filter((v) => ids.includes(v.id.toString())); + if (filteredValues.length === ids.length) { + return; + } + + const load = async () => { + const items = await loadObjectsByID(ids); + setValues(items); + }; + + load(); + }, [ids, idsChanged, values]); + + return ; +}; diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index 5546e47de7c..8a9a10e0507 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -289,3 +289,9 @@ $galleryTabWidth: 450px; .col-form-label { padding-right: 2px; } + +.gallery-select-alias { + font-size: 0.8rem; + font-weight: bold; + white-space: pre; +} diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index 13824e08b8b..c2fa322f87a 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Form } from "react-bootstrap"; import { FilterSelect, SelectObject } from "src/components/Shared/Select"; +import { galleryTitle } from "src/core/galleries"; import { Criterion } from "src/models/list-filter/criteria/criterion"; import { ILabeledId } from "src/models/list-filter/types"; @@ -22,16 +23,25 @@ export const LabeledIdFilter: React.FC = ({ inputType !== "scene_tags" && inputType !== "performer_tags" && inputType !== "tags" && - inputType !== "movies" + inputType !== "movies" && + inputType !== "galleries" ) { return null; } + function getLabel(i: SelectObject) { + if (inputType === "galleries") { + return galleryTitle(i); + } + + return i.name ?? i.title ?? ""; + } + function onSelectionChanged(items: SelectObject[]) { onValueChanged( items.map((i) => ({ id: i.id, - label: i.name ?? i.title ?? "", + label: getLabel(i), })) ); } diff --git a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx index 0698eade59a..516e02d4b8e 100644 --- a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx @@ -23,6 +23,7 @@ function usePerformerQuery(query: string) { return sortByRelevance( query, data?.findPerformers.performers ?? [], + (p) => p.name, (p) => p.alias_list ).map((p) => { return { diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx index 3960ec91758..a99fdde3a54 100644 --- a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -23,6 +23,7 @@ function useStudioQuery(query: string) { return sortByRelevance( query, data?.findStudios.studios ?? [], + (s) => s.name, (s) => s.aliases ).map((p) => { return { diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx index c32a384bd85..177357bf9fc 100644 --- a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx @@ -23,6 +23,7 @@ function useTagQuery(query: string) { return sortByRelevance( query, data?.findTags.tags ?? [], + (t) => t.name, (t) => t.aliases ).map((p) => { return { diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index fbf3e122555..f42d22e1886 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -63,6 +63,7 @@ export const PerformerSelect: React.FC< return sortByRelevance( input, query.data.findPerformers.performers, + (p) => p.name, (p) => p.alias_list ).map((performer) => ({ value: performer.id, diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index cbc448413ad..5aa83d5456a 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -19,7 +19,7 @@ import { mutateReloadScrapers, queryScrapeSceneQueryFragment, } from "src/core/StashService"; -import { GallerySelect, MovieSelect } from "src/components/Shared/Select"; +import { MovieSelect } from "src/components/Shared/Select"; import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ImageInput } from "src/components/Shared/ImageInput"; @@ -49,6 +49,7 @@ import { import { formikUtils } from "src/utils/form"; import { Tag, TagSelect } from "src/components/Tags/TagSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; +import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -73,9 +74,7 @@ export const SceneEditPanel: React.FC = ({ const intl = useIntl(); const Toast = useToast(); - const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( - [] - ); + const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); @@ -95,6 +94,8 @@ export const SceneEditPanel: React.FC = ({ scene.galleries?.map((g) => ({ id: g.id, title: galleryTitle(g), + files: g.files, + folder: g.folder, })) ?? [] ); }, [scene.galleries]); @@ -188,12 +189,7 @@ export const SceneEditPanel: React.FC = ({ formik.setFieldValue("rating100", v); } - interface IGallerySelectValue { - id: string; - title: string; - } - - function onSetGalleries(items: IGallerySelectValue[]) { + function onSetGalleries(items: Gallery[]) { setGalleries(items); formik.setFieldValue( "gallery_ids", @@ -725,7 +721,7 @@ export const SceneEditPanel: React.FC = ({ const title = intl.formatMessage({ id: "galleries" }); const control = ( onSetGalleries(items)} isMulti /> diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 8e629204fc4..84a028526d4 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -1,5 +1,5 @@ import { Form, Col, Row, Button, FormControl } from "react-bootstrap"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; @@ -20,7 +20,6 @@ import { ScrapedTextAreaRow, } from "../Shared/ScrapeDialog/ScrapeDialog"; import { clone, uniq } from "lodash-es"; -import { galleryTitle } from "src/core/galleries"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ModalComponent } from "../Shared/Modal"; import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; @@ -302,34 +301,6 @@ const SceneMergeDetails: React.FC = ({ loadImages(); }, [sources, dest]); - const convertGalleries = useCallback( - (ids?: string[]) => { - const all = [dest, ...sources]; - return ids - ?.map((g) => - all - .map((s) => s.galleries) - .flat() - .find((gg) => g === gg.id) - ) - .map((g) => { - return { - id: g!.id, - title: galleryTitle(g!), - }; - }); - }, - [dest, sources] - ); - - const originalGalleries = useMemo(() => { - return convertGalleries(galleries.originalValue); - }, [galleries, convertGalleries]); - - const newGalleries = useMemo(() => { - return convertGalleries(galleries.newValue); - }, [galleries, convertGalleries]); - // ensure this is updated if fields are changed const hasValues = useMemo(() => { return hasScrapedValues([ @@ -492,17 +463,19 @@ const SceneMergeDetails: React.FC = ({ renderOriginalField={() => ( {}} - disabled + isMulti + isDisabled /> )} renderNewField={() => ( {}} - disabled + isMulti + isDisabled /> )} onChange={(value) => setGalleries(value)} diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx index e6f47bba47e..c8fcb7013c6 100644 --- a/ui/v2.5/src/components/Shared/FilterSelect.tsx +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -134,8 +134,8 @@ export interface IFilterComponentProps extends IFilterProps { onCreate?: ( name: string ) => Promise<{ value: string; item: T; message: string }>; - getNamedObject: (id: string, name: string) => T; - isValidNewOption: (inputValue: string, options: T[]) => boolean; + getNamedObject?: (id: string, name: string) => T; + isValidNewOption?: (inputValue: string, options: T[]) => boolean; } export const FilterSelectComponent = < @@ -150,6 +150,7 @@ export const FilterSelectComponent = < values, isMulti, onSelect, + creatable = false, isValidNewOption, getNamedObject, loadOptions, @@ -182,52 +183,62 @@ export const FilterSelectComponent = < onSelect?.(selected.map((item) => item.object)); }; - const onCreate = async (name: string) => { - try { - setLoading(true); - const { value, item: newItem, message } = await props.onCreate!(name); - const newItemOption = { - object: newItem, - value, - } as Option; - if (!isMulti) { - onChange(newItemOption); - } else { - const o = (selectedOptions ?? []) as Option[]; - onChange([...o, newItemOption]); - } - - setLoading(false); - Toast.success( - - {message}: {name} - - ); - } catch (e) { - Toast.error(e); - } - }; + const onCreate = + creatable && props.onCreate + ? async (name: string) => { + try { + setLoading(true); + const { + value, + item: newItem, + message, + } = await props.onCreate!(name); + const newItemOption = { + object: newItem, + value, + } as Option; + if (!isMulti) { + onChange(newItemOption); + } else { + const o = (selectedOptions ?? []) as Option[]; + onChange([...o, newItemOption]); + } - const getNewOptionData = ( - inputValue: string, - optionLabel: React.ReactNode - ) => { - return { - value: "", - object: getNamedObject("", optionLabel as string), - }; - }; + setLoading(false); + Toast.success( + + {message}: {name} + + ); + } catch (e) { + Toast.error(e); + } + } + : undefined; - const validNewOption = ( - inputValue: string, - value: Options>, - options: OptionsOrGroups, GroupBase>> - ) => { - return isValidNewOption( - inputValue, - (options as Options>).map((o) => o.object) - ); - }; + const getNewOptionData = + creatable && getNamedObject + ? (inputValue: string, optionLabel: React.ReactNode) => { + return { + value: "", + object: getNamedObject("", optionLabel as string), + }; + } + : undefined; + + const validNewOption = + creatable && isValidNewOption + ? ( + inputValue: string, + value: Options>, + options: OptionsOrGroups, GroupBase>> + ) => { + return isValidNewOption( + inputValue, + (options as Options>).map((o) => o.object) + ); + } + : undefined; const debounceDelay = 100; const debounceLoadOptions = useDebounce((inputValue, callback) => { @@ -241,7 +252,7 @@ export const FilterSelectComponent = < isLoading={props.isLoading || loading} onChange={onChange} selectedOptions={selectedOptions} - onCreateOption={props.creatable ? onCreate : undefined} + onCreateOption={onCreate} getNewOptionData={getNewOptionData} isValidNewOption={validNewOption} /> diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index c06a41946c5..1b096c14179 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -23,7 +23,6 @@ import { SelectComponents } from "react-select/dist/declarations/src/components" import { ConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { objectTitle } from "src/core/files"; -import { galleryTitle } from "src/core/galleries"; import { defaultMaxOptionsShown } from "src/core/config"; import { useDebounce } from "src/hooks/debounce"; import { Placement } from "react-bootstrap/esm/Overlay"; @@ -32,6 +31,7 @@ import { Icon } from "./Icon"; import { faTableColumns } from "@fortawesome/free-solid-svg-icons"; import { TagIDSelect } from "../Tags/TagSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; +import { GalleryIDSelect } from "../Galleries/GallerySelect"; export type SelectObject = { id: string; @@ -47,7 +47,8 @@ interface ITypeProps { | "tags" | "scene_tags" | "performer_tags" - | "movies"; + | "movies" + | "galleries"; } interface IFilterProps { ids?: string[]; @@ -333,55 +334,10 @@ const FilterSelectComponent = ( ); }; -export const GallerySelect: React.FC = (props) => { - const [query, setQuery] = useState(""); - const { data, loading } = GQL.useFindGalleriesQuery({ - skip: query === "", - variables: { - filter: { - q: query, - }, - }, - }); - - const galleries = data?.findGalleries.galleries ?? []; - const items = galleries.map((g) => ({ - label: galleryTitle(g), - value: g.id, - })); - - const onInputChange = useDebounce(setQuery, 500); - - const onChange = (selectedItems: OnChangeValue) => { - const selected = getSelectedItems(selectedItems); - props.onSelect( - selected.map((s) => ({ - id: s.value, - title: s.label, - })) - ); - }; - - const options = props.selected.map((g) => ({ - value: g.id, - label: g.title ?? "Unknown", - })); - - return ( - - ); +export const GallerySelect: React.FC< + IFilterProps & { excludeIds?: string[] } +> = (props) => { + return ; }; export const SceneSelect: React.FC = (props) => { @@ -590,14 +546,17 @@ export const TagSelect: React.FC< }; export const FilterSelect: React.FC = (props) => { - if (props.type === "performers") { - return ; - } else if (props.type === "studios") { - return ; - } else if (props.type === "movies") { - return ; - } else { - return ; + switch (props.type) { + case "performers": + return ; + case "studios": + return ; + case "movies": + return ; + case "galleries": + return ; + default: + return ; } }; diff --git a/ui/v2.5/src/components/Studios/StudioSelect.tsx b/ui/v2.5/src/components/Studios/StudioSelect.tsx index 6b21d5a92e2..1271688195d 100644 --- a/ui/v2.5/src/components/Studios/StudioSelect.tsx +++ b/ui/v2.5/src/components/Studios/StudioSelect.tsx @@ -69,7 +69,12 @@ export const StudioSelect: React.FC< return !exclude.includes(studio.id.toString()); }); - return sortByRelevance(input, ret, (o) => o.aliases).map((studio) => ({ + return sortByRelevance( + input, + ret, + (s) => s.name, + (s) => s.aliases + ).map((studio) => ({ value: studio.id, object: studio, })); diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index 80545be48bc..6916402bcc8 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -70,7 +70,12 @@ export const TagSelect: React.FC< return !exclude.includes(tag.id.toString()); }); - return sortByRelevance(input, ret, (o) => o.aliases).map((tag) => ({ + return sortByRelevance( + input, + ret, + (t) => t.name, + (t) => t.aliases + ).map((tag) => ({ value: tag.id, object: tag, })); diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 54d221f893f..00209f2e102 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -244,6 +244,14 @@ export const queryFindGalleries = (filter: ListFilterModel) => }, }); +export const queryFindGalleriesByIDForSelect = (galleryIDs: number[]) => + client.query({ + query: GQL.FindGalleriesForSelectDocument, + variables: { + ids: galleryIDs, + }, + }); + export const useFindPerformer = (id: string) => { const skip = id === "new" || id === ""; return GQL.useFindPerformerQuery({ variables: { id }, skip }); diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index d9d21e33350..5a5d5f43f25 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -20,6 +20,7 @@ import { } from "./criteria/tags"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { DisplayMode } from "./types"; +import { GalleriesCriterionOption } from "./criteria/galleries"; const defaultSortBy = "path"; @@ -39,6 +40,7 @@ const criterionOptions = [ createStringCriterionOption("photographer"), createMandatoryStringCriterionOption("checksum", "media_info.checksum"), PathCriterionOption, + GalleriesCriterionOption, OrganizedCriterionOption, createMandatoryNumberCriterionOption("o_counter"), ResolutionCriterionOption, diff --git a/ui/v2.5/src/utils/query.ts b/ui/v2.5/src/utils/query.ts index fcf2af30785..8235564797d 100644 --- a/ui/v2.5/src/utils/query.ts +++ b/ui/v2.5/src/utils/query.ts @@ -1,6 +1,5 @@ interface ISortable { id: string; - name: string; } // sortByRelevance is a function that sorts an array of objects by relevance to a query string. @@ -15,6 +14,7 @@ interface ISortable { export function sortByRelevance( query: string, value: T[], + getName: (o: T) => string, getAliases?: (o: T) => string[] | undefined ) { if (!query) { @@ -89,7 +89,7 @@ export function sortByRelevance( } function getWords(o: T) { - return o.name.toLowerCase().split(" "); + return getName(o).toLowerCase().split(" "); } function getAliasWords(tag: T) { @@ -170,8 +170,8 @@ export function sortByRelevance( } function compare(a: T, b: T) { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); + const aName = getName(a).toLowerCase(); + const bName = getName(b).toLowerCase(); const aAlias = aliasMatches(a); const bAlias = aliasMatches(b);