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(
|
||||
gallery_filter: GalleryFilterType
|
||||
filter: FindFilterType
|
||||
ids: [ID!]
|
||||
): FindGalleriesResultType!
|
||||
|
||||
findTag(id: ID!): Tag
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"strconv"
|
||||
|
||||
"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) {
|
||||
|
|
@ -23,9 +24,24 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models
|
|||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,3 +38,14 @@ fragment GalleryData on Gallery {
|
|||
...SlimSceneData
|
||||
}
|
||||
}
|
||||
|
||||
fragment SelectGalleryData on Gallery {
|
||||
id
|
||||
title
|
||||
files {
|
||||
path
|
||||
}
|
||||
folder {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,3 +15,16 @@ query FindGallery($id: ID!) {
|
|||
...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 {
|
||||
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 { Form } from "react-bootstrap";
|
||||
import { FilterSelect, SelectObject } from "src/components/Shared/Select";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { ILabeledId } from "src/models/list-filter/types";
|
||||
|
||||
|
|
@ -22,16 +23,25 @@ export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
|
|||
inputType !== "scene_tags" &&
|
||||
inputType !== "performer_tags" &&
|
||||
inputType !== "tags" &&
|
||||
inputType !== "movies"
|
||||
inputType !== "movies" &&
|
||||
inputType !== "galleries"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLabel(i: SelectObject) {
|
||||
if (inputType === "galleries") {
|
||||
return galleryTitle(i);
|
||||
}
|
||||
|
||||
return i.name ?? i.title ?? "";
|
||||
}
|
||||
|
||||
function onSelectionChanged(items: SelectObject[]) {
|
||||
onValueChanged(
|
||||
items.map((i) => ({
|
||||
id: i.id,
|
||||
label: i.name ?? i.title ?? "",
|
||||
label: getLabel(i),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function usePerformerQuery(query: string) {
|
|||
return sortByRelevance(
|
||||
query,
|
||||
data?.findPerformers.performers ?? [],
|
||||
(p) => p.name,
|
||||
(p) => p.alias_list
|
||||
).map((p) => {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function useStudioQuery(query: string) {
|
|||
return sortByRelevance(
|
||||
query,
|
||||
data?.findStudios.studios ?? [],
|
||||
(s) => s.name,
|
||||
(s) => s.aliases
|
||||
).map((p) => {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function useTagQuery(query: string) {
|
|||
return sortByRelevance(
|
||||
query,
|
||||
data?.findTags.tags ?? [],
|
||||
(t) => t.name,
|
||||
(t) => t.aliases
|
||||
).map((p) => {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export const PerformerSelect: React.FC<
|
|||
return sortByRelevance(
|
||||
input,
|
||||
query.data.findPerformers.performers,
|
||||
(p) => p.name,
|
||||
(p) => p.alias_list
|
||||
).map((performer) => ({
|
||||
value: performer.id,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
mutateReloadScrapers,
|
||||
queryScrapeSceneQueryFragment,
|
||||
} 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 { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
||||
import { ImageInput } from "src/components/Shared/ImageInput";
|
||||
|
|
@ -49,6 +49,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 { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect";
|
||||
|
||||
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
|
||||
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
|
||||
|
|
@ -73,9 +74,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
|
||||
const [galleries, setGalleries] = useState<{ id: string; title: string }[]>(
|
||||
[]
|
||||
);
|
||||
const [galleries, setGalleries] = useState<Gallery[]>([]);
|
||||
const [performers, setPerformers] = useState<Performer[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [studio, setStudio] = useState<Studio | null>(null);
|
||||
|
|
@ -95,6 +94,8 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
scene.galleries?.map((g) => ({
|
||||
id: g.id,
|
||||
title: galleryTitle(g),
|
||||
files: g.files,
|
||||
folder: g.folder,
|
||||
})) ?? []
|
||||
);
|
||||
}, [scene.galleries]);
|
||||
|
|
@ -188,12 +189,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
formik.setFieldValue("rating100", v);
|
||||
}
|
||||
|
||||
interface IGallerySelectValue {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function onSetGalleries(items: IGallerySelectValue[]) {
|
||||
function onSetGalleries(items: Gallery[]) {
|
||||
setGalleries(items);
|
||||
formik.setFieldValue(
|
||||
"gallery_ids",
|
||||
|
|
@ -725,7 +721,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
const title = intl.formatMessage({ id: "galleries" });
|
||||
const control = (
|
||||
<GallerySelect
|
||||
selected={galleries}
|
||||
values={galleries}
|
||||
onSelect={(items) => onSetGalleries(items)}
|
||||
isMulti
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { Icon } from "../Shared/Icon";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
|
|
@ -20,7 +20,6 @@ import {
|
|||
ScrapedTextAreaRow,
|
||||
} from "../Shared/ScrapeDialog/ScrapeDialog";
|
||||
import { clone, uniq } from "lodash-es";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { IHasStoredID, sortStoredIdObjects } from "src/utils/data";
|
||||
|
|
@ -302,34 +301,6 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
|
|||
loadImages();
|
||||
}, [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
|
||||
const hasValues = useMemo(() => {
|
||||
return hasScrapedValues([
|
||||
|
|
@ -492,17 +463,19 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
|
|||
renderOriginalField={() => (
|
||||
<GallerySelect
|
||||
className="form-control react-select"
|
||||
selected={originalGalleries ?? []}
|
||||
ids={galleries.originalValue ?? []}
|
||||
onSelect={() => {}}
|
||||
disabled
|
||||
isMulti
|
||||
isDisabled
|
||||
/>
|
||||
)}
|
||||
renderNewField={() => (
|
||||
<GallerySelect
|
||||
className="form-control react-select"
|
||||
selected={newGalleries ?? []}
|
||||
ids={galleries.newValue ?? []}
|
||||
onSelect={() => {}}
|
||||
disabled
|
||||
isMulti
|
||||
isDisabled
|
||||
/>
|
||||
)}
|
||||
onChange={(value) => setGalleries(value)}
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ export interface IFilterComponentProps<T> extends IFilterProps {
|
|||
onCreate?: (
|
||||
name: string
|
||||
) => Promise<{ value: string; item: T; message: string }>;
|
||||
getNamedObject: (id: string, name: string) => T;
|
||||
isValidNewOption: (inputValue: string, options: T[]) => boolean;
|
||||
getNamedObject?: (id: string, name: string) => T;
|
||||
isValidNewOption?: (inputValue: string, options: T[]) => boolean;
|
||||
}
|
||||
|
||||
export const FilterSelectComponent = <
|
||||
|
|
@ -150,6 +150,7 @@ export const FilterSelectComponent = <
|
|||
values,
|
||||
isMulti,
|
||||
onSelect,
|
||||
creatable = false,
|
||||
isValidNewOption,
|
||||
getNamedObject,
|
||||
loadOptions,
|
||||
|
|
@ -182,52 +183,62 @@ export const FilterSelectComponent = <
|
|||
onSelect?.(selected.map((item) => item.object));
|
||||
};
|
||||
|
||||
const onCreate = async (name: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { value, item: newItem, message } = await props.onCreate!(name);
|
||||
const newItemOption = {
|
||||
object: newItem,
|
||||
value,
|
||||
} as Option<T>;
|
||||
if (!isMulti) {
|
||||
onChange(newItemOption);
|
||||
} else {
|
||||
const o = (selectedOptions ?? []) as Option<T>[];
|
||||
onChange([...o, newItemOption]);
|
||||
}
|
||||
const onCreate =
|
||||
creatable && props.onCreate
|
||||
? async (name: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const {
|
||||
value,
|
||||
item: newItem,
|
||||
message,
|
||||
} = await props.onCreate!(name);
|
||||
const newItemOption = {
|
||||
object: newItem,
|
||||
value,
|
||||
} as Option<T>;
|
||||
if (!isMulti) {
|
||||
onChange(newItemOption);
|
||||
} else {
|
||||
const o = (selectedOptions ?? []) as Option<T>[];
|
||||
onChange([...o, newItemOption]);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
Toast.success(
|
||||
<span>
|
||||
{message}: <b>{name}</b>
|
||||
</span>
|
||||
);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
};
|
||||
setLoading(false);
|
||||
Toast.success(
|
||||
<span>
|
||||
{message}: <b>{name}</b>
|
||||
</span>
|
||||
);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const getNewOptionData = (
|
||||
inputValue: string,
|
||||
optionLabel: React.ReactNode
|
||||
) => {
|
||||
return {
|
||||
value: "",
|
||||
object: getNamedObject("", optionLabel as string),
|
||||
};
|
||||
};
|
||||
const getNewOptionData =
|
||||
creatable && getNamedObject
|
||||
? (inputValue: string, optionLabel: React.ReactNode) => {
|
||||
return {
|
||||
value: "",
|
||||
object: getNamedObject("", optionLabel as string),
|
||||
};
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const validNewOption = (
|
||||
inputValue: string,
|
||||
value: Options<Option<T>>,
|
||||
options: OptionsOrGroups<Option<T>, GroupBase<Option<T>>>
|
||||
) => {
|
||||
return isValidNewOption(
|
||||
inputValue,
|
||||
(options as Options<Option<T>>).map((o) => o.object)
|
||||
);
|
||||
};
|
||||
const validNewOption =
|
||||
creatable && isValidNewOption
|
||||
? (
|
||||
inputValue: string,
|
||||
value: Options<Option<T>>,
|
||||
options: OptionsOrGroups<Option<T>, GroupBase<Option<T>>>
|
||||
) => {
|
||||
return isValidNewOption(
|
||||
inputValue,
|
||||
(options as Options<Option<T>>).map((o) => o.object)
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const debounceDelay = 100;
|
||||
const debounceLoadOptions = useDebounce((inputValue, callback) => {
|
||||
|
|
@ -241,7 +252,7 @@ export const FilterSelectComponent = <
|
|||
isLoading={props.isLoading || loading}
|
||||
onChange={onChange}
|
||||
selectedOptions={selectedOptions}
|
||||
onCreateOption={props.creatable ? onCreate : undefined}
|
||||
onCreateOption={onCreate}
|
||||
getNewOptionData={getNewOptionData}
|
||||
isValidNewOption={validNewOption}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import { SelectComponents } from "react-select/dist/declarations/src/components"
|
|||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { useIntl } from "react-intl";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import { defaultMaxOptionsShown } from "src/core/config";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||
|
|
@ -32,6 +31,7 @@ import { Icon } from "./Icon";
|
|||
import { faTableColumns } from "@fortawesome/free-solid-svg-icons";
|
||||
import { TagIDSelect } from "../Tags/TagSelect";
|
||||
import { StudioIDSelect } from "../Studios/StudioSelect";
|
||||
import { GalleryIDSelect } from "../Galleries/GallerySelect";
|
||||
|
||||
export type SelectObject = {
|
||||
id: string;
|
||||
|
|
@ -47,7 +47,8 @@ interface ITypeProps {
|
|||
| "tags"
|
||||
| "scene_tags"
|
||||
| "performer_tags"
|
||||
| "movies";
|
||||
| "movies"
|
||||
| "galleries";
|
||||
}
|
||||
interface IFilterProps {
|
||||
ids?: string[];
|
||||
|
|
@ -333,55 +334,10 @@ const FilterSelectComponent = <T extends boolean>(
|
|||
);
|
||||
};
|
||||
|
||||
export const GallerySelect: React.FC<ITitledSelect> = (props) => {
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const { data, loading } = GQL.useFindGalleriesQuery({
|
||||
skip: query === "",
|
||||
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 GallerySelect: React.FC<
|
||||
IFilterProps & { excludeIds?: string[] }
|
||||
> = (props) => {
|
||||
return <GalleryIDSelect {...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) => {
|
||||
if (props.type === "performers") {
|
||||
return <PerformerSelect {...props} creatable={false} />;
|
||||
} else if (props.type === "studios") {
|
||||
return <StudioSelect {...props} creatable={false} />;
|
||||
} else if (props.type === "movies") {
|
||||
return <MovieSelect {...props} creatable={false} />;
|
||||
} else {
|
||||
return <TagSelect {...props} creatable={false} />;
|
||||
switch (props.type) {
|
||||
case "performers":
|
||||
return <PerformerSelect {...props} creatable={false} />;
|
||||
case "studios":
|
||||
return <StudioSelect {...props} creatable={false} />;
|
||||
case "movies":
|
||||
return <MovieSelect {...props} creatable={false} />;
|
||||
case "galleries":
|
||||
return <GallerySelect {...props} />;
|
||||
default:
|
||||
return <TagSelect {...props} creatable={false} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,12 @@ export const StudioSelect: React.FC<
|
|||
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,
|
||||
object: studio,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -70,7 +70,12 @@ export const TagSelect: React.FC<
|
|||
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,
|
||||
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) => {
|
||||
const skip = id === "new" || id === "";
|
||||
return GQL.useFindPerformerQuery({ variables: { id }, skip });
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from "./criteria/tags";
|
||||
import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
|
||||
import { DisplayMode } from "./types";
|
||||
import { GalleriesCriterionOption } from "./criteria/galleries";
|
||||
|
||||
const defaultSortBy = "path";
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ const criterionOptions = [
|
|||
createStringCriterionOption("photographer"),
|
||||
createMandatoryStringCriterionOption("checksum", "media_info.checksum"),
|
||||
PathCriterionOption,
|
||||
GalleriesCriterionOption,
|
||||
OrganizedCriterionOption,
|
||||
createMandatoryNumberCriterionOption("o_counter"),
|
||||
ResolutionCriterionOption,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
interface ISortable {
|
||||
id: string;
|
||||
name: 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>(
|
||||
query: string,
|
||||
value: T[],
|
||||
getName: (o: T) => string,
|
||||
getAliases?: (o: T) => string[] | undefined
|
||||
) {
|
||||
if (!query) {
|
||||
|
|
@ -89,7 +89,7 @@ export function sortByRelevance<T extends ISortable>(
|
|||
}
|
||||
|
||||
function getWords(o: T) {
|
||||
return o.name.toLowerCase().split(" ");
|
||||
return getName(o).toLowerCase().split(" ");
|
||||
}
|
||||
|
||||
function getAliasWords(tag: T) {
|
||||
|
|
@ -170,8 +170,8 @@ export function sortByRelevance<T extends ISortable>(
|
|||
}
|
||||
|
||||
function compare(a: T, b: T) {
|
||||
const aName = a.name.toLowerCase();
|
||||
const bName = b.name.toLowerCase();
|
||||
const aName = getName(a).toLowerCase();
|
||||
const bName = getName(b).toLowerCase();
|
||||
|
||||
const aAlias = aliasMatches(a);
|
||||
const bAlias = aliasMatches(b);
|
||||
|
|
|
|||
Loading…
Reference in a new issue