diff --git a/ui/v2.5/graphql/data/scene.graphql b/ui/v2.5/graphql/data/scene.graphql index f3425ca10..2b9ef76a3 100644 --- a/ui/v2.5/graphql/data/scene.graphql +++ b/ui/v2.5/graphql/data/scene.graphql @@ -79,3 +79,19 @@ fragment SceneData on Scene { label } } + +fragment SelectSceneData on Scene { + id + title + date + code + studio { + name + } + files { + path + } + paths { + screenshot + } +} diff --git a/ui/v2.5/graphql/queries/scene.graphql b/ui/v2.5/graphql/queries/scene.graphql index 9186e09ca..d6a3afd47 100644 --- a/ui/v2.5/graphql/queries/scene.graphql +++ b/ui/v2.5/graphql/queries/scene.graphql @@ -89,3 +89,16 @@ query SceneStreams($id: ID!) { } } } + +query FindScenesForSelect( + $filter: FindFilterType + $scene_filter: SceneFilterType + $ids: [ID!] +) { + findScenes(filter: $filter, scene_filter: $scene_filter, ids: $ids) { + count + scenes { + ...SelectSceneData + } + } +} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index b400c6329..4c12b0232 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -18,14 +18,12 @@ import { useListGalleryScrapers, mutateReloadScrapers, } from "src/core/StashService"; -import { SceneSelect } from "src/components/Shared/Select"; import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { useFormik } from "formik"; import { GalleryScrapeDialog } from "./GalleryScrapeDialog"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; -import { galleryTitle } from "src/core/galleries"; import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { @@ -40,6 +38,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 { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; interface IProps { gallery: Partial; @@ -56,12 +55,7 @@ export const GalleryEditPanel: React.FC = ({ }) => { const intl = useIntl(); const Toast = useToast(); - const [scenes, setScenes] = useState<{ id: string; title: string }[]>( - (gallery?.scenes ?? []).map((s) => ({ - id: s.id, - title: galleryTitle(s), - })) - ); + const [scenes, setScenes] = useState([]); const [performers, setPerformers] = useState([]); const [tags, setTags] = useState([]); @@ -116,12 +110,7 @@ export const GalleryEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); - interface ISceneSelectValue { - id: string; - title: string; - } - - function onSetScenes(items: ISceneSelectValue[]) { + function onSetScenes(items: Scene[]) { setScenes(items); formik.setFieldValue( "scene_ids", @@ -162,6 +151,10 @@ export const GalleryEditPanel: React.FC = ({ setStudio(gallery.studio ?? null); }, [gallery.studio]); + useEffect(() => { + setScenes(gallery.scenes ?? []); + }, [gallery.scenes]); + useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { @@ -412,7 +405,7 @@ export const GalleryEditPanel: React.FC = ({ const title = intl.formatMessage({ id: "scenes" }); const control = ( onSetScenes(items)} isMulti /> diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 55301f926..af050a6ec 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -3,7 +3,7 @@ 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"; -import { StringListSelect, GallerySelect, SceneSelect } from "../Shared/Select"; +import { StringListSelect, GallerySelect } from "../Shared/Select"; import * as FormUtils from "src/utils/form"; import ImageUtils from "src/utils/image"; import TextUtils from "src/utils/text"; @@ -35,6 +35,7 @@ import { ScrapedStudioRow, ScrapedTagsRow, } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; +import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; interface IStashIDsField { values: GQL.StashId[]; @@ -645,12 +646,8 @@ export const SceneMergeModal: React.FC = ({ onClose, scenes, }) => { - const [sourceScenes, setSourceScenes] = useState< - { id: string; title: string }[] - >([]); - const [destScene, setDestScene] = useState<{ id: string; title: string }[]>( - [] - ); + const [sourceScenes, setSourceScenes] = useState([]); + const [destScene, setDestScene] = useState([]); const [loadedSources, setLoadedSources] = useState< GQL.SlimSceneDataFragment[] @@ -773,7 +770,7 @@ export const SceneMergeModal: React.FC = ({ setSourceScenes(items)} - selected={sourceScenes} + values={sourceScenes} /> @@ -805,7 +802,7 @@ export const SceneMergeModal: React.FC = ({ setDestScene(items)} - selected={destScene} + values={destScene} /> diff --git a/ui/v2.5/src/components/Scenes/SceneSelect.tsx b/ui/v2.5/src/components/Scenes/SceneSelect.tsx new file mode 100644 index 000000000..4826ef1f5 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneSelect.tsx @@ -0,0 +1,264 @@ +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 } from "src/patch"; +import { + Criterion, + 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>; +}; + +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 sortByRelevance(input, ret, objectTitle, (s) => { + return s.files.map((f) => f.path); + }).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); diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index e40f62ac5..ed74de97f 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -842,3 +842,52 @@ input[type="range"].blue-slider { } } } + +.scene-select-option { + .scene-select-row { + align-items: center; + display: flex; + width: 100%; + + .scene-select-image { + background-color: $body-bg; + margin-right: 0.4em; + max-height: 50px; + max-width: 89px; + object-fit: contain; + object-position: center; + } + + .scene-select-details { + display: flex; + flex-direction: column; + justify-content: flex-start; + max-height: 4.1rem; + overflow: hidden; + + .scene-select-title { + flex-shrink: 0; + white-space: pre-wrap; + word-break: break-all; + } + + .scene-select-date, + .scene-select-studio, + .scene-select-code { + color: $text-muted; + flex-shrink: 0; + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + + .scene-select-alias { + font-size: 0.8rem; + font-weight: bold; + width: 100%; + word-break: break-all; + } +} diff --git a/ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx b/ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx index ffe388c2f..c34cd2a7e 100644 --- a/ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx +++ b/ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx @@ -1,12 +1,12 @@ import React, { useState } from "react"; import { ModalComponent } from "./Modal"; -import { SceneSelect } from "./Select"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons"; import { Col, Form, Row } from "react-bootstrap"; import * as FormUtils from "src/utils/form"; import { mutateSceneAssignFile } from "src/core/StashService"; +import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; interface IFile { id: string; @@ -21,7 +21,7 @@ interface IReassignFilesDialogProps { export const ReassignFilesDialog: React.FC = ( props: IReassignFilesDialogProps ) => { - const [scenes, setScenes] = useState<{ id: string; title: string }[]>([]); + const [scenes, setScenes] = useState([]); const intl = useIntl(); const singularEntity = intl.formatMessage({ id: "file" }); @@ -89,7 +89,7 @@ export const ReassignFilesDialog: React.FC = ( })} setScenes(items)} /> diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 905147ddf..71724c74e 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -27,6 +27,7 @@ import { TagIDSelect } from "../Tags/TagSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; import { GalleryIDSelect } from "../Galleries/GallerySelect"; import { MovieIDSelect } from "../Movies/MovieSelect"; +import { SceneIDSelect } from "../Scenes/SceneSelect"; export type SelectObject = { id: string; @@ -254,54 +255,10 @@ export const GallerySelect: React.FC< return ; }; -export const SceneSelect: React.FC = (props) => { - const [query, setQuery] = useState(""); - const { data, loading } = GQL.useFindScenesQuery({ - skip: query === "", - variables: { - filter: { - q: query, - }, - }, - }); - - const scenes = data?.findScenes.scenes ?? []; - const items = scenes.map((s) => ({ - label: objectTitle(s), - value: s.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((s) => ({ - value: s.id, - label: s.title, - })); - - return ( - - ); +export const SceneSelect: React.FC = ( + props +) => { + return ; }; export const ImageSelect: React.FC = (props) => { diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 6ccc52db8..5a7b6d811 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -166,6 +166,23 @@ export const queryFindScenesByID = (sceneIDs: number[]) => }, }); +export const queryFindScenesForSelect = (filter: ListFilterModel) => + client.query({ + query: GQL.FindScenesForSelectDocument, + variables: { + filter: filter.makeFindFilter(), + scene_filter: filter.makeFilter(), + }, + }); + +export const queryFindScenesByIDForSelect = (sceneIDs: string[]) => + client.query({ + query: GQL.FindScenesForSelectDocument, + variables: { + ids: sceneIDs, + }, + }); + export const querySceneByPathRegex = (filter: GQL.FindFilterType) => client.query({ query: GQL.FindScenesByPathRegexDocument, diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 24b441336..f16e672aa 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -672,6 +672,8 @@ declare namespace PluginApi { GalleryIDSelect: React.FC; MovieSelect: React.FC; MovieIDSelect: React.FC; + SceneSelect: React.FC; + SceneIDSelect: React.FC; DateInput: React.FC; CountrySelect: React.FC; FolderSelect: React.FC;