Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add gallery select filter and fix image gallery filtering #4535

Merged
merged 4 commits into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions graphql/schema/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ type Query {
findGalleries(
gallery_filter: GalleryFilterType
filter: FindFilterType
ids: [ID!]
): FindGalleriesResultType!

findTag(id: ID!): Tag
Expand Down
20 changes: 18 additions & 2 deletions internal/api/resolver_query_find_gallery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}
Expand Down
11 changes: 11 additions & 0 deletions ui/v2.5/graphql/data/gallery.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,14 @@ fragment GalleryData on Gallery {
...SlimSceneData
}
}

fragment SelectGalleryData on Gallery {
id
title
files {
path
}
folder {
path
}
}
13 changes: 13 additions & 0 deletions ui/v2.5/graphql/queries/gallery.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 222 additions & 0 deletions ui/v2.5/src/components/Galleries/GallerySelect.tsx
Original file line number Diff line number Diff line change
@@ -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} />;
};
6 changes: 6 additions & 0 deletions ui/v2.5/src/components/Galleries/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
14 changes: 12 additions & 2 deletions ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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),
}))
);
}
Expand Down
1 change: 1 addition & 0 deletions ui/v2.5/src/components/List/Filters/PerformersFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function usePerformerQuery(query: string) {
return sortByRelevance(
query,
data?.findPerformers.performers ?? [],
(p) => p.name,
(p) => p.alias_list
).map((p) => {
return {
Expand Down
1 change: 1 addition & 0 deletions ui/v2.5/src/components/List/Filters/StudiosFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function useStudioQuery(query: string) {
return sortByRelevance(
query,
data?.findStudios.studios ?? [],
(s) => s.name,
(s) => s.aliases
).map((p) => {
return {
Expand Down
1 change: 1 addition & 0 deletions ui/v2.5/src/components/List/Filters/TagsFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function useTagQuery(query: string) {
return sortByRelevance(
query,
data?.findTags.tags ?? [],
(t) => t.name,
(t) => t.aliases
).map((p) => {
return {
Expand Down
1 change: 1 addition & 0 deletions ui/v2.5/src/components/Performers/PerformerSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading