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 { queryFindScenesForSelect, queryFindScenesByIDForSelect, } 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 { objectTitle } from "src/core/files"; import { PatchComponent, PatchFunction } from "src/patch"; import { ModifierCriterion, CriterionValue, } from "src/models/list-filter/criteria/criterion"; import { TruncatedText } from "../Shared/TruncatedText"; export type Scene = Pick & { studio?: Pick | null; files?: Pick[]; paths?: Pick; }; type Option = SelectOption; type ExtraSceneProps = { hoverPlacement?: Placement; excludeIds?: string[]; extraCriteria?: Array>; }; type FindScenesResult = Awaited< ReturnType >["data"]["findScenes"]["scenes"]; function sortScenesByRelevance(input: string, scenes: FindScenesResult) { return sortByRelevance(input, scenes, objectTitle, (s) => { return s.files.map((f) => f.path); }); } const sceneSelectSort = PatchFunction( "SceneSelect.sort", sortScenesByRelevance ); const _SceneSelect: React.FC< IFilterProps & IFilterValueProps & ExtraSceneProps > = (props) => { const { configuration } = React.useContext(ConfigurationContext); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); async function loadScenes(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Scenes); filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "title"; filter.sortDirection = GQL.SortDirectionEnum.Asc; if (props.extraCriteria) { filter.criteria = [...props.extraCriteria]; } const query = await queryFindScenesForSelect(filter); let ret = query.data.findScenes.scenes.filter((scene) => { // HACK - we should probably exclude these in the backend query, but // this will do in the short-term return !exclude.includes(scene.id.toString()); }); return sceneSelectSort(input, ret).map((scene) => ({ value: scene.id, object: scene, })); } const SceneOption: React.FC> = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; const title = objectTitle(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; } thisOptionProps = { ...optionProps, children: ( {object.paths?.screenshot && ( )} {object.studio?.name && ( {object.studio?.name} )} {object.date && ( {object.date} )} {object.code && ( {object.code} )} {matchedPath && ( {`(${matchedPath})`} )} ), }; return ; }; const SceneMultiValueLabel: React.FC< MultiValueGenericProps > = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: objectTitle(object), }; return ; }; const SceneValueLabel: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; const { object } = optionProps.data; thisOptionProps = { ...optionProps, children: <>{objectTitle(object)}, }; return ; }; return ( {...props} className={cx( "scene-select", { "scene-select-active": props.active, }, props.className )} loadOptions={loadScenes} components={{ Option: SceneOption, MultiValueLabel: SceneMultiValueLabel, SingleValue: SceneValueLabel, }} isMulti={props.isMulti ?? false} placeholder={ props.noSelectionString ?? intl.formatMessage( { id: "actions.select_entity" }, { entityType: intl.formatMessage({ id: props.isMulti ? "scenes" : "scene", }), } ) } closeMenuOnSelect={!props.isMulti} /> ); }; export const SceneSelect = PatchComponent("SceneSelect", _SceneSelect); const _SceneIDSelect: React.FC< IFilterProps & IFilterIDProps & ExtraSceneProps > = (props) => { const { ids, onSelect: onSelectValues } = props; const [values, setValues] = useState([]); const idsChanged = useCompare(ids); function onSelect(items: Scene[]) { setValues(items); onSelectValues?.(items); } async function loadObjectsByID(idsToLoad: string[]): Promise { const query = await queryFindScenesByIDForSelect(idsToLoad); const { scenes: loadedScenes } = query.data.findScenes; return loadedScenes; } 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 ; }; export const SceneIDSelect = PatchComponent("SceneIDSelect", _SceneIDSelect);