mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
* Rebuild Studio page by splitting view and edit * Fix parent studio id, open studio link in same tab Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
607 lines
15 KiB
TypeScript
607 lines
15 KiB
TypeScript
import React, { useEffect, useMemo, useState } from "react";
|
|
import Select, {
|
|
ValueType,
|
|
Styles,
|
|
OptionProps,
|
|
components as reactSelectComponents,
|
|
GroupedOptionsType,
|
|
OptionsType,
|
|
} from "react-select";
|
|
import CreatableSelect from "react-select/creatable";
|
|
import { debounce } from "lodash";
|
|
|
|
import * as GQL from "src/core/generated-graphql";
|
|
import {
|
|
useAllTagsForFilter,
|
|
useAllMoviesForFilter,
|
|
useAllStudiosForFilter,
|
|
useAllPerformersForFilter,
|
|
useMarkerStrings,
|
|
useTagCreate,
|
|
useStudioCreate,
|
|
usePerformerCreate,
|
|
} from "src/core/StashService";
|
|
import { useToast } from "src/hooks";
|
|
import { TextUtils } from "src/utils";
|
|
import { SelectComponents } from "react-select/src/components";
|
|
|
|
export type ValidTypes =
|
|
| GQL.SlimPerformerDataFragment
|
|
| GQL.SlimTagDataFragment
|
|
| GQL.SlimStudioDataFragment
|
|
| GQL.SlimMovieDataFragment;
|
|
type Option = { value: string; label: string };
|
|
|
|
interface ITypeProps {
|
|
type?:
|
|
| "performers"
|
|
| "studios"
|
|
| "parent_studios"
|
|
| "tags"
|
|
| "sceneTags"
|
|
| "performerTags"
|
|
| "movies";
|
|
}
|
|
interface IFilterProps {
|
|
ids?: string[];
|
|
initialIds?: string[];
|
|
onSelect?: (item: ValidTypes[]) => void;
|
|
noSelectionString?: string;
|
|
className?: string;
|
|
isMulti?: boolean;
|
|
isClearable?: boolean;
|
|
isDisabled?: boolean;
|
|
creatable?: boolean;
|
|
menuPortalTarget?: HTMLElement | null;
|
|
}
|
|
interface ISelectProps<T extends boolean> {
|
|
className?: string;
|
|
items: Option[];
|
|
selectedOptions?: ValueType<Option, T>;
|
|
creatable?: boolean;
|
|
onCreateOption?: (value: string) => void;
|
|
isLoading: boolean;
|
|
isDisabled?: boolean;
|
|
onChange: (item: ValueType<Option, T>) => void;
|
|
initialIds?: string[];
|
|
isMulti: T;
|
|
isClearable?: boolean;
|
|
onInputChange?: (input: string) => void;
|
|
components?: Partial<SelectComponents<Option, T>>;
|
|
filterOption?: (option: Option, rawInput: string) => boolean;
|
|
isValidNewOption?: (
|
|
inputValue: string,
|
|
value: ValueType<Option, T>,
|
|
options: OptionsType<Option> | GroupedOptionsType<Option>
|
|
) => boolean;
|
|
placeholder?: string;
|
|
showDropdown?: boolean;
|
|
groupHeader?: string;
|
|
menuPortalTarget?: HTMLElement | null;
|
|
closeMenuOnSelect?: boolean;
|
|
noOptionsMessage?: string | null;
|
|
}
|
|
interface IFilterComponentProps extends IFilterProps {
|
|
items: Array<ValidTypes>;
|
|
onCreate?: (name: string) => Promise<{ item: ValidTypes; message: string }>;
|
|
}
|
|
interface IFilterSelectProps<T extends boolean>
|
|
extends Omit<ISelectProps<T>, "onChange" | "items" | "onCreateOption"> {}
|
|
|
|
type Gallery = { id: string; title: string };
|
|
interface IGallerySelect {
|
|
galleries: Gallery[];
|
|
onSelect: (items: Gallery[]) => void;
|
|
}
|
|
|
|
type Scene = { id: string; title: string };
|
|
interface ISceneSelect {
|
|
scenes: Scene[];
|
|
onSelect: (items: Scene[]) => void;
|
|
}
|
|
|
|
const getSelectedItems = (selectedItems: ValueType<Option, boolean>) =>
|
|
selectedItems
|
|
? Array.isArray(selectedItems)
|
|
? selectedItems
|
|
: [selectedItems]
|
|
: [];
|
|
|
|
const getSelectedValues = (selectedItems: ValueType<Option, boolean>) =>
|
|
getSelectedItems(selectedItems).map((item) => item.value);
|
|
|
|
const SelectComponent = <T extends boolean>({
|
|
type,
|
|
initialIds,
|
|
onChange,
|
|
className,
|
|
items,
|
|
selectedOptions,
|
|
isLoading,
|
|
isDisabled = false,
|
|
onCreateOption,
|
|
isClearable = true,
|
|
creatable = false,
|
|
isMulti,
|
|
onInputChange,
|
|
filterOption,
|
|
isValidNewOption,
|
|
components,
|
|
placeholder,
|
|
showDropdown = true,
|
|
groupHeader,
|
|
menuPortalTarget,
|
|
closeMenuOnSelect = true,
|
|
noOptionsMessage = type !== "tags" ? "None" : null,
|
|
}: ISelectProps<T> & ITypeProps) => {
|
|
const values = items.filter((item) => initialIds?.indexOf(item.value) !== -1);
|
|
const defaultValue = (isMulti ? values : values[0] ?? null) as ValueType<
|
|
Option,
|
|
T
|
|
>;
|
|
|
|
const options = groupHeader
|
|
? [
|
|
{
|
|
label: groupHeader,
|
|
options: items,
|
|
},
|
|
]
|
|
: items;
|
|
|
|
const styles: Partial<Styles<Option, T>> = {
|
|
option: (base) => ({
|
|
...base,
|
|
color: "#000",
|
|
}),
|
|
container: (base, props) => ({
|
|
...base,
|
|
zIndex: props.selectProps.isFocused ? 10 : base.zIndex,
|
|
}),
|
|
multiValueRemove: (base, props) => ({
|
|
...base,
|
|
color: props.selectProps.isFocused ? base.color : "#333333",
|
|
}),
|
|
};
|
|
|
|
const props = {
|
|
options,
|
|
value: selectedOptions,
|
|
className,
|
|
classNamePrefix: "react-select",
|
|
onChange,
|
|
isMulti,
|
|
isClearable,
|
|
defaultValue: defaultValue ?? undefined,
|
|
noOptionsMessage: () => noOptionsMessage,
|
|
placeholder: isDisabled ? "" : placeholder,
|
|
onInputChange,
|
|
filterOption,
|
|
isValidNewOption,
|
|
isDisabled,
|
|
isLoading,
|
|
styles,
|
|
closeMenuOnSelect,
|
|
menuPortalTarget,
|
|
components: {
|
|
...components,
|
|
IndicatorSeparator: () => null,
|
|
...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }),
|
|
...(isDisabled && { MultiValueRemove: () => null }),
|
|
},
|
|
};
|
|
|
|
return creatable ? (
|
|
<CreatableSelect
|
|
{...props}
|
|
isDisabled={isLoading || isDisabled}
|
|
onCreateOption={onCreateOption}
|
|
/>
|
|
) : (
|
|
<Select {...props} />
|
|
);
|
|
};
|
|
|
|
const FilterSelectComponent = <T extends boolean>(
|
|
props: IFilterComponentProps & ITypeProps & IFilterSelectProps<T>
|
|
) => {
|
|
const { items, ids, isMulti, onSelect } = props;
|
|
const [loading, setLoading] = useState(false);
|
|
const selectedIds = ids ?? [];
|
|
const Toast = useToast();
|
|
|
|
const options = items.map((i) => ({
|
|
value: i.id,
|
|
label: i.name ?? "",
|
|
}));
|
|
|
|
const selected = options.filter((option) =>
|
|
selectedIds.includes(option.value)
|
|
);
|
|
const selectedOptions = (isMulti
|
|
? selected
|
|
: selected[0] ?? null) as ValueType<Option, T>;
|
|
|
|
const onChange = (selectedItems: ValueType<Option, boolean>) => {
|
|
const selectedValues = getSelectedValues(selectedItems);
|
|
onSelect?.(items.filter((item) => selectedValues.includes(item.id)));
|
|
};
|
|
|
|
const onCreate = async (name: string) => {
|
|
try {
|
|
setLoading(true);
|
|
const { item: newItem, message } = await props.onCreate!(name);
|
|
props.onSelect?.([
|
|
...items.filter((item) => selectedIds.includes(item.id)),
|
|
newItem,
|
|
]);
|
|
setLoading(false);
|
|
Toast.success({
|
|
content: (
|
|
<span>
|
|
{message}: <b>{name}</b>
|
|
</span>
|
|
),
|
|
});
|
|
} catch (e) {
|
|
Toast.error(e);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<SelectComponent<T>
|
|
{...props}
|
|
isLoading={props.isLoading || loading}
|
|
onChange={onChange}
|
|
items={options}
|
|
selectedOptions={selectedOptions}
|
|
onCreateOption={props.creatable ? onCreate : undefined}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const GallerySelect: React.FC<IGallerySelect> = (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: g.title ?? TextUtils.fileNameFromPath(g.path ?? ""),
|
|
value: g.id,
|
|
}));
|
|
|
|
const onInputChange = debounce((input: string) => {
|
|
setQuery(input);
|
|
}, 500);
|
|
|
|
const onChange = (selectedItems: ValueType<Option, boolean>) => {
|
|
const selected = getSelectedItems(selectedItems);
|
|
props.onSelect(
|
|
selected.map((s) => ({
|
|
id: s.value,
|
|
title: s.label,
|
|
}))
|
|
);
|
|
};
|
|
|
|
const options = props.galleries.map((g) => ({
|
|
value: g.id,
|
|
label: g.title ?? "Unknown",
|
|
}));
|
|
|
|
return (
|
|
<SelectComponent
|
|
onChange={onChange}
|
|
onInputChange={onInputChange}
|
|
isLoading={loading}
|
|
items={items}
|
|
selectedOptions={options}
|
|
isMulti
|
|
placeholder="Search for gallery..."
|
|
noOptionsMessage={query === "" ? null : "No galleries found."}
|
|
showDropdown={false}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const SceneSelect: React.FC<ISceneSelect> = (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: s.title ?? TextUtils.fileNameFromPath(s.path ?? ""),
|
|
value: s.id,
|
|
}));
|
|
|
|
const onInputChange = debounce((input: string) => {
|
|
setQuery(input);
|
|
}, 500);
|
|
|
|
const onChange = (selectedItems: ValueType<Option, true>) => {
|
|
const selected = getSelectedItems(selectedItems);
|
|
props.onSelect(
|
|
(selected ?? []).map((s) => ({
|
|
id: s.value,
|
|
title: s.label,
|
|
}))
|
|
);
|
|
};
|
|
|
|
const options = props.scenes.map((s) => ({
|
|
value: s.id,
|
|
label: s.title,
|
|
}));
|
|
|
|
return (
|
|
<SelectComponent
|
|
onChange={onChange}
|
|
onInputChange={onInputChange}
|
|
isLoading={loading}
|
|
items={items}
|
|
selectedOptions={options}
|
|
isMulti
|
|
placeholder="Search for scene..."
|
|
noOptionsMessage={query === "" ? null : "No scenes found."}
|
|
showDropdown={false}
|
|
/>
|
|
);
|
|
};
|
|
|
|
interface IMarkerSuggestProps {
|
|
initialMarkerTitle?: string;
|
|
onChange: (title: string) => void;
|
|
}
|
|
export const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = (props) => {
|
|
const { data, loading } = useMarkerStrings();
|
|
const suggestions = data?.markerStrings ?? [];
|
|
|
|
const onChange = (selectedItem: ValueType<Option, false>) =>
|
|
props.onChange(selectedItem?.value ?? "");
|
|
|
|
const items = suggestions.map((item) => ({
|
|
label: item?.title ?? "",
|
|
value: item?.title ?? "",
|
|
}));
|
|
const initialIds = props.initialMarkerTitle ? [props.initialMarkerTitle] : [];
|
|
return (
|
|
<SelectComponent
|
|
isMulti={false}
|
|
creatable
|
|
onChange={onChange}
|
|
isLoading={loading}
|
|
items={items}
|
|
initialIds={initialIds}
|
|
placeholder="Marker title..."
|
|
className="select-suggest"
|
|
showDropdown={false}
|
|
groupHeader="Previously used titles..."
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const PerformerSelect: React.FC<IFilterProps> = (props) => {
|
|
const { data, loading } = useAllPerformersForFilter();
|
|
const [createPerformer] = usePerformerCreate();
|
|
|
|
const performers = data?.allPerformers ?? [];
|
|
|
|
const onCreate = async (name: string) => {
|
|
const result = await createPerformer({
|
|
variables: { input: { name } },
|
|
});
|
|
return {
|
|
item: result.data!.performerCreate!,
|
|
message: "Created performer",
|
|
};
|
|
};
|
|
|
|
return (
|
|
<FilterSelectComponent
|
|
{...props}
|
|
isMulti={props.isMulti ?? false}
|
|
creatable={props.creatable ?? true}
|
|
onCreate={onCreate}
|
|
type="performers"
|
|
isLoading={loading}
|
|
items={performers}
|
|
placeholder={props.noSelectionString ?? "Select performer..."}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const StudioSelect: React.FC<
|
|
IFilterProps & { excludeIds?: string[] }
|
|
> = (props) => {
|
|
const { data, loading } = useAllStudiosForFilter();
|
|
const [createStudio] = useStudioCreate();
|
|
|
|
const exclude = props.excludeIds ?? [];
|
|
const studios = (data?.allStudios ?? []).filter(
|
|
(studio) => !exclude.includes(studio.id)
|
|
);
|
|
|
|
const onCreate = async (name: string) => {
|
|
const result = await createStudio({
|
|
variables: {
|
|
input: { name },
|
|
},
|
|
});
|
|
return { item: result.data!.studioCreate!, message: "Created studio" };
|
|
};
|
|
|
|
return (
|
|
<FilterSelectComponent
|
|
{...props}
|
|
isMulti={props.isMulti ?? false}
|
|
type="studios"
|
|
isLoading={loading}
|
|
items={studios}
|
|
placeholder={props.noSelectionString ?? "Select studio..."}
|
|
creatable={props.creatable ?? true}
|
|
onCreate={onCreate}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const MovieSelect: React.FC<IFilterProps> = (props) => {
|
|
const { data, loading } = useAllMoviesForFilter();
|
|
const items = data?.allMovies ?? [];
|
|
|
|
return (
|
|
<FilterSelectComponent
|
|
{...props}
|
|
isMulti={props.isMulti ?? false}
|
|
type="movies"
|
|
isLoading={loading}
|
|
items={items}
|
|
placeholder={props.noSelectionString ?? "Select movie..."}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const TagSelect: React.FC<IFilterProps & { excludeIds?: string[] }> = (
|
|
props
|
|
) => {
|
|
const [tagAliases, setTagAliases] = useState<Record<string, string[]>>({});
|
|
const [allAliases, setAllAliases] = useState<string[]>([]);
|
|
const { data, loading } = useAllTagsForFilter();
|
|
const [createTag] = useTagCreate();
|
|
const placeholder = props.noSelectionString ?? "Select tags...";
|
|
|
|
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
|
|
const tags = useMemo(
|
|
() => (data?.allTags ?? []).filter((tag) => !exclude.includes(tag.id)),
|
|
[data?.allTags, exclude]
|
|
);
|
|
|
|
useEffect(() => {
|
|
// build the tag aliases map
|
|
const newAliases: Record<string, string[]> = {};
|
|
const newAll: string[] = [];
|
|
tags.forEach((t) => {
|
|
newAliases[t.id] = t.aliases;
|
|
newAll.push(...t.aliases);
|
|
});
|
|
setTagAliases(newAliases);
|
|
setAllAliases(newAll);
|
|
}, [tags]);
|
|
|
|
const TagOption: React.FC<OptionProps<Option, boolean>> = (optionProps) => {
|
|
const { inputValue } = optionProps.selectProps;
|
|
|
|
let thisOptionProps = optionProps;
|
|
if (
|
|
inputValue &&
|
|
!optionProps.label.toLowerCase().includes(inputValue.toLowerCase())
|
|
) {
|
|
// must be alias
|
|
const newLabel = `${optionProps.data.label} (alias)`;
|
|
thisOptionProps = {
|
|
...optionProps,
|
|
children: newLabel,
|
|
};
|
|
}
|
|
|
|
return <reactSelectComponents.Option {...thisOptionProps} />;
|
|
};
|
|
|
|
const filterOption = (option: Option, rawInput: string): boolean => {
|
|
if (!rawInput) {
|
|
return true;
|
|
}
|
|
|
|
const input = rawInput.toLowerCase();
|
|
const optionVal = option.label.toLowerCase();
|
|
|
|
if (optionVal.includes(input)) {
|
|
return true;
|
|
}
|
|
|
|
// search for tag aliases
|
|
const aliases = tagAliases[option.value];
|
|
// only match on alias if exact
|
|
if (aliases && aliases.some((a) => a.toLowerCase() === input)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const onCreate = async (name: string) => {
|
|
const result = await createTag({
|
|
variables: {
|
|
input: {
|
|
name,
|
|
},
|
|
},
|
|
});
|
|
return { item: result.data!.tagCreate!, message: "Created tag" };
|
|
};
|
|
|
|
const isValidNewOption = (
|
|
inputValue: string,
|
|
value: ValueType<Option, boolean>,
|
|
options: OptionsType<Option> | GroupedOptionsType<Option>
|
|
) => {
|
|
if (!inputValue) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
(options as OptionsType<Option>).some((o: Option) => {
|
|
return o.label.toLowerCase() === inputValue.toLowerCase();
|
|
})
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (allAliases.some((a) => a.toLowerCase() === inputValue.toLowerCase())) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
return (
|
|
<FilterSelectComponent
|
|
{...props}
|
|
filterOption={filterOption}
|
|
isValidNewOption={isValidNewOption}
|
|
components={{ Option: TagOption }}
|
|
isMulti={props.isMulti ?? false}
|
|
items={tags}
|
|
creatable={props.creatable ?? true}
|
|
type="tags"
|
|
placeholder={placeholder}
|
|
isLoading={loading}
|
|
onCreate={onCreate}
|
|
closeMenuOnSelect={!props.isMulti}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) =>
|
|
props.type === "performers" ? (
|
|
<PerformerSelect {...props} creatable={false} />
|
|
) : props.type === "studios" || props.type === "parent_studios" ? (
|
|
<StudioSelect {...props} creatable={false} />
|
|
) : props.type === "movies" ? (
|
|
<MovieSelect {...props} creatable={false} />
|
|
) : (
|
|
<TagSelect {...props} creatable={false} />
|
|
);
|