New scene select with additional fields (#4832)

This commit is contained in:
dogwithakeyboard 2024-05-14 05:51:24 +01:00 committed by GitHub
parent c8aeb7966a
commit ca5febc65b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 383 additions and 75 deletions

View file

@ -79,3 +79,19 @@ fragment SceneData on Scene {
label
}
}
fragment SelectSceneData on Scene {
id
title
date
code
studio {
name
}
files {
path
}
paths {
screenshot
}
}

View file

@ -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
}
}
}

View file

@ -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<GQL.GalleryDataFragment>;
@ -56,12 +55,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
}) => {
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<Scene[]>([]);
const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
@ -116,12 +110,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
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<IProps> = ({
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<IProps> = ({
const title = intl.formatMessage({ id: "scenes" });
const control = (
<SceneSelect
selected={scenes}
values={scenes}
onSelect={(items) => onSetScenes(items)}
isMulti
/>

View file

@ -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<ISceneMergeModalProps> = ({
onClose,
scenes,
}) => {
const [sourceScenes, setSourceScenes] = useState<
{ id: string; title: string }[]
>([]);
const [destScene, setDestScene] = useState<{ id: string; title: string }[]>(
[]
);
const [sourceScenes, setSourceScenes] = useState<Scene[]>([]);
const [destScene, setDestScene] = useState<Scene[]>([]);
const [loadedSources, setLoadedSources] = useState<
GQL.SlimSceneDataFragment[]
@ -773,7 +770,7 @@ export const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({
<SceneSelect
isMulti
onSelect={(items) => setSourceScenes(items)}
selected={sourceScenes}
values={sourceScenes}
/>
</Col>
</Form.Group>
@ -805,7 +802,7 @@ export const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({
<Col sm={9} xl={12}>
<SceneSelect
onSelect={(items) => setDestScene(items)}
selected={destScene}
values={destScene}
/>
</Col>
</Form.Group>

View file

@ -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<GQL.Scene, "id" | "title" | "date" | "code"> & {
studio?: Pick<GQL.Studio, "name"> | null;
} & {
files?: Pick<GQL.VideoFile, "path">[];
} & {
paths?: Pick<GQL.ScenePathsType, "screenshot">;
};
type Option = SelectOption<Scene>;
type ExtraSceneProps = {
hoverPlacement?: Placement;
excludeIds?: string[];
extraCriteria?: Array<Criterion<CriterionValue>>;
};
const _SceneSelect: React.FC<
IFilterProps & IFilterValueProps<Scene> & 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<Option[]> {
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<Option, boolean>> = (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: (
<span className="scene-select-option">
<span className="scene-select-row">
{object.paths?.screenshot && (
<img
className="scene-select-image"
src={object.paths.screenshot}
loading="lazy"
/>
)}
<span className="scene-select-details">
<TruncatedText
className="scene-select-title"
text={title}
lineCount={1}
/>
{object.studio?.name && (
<span className="scene-select-studio">
{object.studio?.name}
</span>
)}
{object.date && (
<span className="scene-select-date">{object.date}</span>
)}
{object.code && (
<span className="scene-select-code">{object.code}</span>
)}
</span>
</span>
{matchedPath && (
<span className="scene-select-alias">{`(${matchedPath})`}</span>
)}
</span>
),
};
return <reactSelectComponents.Option {...thisOptionProps} />;
};
const SceneMultiValueLabel: React.FC<
MultiValueGenericProps<Option, boolean>
> = (optionProps) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
thisOptionProps = {
...optionProps,
children: objectTitle(object),
};
return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;
};
const SceneValueLabel: React.FC<SingleValueProps<Option, boolean>> = (
optionProps
) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
thisOptionProps = {
...optionProps,
children: <>{objectTitle(object)}</>,
};
return <reactSelectComponents.SingleValue {...thisOptionProps} />;
};
return (
<FilterSelectComponent<Scene, boolean>
{...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<Scene> & ExtraSceneProps
> = (props) => {
const { ids, onSelect: onSelectValues } = props;
const [values, setValues] = useState<Scene[]>([]);
const idsChanged = useCompare(ids);
function onSelect(items: Scene[]) {
setValues(items);
onSelectValues?.(items);
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Scene[]> {
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 <SceneSelect {...props} values={values} onSelect={onSelect} />;
};
export const SceneIDSelect = PatchComponent("SceneIDSelect", _SceneIDSelect);

View file

@ -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;
}
}

View file

@ -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<IReassignFilesDialogProps> = (
props: IReassignFilesDialogProps
) => {
const [scenes, setScenes] = useState<{ id: string; title: string }[]>([]);
const [scenes, setScenes] = useState<Scene[]>([]);
const intl = useIntl();
const singularEntity = intl.formatMessage({ id: "file" });
@ -89,7 +89,7 @@ export const ReassignFilesDialog: React.FC<IReassignFilesDialogProps> = (
})}
<Col sm={9} xl={12}>
<SceneSelect
selected={scenes}
values={scenes}
onSelect={(items) => setScenes(items)}
/>
</Col>

View file

@ -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 <GalleryIDSelect {...props} />;
};
export const SceneSelect: React.FC<ITitledSelect> = (props) => {
const [query, setQuery] = useState<string>("");
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<Option, boolean>) => {
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 (
<SelectComponent
onChange={onChange}
onInputChange={onInputChange}
isLoading={loading}
items={items}
selectedOptions={options}
isMulti={props.isMulti ?? false}
placeholder="Search for scene..."
noOptionsMessage={query === "" ? null : "No scenes found."}
showDropdown={false}
isDisabled={props.disabled}
/>
);
export const SceneSelect: React.FC<IFilterProps & { excludeIds?: string[] }> = (
props
) => {
return <SceneIDSelect {...props} />;
};
export const ImageSelect: React.FC<ITitledSelect> = (props) => {

View file

@ -166,6 +166,23 @@ export const queryFindScenesByID = (sceneIDs: number[]) =>
},
});
export const queryFindScenesForSelect = (filter: ListFilterModel) =>
client.query<GQL.FindScenesForSelectQuery>({
query: GQL.FindScenesForSelectDocument,
variables: {
filter: filter.makeFindFilter(),
scene_filter: filter.makeFilter(),
},
});
export const queryFindScenesByIDForSelect = (sceneIDs: string[]) =>
client.query<GQL.FindScenesForSelectQuery>({
query: GQL.FindScenesForSelectDocument,
variables: {
ids: sceneIDs,
},
});
export const querySceneByPathRegex = (filter: GQL.FindFilterType) =>
client.query<GQL.FindScenesByPathRegexQuery>({
query: GQL.FindScenesByPathRegexDocument,

View file

@ -672,6 +672,8 @@ declare namespace PluginApi {
GalleryIDSelect: React.FC<any>;
MovieSelect: React.FC<any>;
MovieIDSelect: React.FC<any>;
SceneSelect: React.FC<any>;
SceneIDSelect: React.FC<any>;
DateInput: React.FC<any>;
CountrySelect: React.FC<any>;
FolderSelect: React.FC<any>;