mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
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:
parent
79e72ff3bc
commit
9981574e82
20 changed files with 402 additions and 160 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,14 @@ fragment GalleryData on Gallery {
|
||||||
...SlimSceneData
|
...SlimSceneData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fragment SelectGalleryData on Gallery {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
files {
|
||||||
|
path
|
||||||
|
}
|
||||||
|
folder {
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
222
ui/v2.5/src/components/Galleries/GallerySelect.tsx
Normal file
222
ui/v2.5/src/components/Galleries/GallerySelect.tsx
Normal 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} />;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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,10 +183,16 @@ export const FilterSelectComponent = <
|
||||||
onSelect?.(selected.map((item) => item.object));
|
onSelect?.(selected.map((item) => item.object));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCreate = async (name: string) => {
|
const onCreate =
|
||||||
|
creatable && props.onCreate
|
||||||
|
? async (name: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { value, item: newItem, message } = await props.onCreate!(name);
|
const {
|
||||||
|
value,
|
||||||
|
item: newItem,
|
||||||
|
message,
|
||||||
|
} = await props.onCreate!(name);
|
||||||
const newItemOption = {
|
const newItemOption = {
|
||||||
object: newItem,
|
object: newItem,
|
||||||
value,
|
value,
|
||||||
|
|
@ -206,19 +213,22 @@ export const FilterSelectComponent = <
|
||||||
} 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 =
|
||||||
|
creatable && isValidNewOption
|
||||||
|
? (
|
||||||
inputValue: string,
|
inputValue: string,
|
||||||
value: Options<Option<T>>,
|
value: Options<Option<T>>,
|
||||||
options: OptionsOrGroups<Option<T>, GroupBase<Option<T>>>
|
options: OptionsOrGroups<Option<T>, GroupBase<Option<T>>>
|
||||||
|
|
@ -227,7 +237,8 @@ export const FilterSelectComponent = <
|
||||||
inputValue,
|
inputValue,
|
||||||
(options as Options<Option<T>>).map((o) => o.object)
|
(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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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,13 +546,16 @@ 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) {
|
||||||
|
case "performers":
|
||||||
return <PerformerSelect {...props} creatable={false} />;
|
return <PerformerSelect {...props} creatable={false} />;
|
||||||
} else if (props.type === "studios") {
|
case "studios":
|
||||||
return <StudioSelect {...props} creatable={false} />;
|
return <StudioSelect {...props} creatable={false} />;
|
||||||
} else if (props.type === "movies") {
|
case "movies":
|
||||||
return <MovieSelect {...props} creatable={false} />;
|
return <MovieSelect {...props} creatable={false} />;
|
||||||
} else {
|
case "galleries":
|
||||||
|
return <GallerySelect {...props} />;
|
||||||
|
default:
|
||||||
return <TagSelect {...props} creatable={false} />;
|
return <TagSelect {...props} creatable={false} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue