Skip to content

Commit

Permalink
Add gallery select filter and fix image gallery filtering (#4535)
Browse files Browse the repository at this point in the history
* Accept gallery ids in findGalleries
* Add gallery select component
* Add and fix image gallery filter
* Show gallery path as alias
  • Loading branch information
WithoutPants authored Feb 9, 2024
1 parent 79e72ff commit 9981574
Show file tree
Hide file tree
Showing 20 changed files with 403 additions and 161 deletions.
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

0 comments on commit 9981574

Please sign in to comment.