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
This commit is contained in:
WithoutPants 2024-02-09 16:42:07 +11:00 committed by GitHub
parent 79e72ff3bc
commit 9981574e82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 402 additions and 160 deletions

View file

@ -84,6 +84,7 @@ type Query {
findGalleries( findGalleries(
gallery_filter: GalleryFilterType gallery_filter: GalleryFilterType
filter: FindFilterType filter: FindFilterType
ids: [ID!]
): FindGalleriesResultType! ): FindGalleriesResultType!
findTag(id: ID!): Tag findTag(id: ID!): Tag

View file

@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "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) { 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 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 { 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 { if err != nil {
return err return err
} }

View file

@ -38,3 +38,14 @@ fragment GalleryData on Gallery {
...SlimSceneData ...SlimSceneData
} }
} }
fragment SelectGalleryData on Gallery {
id
title
files {
path
}
folder {
path
}
}

View file

@ -15,3 +15,16 @@ query FindGallery($id: ID!) {
...GalleryData ...GalleryData
} }
} }
query FindGalleriesForSelect(
$filter: FindFilterType
$gallery_filter: GalleryFilterType
$ids: [ID!]
) {
findGalleries(filter: $filter, gallery_filter: $gallery_filter, ids: $ids) {
count
galleries {
...SelectGalleryData
}
}
}

View file

@ -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<GQL.Gallery, "id" | "title"> & {
files: Pick<GQL.GalleryFile, "path">[];
folder?: Pick<GQL.Folder, "path"> | null;
};
type Option = SelectOption<Gallery>;
export const GallerySelect: React.FC<
IFilterProps &
IFilterValueProps<Gallery> & {
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<Option[]> {
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<Option, boolean>> = (
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: (
<span>
<span>{title}</span>
{matchedPath && (
<span className="gallery-select-alias">{` (${matchedPath})`}</span>
)}
</span>
),
};
return <reactSelectComponents.Option {...thisOptionProps} />;
};
const GalleryMultiValueLabel: React.FC<
MultiValueGenericProps<Option, boolean>
> = (optionProps) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
thisOptionProps = {
...optionProps,
children: galleryTitle(object),
};
return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;
};
const GalleryValueLabel: React.FC<SingleValueProps<Option, boolean>> = (
optionProps
) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
thisOptionProps = {
...optionProps,
children: <>{galleryTitle(object)}</>,
};
return <reactSelectComponents.SingleValue {...thisOptionProps} />;
};
return (
<FilterSelectComponent<Gallery, boolean>
{...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<Gallery>
> = (props) => {
const { ids, onSelect: onSelectValues } = props;
const [values, setValues] = useState<Gallery[]>([]);
const idsChanged = useCompare(ids);
function onSelect(items: Gallery[]) {
setValues(items);
onSelectValues?.(items);
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Gallery[]> {
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 <GallerySelect {...props} values={values} onSelect={onSelect} />;
};

View file

@ -289,3 +289,9 @@ $galleryTabWidth: 450px;
.col-form-label { .col-form-label {
padding-right: 2px; padding-right: 2px;
} }
.gallery-select-alias {
font-size: 0.8rem;
font-weight: bold;
white-space: pre;
}

View file

@ -1,6 +1,7 @@
import React from "react"; import React 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 { galleryTitle } from "src/core/galleries";
import { Criterion } from "src/models/list-filter/criteria/criterion"; import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ILabeledId } from "src/models/list-filter/types"; import { ILabeledId } from "src/models/list-filter/types";
@ -22,16 +23,25 @@ export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
inputType !== "scene_tags" && inputType !== "scene_tags" &&
inputType !== "performer_tags" && inputType !== "performer_tags" &&
inputType !== "tags" && inputType !== "tags" &&
inputType !== "movies" inputType !== "movies" &&
inputType !== "galleries"
) { ) {
return null; return null;
} }
function getLabel(i: SelectObject) {
if (inputType === "galleries") {
return galleryTitle(i);
}
return i.name ?? i.title ?? "";
}
function onSelectionChanged(items: SelectObject[]) { function onSelectionChanged(items: SelectObject[]) {
onValueChanged( onValueChanged(
items.map((i) => ({ items.map((i) => ({
id: i.id, id: i.id,
label: i.name ?? i.title ?? "", label: getLabel(i),
})) }))
); );
} }

View file

@ -23,6 +23,7 @@ function usePerformerQuery(query: string) {
return sortByRelevance( return sortByRelevance(
query, query,
data?.findPerformers.performers ?? [], data?.findPerformers.performers ?? [],
(p) => p.name,
(p) => p.alias_list (p) => p.alias_list
).map((p) => { ).map((p) => {
return { return {

View file

@ -23,6 +23,7 @@ function useStudioQuery(query: string) {
return sortByRelevance( return sortByRelevance(
query, query,
data?.findStudios.studios ?? [], data?.findStudios.studios ?? [],
(s) => s.name,
(s) => s.aliases (s) => s.aliases
).map((p) => { ).map((p) => {
return { return {

View file

@ -23,6 +23,7 @@ function useTagQuery(query: string) {
return sortByRelevance( return sortByRelevance(
query, query,
data?.findTags.tags ?? [], data?.findTags.tags ?? [],
(t) => t.name,
(t) => t.aliases (t) => t.aliases
).map((p) => { ).map((p) => {
return { return {

View file

@ -63,6 +63,7 @@ export const PerformerSelect: React.FC<
return sortByRelevance( return sortByRelevance(
input, input,
query.data.findPerformers.performers, query.data.findPerformers.performers,
(p) => p.name,
(p) => p.alias_list (p) => p.alias_list
).map((performer) => ({ ).map((performer) => ({
value: performer.id, value: performer.id,

View file

@ -19,7 +19,7 @@ import {
mutateReloadScrapers, mutateReloadScrapers,
queryScrapeSceneQueryFragment, queryScrapeSceneQueryFragment,
} from "src/core/StashService"; } 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 { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { ImageInput } from "src/components/Shared/ImageInput"; import { ImageInput } from "src/components/Shared/ImageInput";
@ -49,6 +49,7 @@ import {
import { formikUtils } from "src/utils/form"; import { formikUtils } from "src/utils/form";
import { Tag, TagSelect } from "src/components/Tags/TagSelect"; import { Tag, TagSelect } from "src/components/Tags/TagSelect";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect";
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
@ -73,9 +74,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( const [galleries, setGalleries] = useState<Gallery[]>([]);
[]
);
const [performers, setPerformers] = useState<Performer[]>([]); const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [studio, setStudio] = useState<Studio | null>(null); const [studio, setStudio] = useState<Studio | null>(null);
@ -95,6 +94,8 @@ export const SceneEditPanel: React.FC<IProps> = ({
scene.galleries?.map((g) => ({ scene.galleries?.map((g) => ({
id: g.id, id: g.id,
title: galleryTitle(g), title: galleryTitle(g),
files: g.files,
folder: g.folder,
})) ?? [] })) ?? []
); );
}, [scene.galleries]); }, [scene.galleries]);
@ -188,12 +189,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
formik.setFieldValue("rating100", v); formik.setFieldValue("rating100", v);
} }
interface IGallerySelectValue { function onSetGalleries(items: Gallery[]) {
id: string;
title: string;
}
function onSetGalleries(items: IGallerySelectValue[]) {
setGalleries(items); setGalleries(items);
formik.setFieldValue( formik.setFieldValue(
"gallery_ids", "gallery_ids",
@ -725,7 +721,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
const title = intl.formatMessage({ id: "galleries" }); const title = intl.formatMessage({ id: "galleries" });
const control = ( const control = (
<GallerySelect <GallerySelect
selected={galleries} values={galleries}
onSelect={(items) => onSetGalleries(items)} onSelect={(items) => onSetGalleries(items)}
isMulti isMulti
/> />

View file

@ -1,5 +1,5 @@
import { Form, Col, Row, Button, FormControl } from "react-bootstrap"; 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 * as GQL from "src/core/generated-graphql";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { LoadingIndicator } from "../Shared/LoadingIndicator";
@ -20,7 +20,6 @@ import {
ScrapedTextAreaRow, ScrapedTextAreaRow,
} from "../Shared/ScrapeDialog/ScrapeDialog"; } from "../Shared/ScrapeDialog/ScrapeDialog";
import { clone, uniq } from "lodash-es"; import { clone, uniq } from "lodash-es";
import { galleryTitle } from "src/core/galleries";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { ModalComponent } from "../Shared/Modal"; import { ModalComponent } from "../Shared/Modal";
import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; import { IHasStoredID, sortStoredIdObjects } from "src/utils/data";
@ -302,34 +301,6 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
loadImages(); loadImages();
}, [sources, dest]); }, [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 // ensure this is updated if fields are changed
const hasValues = useMemo(() => { const hasValues = useMemo(() => {
return hasScrapedValues([ return hasScrapedValues([
@ -492,17 +463,19 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
renderOriginalField={() => ( renderOriginalField={() => (
<GallerySelect <GallerySelect
className="form-control react-select" className="form-control react-select"
selected={originalGalleries ?? []} ids={galleries.originalValue ?? []}
onSelect={() => {}} onSelect={() => {}}
disabled isMulti
isDisabled
/> />
)} )}
renderNewField={() => ( renderNewField={() => (
<GallerySelect <GallerySelect
className="form-control react-select" className="form-control react-select"
selected={newGalleries ?? []} ids={galleries.newValue ?? []}
onSelect={() => {}} onSelect={() => {}}
disabled isMulti
isDisabled
/> />
)} )}
onChange={(value) => setGalleries(value)} onChange={(value) => setGalleries(value)}

View file

@ -134,8 +134,8 @@ export interface IFilterComponentProps<T> extends IFilterProps {
onCreate?: ( onCreate?: (
name: string name: string
) => Promise<{ value: string; item: T; message: string }>; ) => Promise<{ value: string; item: T; message: string }>;
getNamedObject: (id: string, name: string) => T; getNamedObject?: (id: string, name: string) => T;
isValidNewOption: (inputValue: string, options: T[]) => boolean; isValidNewOption?: (inputValue: string, options: T[]) => boolean;
} }
export const FilterSelectComponent = < export const FilterSelectComponent = <
@ -150,6 +150,7 @@ export const FilterSelectComponent = <
values, values,
isMulti, isMulti,
onSelect, onSelect,
creatable = false,
isValidNewOption, isValidNewOption,
getNamedObject, getNamedObject,
loadOptions, loadOptions,
@ -182,52 +183,62 @@ export const FilterSelectComponent = <
onSelect?.(selected.map((item) => item.object)); onSelect?.(selected.map((item) => item.object));
}; };
const onCreate = async (name: string) => { const onCreate =
try { creatable && props.onCreate
setLoading(true); ? async (name: string) => {
const { value, item: newItem, message } = await props.onCreate!(name); try {
const newItemOption = { setLoading(true);
object: newItem, const {
value, value,
} as Option<T>; item: newItem,
if (!isMulti) { message,
onChange(newItemOption); } = await props.onCreate!(name);
} else { const newItemOption = {
const o = (selectedOptions ?? []) as Option<T>[]; object: newItem,
onChange([...o, newItemOption]); value,
} } as Option<T>;
if (!isMulti) {
onChange(newItemOption);
} else {
const o = (selectedOptions ?? []) as Option<T>[];
onChange([...o, newItemOption]);
}
setLoading(false); setLoading(false);
Toast.success( Toast.success(
<span> <span>
{message}: <b>{name}</b> {message}: <b>{name}</b>
</span> </span>
); );
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
}; }
: undefined;
const getNewOptionData = ( const getNewOptionData =
inputValue: string, creatable && getNamedObject
optionLabel: React.ReactNode ? (inputValue: string, optionLabel: React.ReactNode) => {
) => { return {
return { value: "",
value: "", object: getNamedObject("", optionLabel as string),
object: getNamedObject("", optionLabel as string), };
}; }
}; : undefined;
const validNewOption = ( const validNewOption =
inputValue: string, creatable && isValidNewOption
value: Options<Option<T>>, ? (
options: OptionsOrGroups<Option<T>, GroupBase<Option<T>>> inputValue: string,
) => { value: Options<Option<T>>,
return isValidNewOption( options: OptionsOrGroups<Option<T>, GroupBase<Option<T>>>
inputValue, ) => {
(options as Options<Option<T>>).map((o) => o.object) return isValidNewOption(
); inputValue,
}; (options as Options<Option<T>>).map((o) => o.object)
);
}
: undefined;
const debounceDelay = 100; const debounceDelay = 100;
const debounceLoadOptions = useDebounce((inputValue, callback) => { const debounceLoadOptions = useDebounce((inputValue, callback) => {
@ -241,7 +252,7 @@ export const FilterSelectComponent = <
isLoading={props.isLoading || loading} isLoading={props.isLoading || loading}
onChange={onChange} onChange={onChange}
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
onCreateOption={props.creatable ? onCreate : undefined} onCreateOption={onCreate}
getNewOptionData={getNewOptionData} getNewOptionData={getNewOptionData}
isValidNewOption={validNewOption} isValidNewOption={validNewOption}
/> />

View file

@ -23,7 +23,6 @@ import { SelectComponents } from "react-select/dist/declarations/src/components"
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { objectTitle } from "src/core/files"; import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries";
import { defaultMaxOptionsShown } from "src/core/config"; import { defaultMaxOptionsShown } from "src/core/config";
import { useDebounce } from "src/hooks/debounce"; import { useDebounce } from "src/hooks/debounce";
import { Placement } from "react-bootstrap/esm/Overlay"; import { Placement } from "react-bootstrap/esm/Overlay";
@ -32,6 +31,7 @@ import { Icon } from "./Icon";
import { faTableColumns } from "@fortawesome/free-solid-svg-icons"; import { faTableColumns } from "@fortawesome/free-solid-svg-icons";
import { TagIDSelect } from "../Tags/TagSelect"; import { TagIDSelect } from "../Tags/TagSelect";
import { StudioIDSelect } from "../Studios/StudioSelect"; import { StudioIDSelect } from "../Studios/StudioSelect";
import { GalleryIDSelect } from "../Galleries/GallerySelect";
export type SelectObject = { export type SelectObject = {
id: string; id: string;
@ -47,7 +47,8 @@ interface ITypeProps {
| "tags" | "tags"
| "scene_tags" | "scene_tags"
| "performer_tags" | "performer_tags"
| "movies"; | "movies"
| "galleries";
} }
interface IFilterProps { interface IFilterProps {
ids?: string[]; ids?: string[];
@ -333,55 +334,10 @@ const FilterSelectComponent = <T extends boolean>(
); );
}; };
export const GallerySelect: React.FC<ITitledSelect> = (props) => { export const GallerySelect: React.FC<
const [query, setQuery] = useState<string>(""); IFilterProps & { excludeIds?: string[] }
const { data, loading } = GQL.useFindGalleriesQuery({ > = (props) => {
skip: query === "", return <GalleryIDSelect {...props} />;
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<Option, boolean>) => {
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 (
<SelectComponent
className={props.className}
onChange={onChange}
onInputChange={onInputChange}
isLoading={loading}
items={items}
selectedOptions={options}
isMulti
placeholder="Search for gallery..."
noOptionsMessage={query === "" ? null : "No galleries found."}
showDropdown={false}
isDisabled={props.disabled}
/>
);
}; };
export const SceneSelect: React.FC<ITitledSelect> = (props) => { export const SceneSelect: React.FC<ITitledSelect> = (props) => {
@ -590,14 +546,17 @@ export const TagSelect: React.FC<
}; };
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) => { export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) => {
if (props.type === "performers") { switch (props.type) {
return <PerformerSelect {...props} creatable={false} />; case "performers":
} else if (props.type === "studios") { return <PerformerSelect {...props} creatable={false} />;
return <StudioSelect {...props} creatable={false} />; case "studios":
} else if (props.type === "movies") { return <StudioSelect {...props} creatable={false} />;
return <MovieSelect {...props} creatable={false} />; case "movies":
} else { return <MovieSelect {...props} creatable={false} />;
return <TagSelect {...props} creatable={false} />; case "galleries":
return <GallerySelect {...props} />;
default:
return <TagSelect {...props} creatable={false} />;
} }
}; };

View file

@ -69,7 +69,12 @@ export const StudioSelect: React.FC<
return !exclude.includes(studio.id.toString()); 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, value: studio.id,
object: studio, object: studio,
})); }));

View file

@ -70,7 +70,12 @@ export const TagSelect: React.FC<
return !exclude.includes(tag.id.toString()); 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, value: tag.id,
object: tag, object: tag,
})); }));

View file

@ -244,6 +244,14 @@ export const queryFindGalleries = (filter: ListFilterModel) =>
}, },
}); });
export const queryFindGalleriesByIDForSelect = (galleryIDs: number[]) =>
client.query<GQL.FindGalleriesForSelectQuery>({
query: GQL.FindGalleriesForSelectDocument,
variables: {
ids: galleryIDs,
},
});
export const useFindPerformer = (id: string) => { export const useFindPerformer = (id: string) => {
const skip = id === "new" || id === ""; const skip = id === "new" || id === "";
return GQL.useFindPerformerQuery({ variables: { id }, skip }); return GQL.useFindPerformerQuery({ variables: { id }, skip });

View file

@ -20,6 +20,7 @@ import {
} from "./criteria/tags"; } from "./criteria/tags";
import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
import { DisplayMode } from "./types"; import { DisplayMode } from "./types";
import { GalleriesCriterionOption } from "./criteria/galleries";
const defaultSortBy = "path"; const defaultSortBy = "path";
@ -39,6 +40,7 @@ const criterionOptions = [
createStringCriterionOption("photographer"), createStringCriterionOption("photographer"),
createMandatoryStringCriterionOption("checksum", "media_info.checksum"), createMandatoryStringCriterionOption("checksum", "media_info.checksum"),
PathCriterionOption, PathCriterionOption,
GalleriesCriterionOption,
OrganizedCriterionOption, OrganizedCriterionOption,
createMandatoryNumberCriterionOption("o_counter"), createMandatoryNumberCriterionOption("o_counter"),
ResolutionCriterionOption, ResolutionCriterionOption,

View file

@ -1,6 +1,5 @@
interface ISortable { interface ISortable {
id: string; id: string;
name: string;
} }
// sortByRelevance is a function that sorts an array of objects by relevance to a query 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<T extends ISortable>( export function sortByRelevance<T extends ISortable>(
query: string, query: string,
value: T[], value: T[],
getName: (o: T) => string,
getAliases?: (o: T) => string[] | undefined getAliases?: (o: T) => string[] | undefined
) { ) {
if (!query) { if (!query) {
@ -89,7 +89,7 @@ export function sortByRelevance<T extends ISortable>(
} }
function getWords(o: T) { function getWords(o: T) {
return o.name.toLowerCase().split(" "); return getName(o).toLowerCase().split(" ");
} }
function getAliasWords(tag: T) { function getAliasWords(tag: T) {
@ -170,8 +170,8 @@ export function sortByRelevance<T extends ISortable>(
} }
function compare(a: T, b: T) { function compare(a: T, b: T) {
const aName = a.name.toLowerCase(); const aName = getName(a).toLowerCase();
const bName = b.name.toLowerCase(); const bName = getName(b).toLowerCase();
const aAlias = aliasMatches(a); const aAlias = aliasMatches(a);
const bAlias = aliasMatches(b); const bAlias = aliasMatches(b);