mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Allow adding performer & studio from scenes page (#663)
* Allow adding performer & studio from scenes page Adds "create" options for performer and studio select in SceneEditPanel. Adds new FilterSelectComponent to reduce duplicate logic in selects. Make invalidateQueries case insensitive so we can pass upper-case query names that also work with refetchQueries
This commit is contained in:
@@ -13,6 +13,7 @@ const markup = `
|
|||||||
* Add support for parent/child studios.
|
* Add support for parent/child studios.
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Allow adding performers and studios from selectors.
|
||||||
* Add support for chrome dp in xpath scrapers.
|
* Add support for chrome dp in xpath scrapers.
|
||||||
* Allow customisation of preview video generation.
|
* Allow customisation of preview video generation.
|
||||||
* Add support for live transcoding in Safari.
|
* Add support for live transcoding in Safari.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
useScrapePerformerList,
|
useScrapePerformerList,
|
||||||
useValidGalleriesForScene,
|
useValidGalleriesForScene,
|
||||||
useTagCreate,
|
useTagCreate,
|
||||||
|
useStudioCreate,
|
||||||
|
usePerformerCreate,
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import { useToast } from "src/hooks";
|
import { useToast } from "src/hooks";
|
||||||
|
|
||||||
@@ -59,6 +61,16 @@ interface ISelectProps {
|
|||||||
groupHeader?: string;
|
groupHeader?: string;
|
||||||
closeMenuOnSelect?: boolean;
|
closeMenuOnSelect?: boolean;
|
||||||
}
|
}
|
||||||
|
interface IFilterItem {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
}
|
||||||
|
interface IFilterComponentProps extends IFilterProps {
|
||||||
|
items: Array<IFilterItem>;
|
||||||
|
onCreate?: (name: string) => Promise<{ item: IFilterItem; message: string }>;
|
||||||
|
}
|
||||||
|
interface IFilterSelectProps
|
||||||
|
extends Omit<ISelectProps, "onChange" | "items" | "onCreateOption"> {}
|
||||||
|
|
||||||
interface ISceneGallerySelect {
|
interface ISceneGallerySelect {
|
||||||
initialId?: string;
|
initialId?: string;
|
||||||
@@ -191,180 +203,151 @@ export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) =>
|
|||||||
|
|
||||||
export const PerformerSelect: React.FC<IFilterProps> = (props) => {
|
export const PerformerSelect: React.FC<IFilterProps> = (props) => {
|
||||||
const { data, loading } = useAllPerformersForFilter();
|
const { data, loading } = useAllPerformersForFilter();
|
||||||
|
const [createPerformer] = usePerformerCreate();
|
||||||
|
|
||||||
const normalizedData = data?.allPerformersSlim ?? [];
|
const performers = data?.allPerformersSlim ?? [];
|
||||||
const items: Option[] = normalizedData.map((item) => ({
|
|
||||||
value: item.id,
|
|
||||||
label: item.name ?? "",
|
|
||||||
}));
|
|
||||||
const placeholder = props.noSelectionString ?? "Select performer...";
|
|
||||||
const selectedOptions: Option[] = props.ids
|
|
||||||
? items.filter((item) => props.ids?.indexOf(item.value) !== -1)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const onChange = (selectedItems: ValueType<Option>) => {
|
const onCreate = async (name: string) => {
|
||||||
const selectedIds = getSelectedValues(selectedItems);
|
const result = await createPerformer({
|
||||||
props.onSelect?.(
|
variables: { name },
|
||||||
normalizedData.filter((item) => selectedIds.indexOf(item.id) !== -1)
|
});
|
||||||
);
|
return {
|
||||||
|
item: result.data!.performerCreate!,
|
||||||
|
message: "Created performer",
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectComponent
|
<FilterSelectComponent
|
||||||
{...props}
|
{...props}
|
||||||
selectedOptions={selectedOptions}
|
creatable
|
||||||
onChange={onChange}
|
onCreate={onCreate}
|
||||||
type="performers"
|
type="performers"
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
items={items}
|
items={performers}
|
||||||
placeholder={placeholder}
|
placeholder={props.noSelectionString ?? "Select performer..."}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StudioSelect: React.FC<IFilterProps> = (props) => {
|
export const StudioSelect: React.FC<IFilterProps> = (props) => {
|
||||||
const { data, loading } = useAllStudiosForFilter();
|
const { data, loading } = useAllStudiosForFilter();
|
||||||
|
const [createStudio] = useStudioCreate({ name: "" });
|
||||||
|
|
||||||
const normalizedData = data?.allStudiosSlim ?? [];
|
const studios = data?.allStudiosSlim ?? [];
|
||||||
|
|
||||||
const items = (normalizedData.length > 0
|
const onCreate = async (name: string) => {
|
||||||
? [{ name: "None", id: "0" }, ...normalizedData]
|
const result = await createStudio({ variables: { name } });
|
||||||
: []
|
return { item: result.data!.studioCreate!, message: "Created studio" };
|
||||||
).map((item) => ({
|
|
||||||
value: item.id,
|
|
||||||
label: item.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const placeholder = props.noSelectionString ?? "Select studio...";
|
|
||||||
const selectedOptions: Option[] = props.ids
|
|
||||||
? items.filter((item) => props.ids?.indexOf(item.value) !== -1)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const onChange = (selectedItems: ValueType<Option>) => {
|
|
||||||
const selectedIds = getSelectedValues(selectedItems);
|
|
||||||
props.onSelect?.(
|
|
||||||
normalizedData.filter((item) => selectedIds.indexOf(item.id) !== -1)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectComponent
|
<FilterSelectComponent
|
||||||
{...props}
|
{...props}
|
||||||
onChange={onChange}
|
|
||||||
type="studios"
|
type="studios"
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
items={items}
|
items={studios}
|
||||||
placeholder={placeholder}
|
placeholder={props.noSelectionString ?? "Select studio..."}
|
||||||
selectedOptions={selectedOptions}
|
creatable
|
||||||
|
onCreate={onCreate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MovieSelect: React.FC<IFilterProps> = (props) => {
|
export const MovieSelect: React.FC<IFilterProps> = (props) => {
|
||||||
const { data, loading } = useAllMoviesForFilter();
|
const { data, loading } = useAllMoviesForFilter();
|
||||||
|
const items = data?.allMoviesSlim ?? [];
|
||||||
const normalizedData = data?.allMoviesSlim ?? [];
|
|
||||||
|
|
||||||
const items = (normalizedData.length > 0
|
|
||||||
? [{ name: "None", id: "0" }, ...normalizedData]
|
|
||||||
: []
|
|
||||||
).map((item) => ({
|
|
||||||
value: item.id,
|
|
||||||
label: item.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const placeholder = props.noSelectionString ?? "Select movie...";
|
|
||||||
const selectedOptions: Option[] = props.ids
|
|
||||||
? items.filter((item) => props.ids?.indexOf(item.value) !== -1)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const onChange = (selectedItems: ValueType<Option>) => {
|
|
||||||
const selectedIds = getSelectedValues(selectedItems);
|
|
||||||
props.onSelect?.(
|
|
||||||
normalizedData.filter((item) => selectedIds.indexOf(item.id) !== -1)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectComponent
|
<FilterSelectComponent
|
||||||
{...props}
|
{...props}
|
||||||
onChange={onChange}
|
type="movies"
|
||||||
type="studios"
|
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
items={items}
|
items={items}
|
||||||
placeholder={placeholder}
|
placeholder={props.noSelectionString ?? "Select movie..."}
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TagSelect: React.FC<IFilterProps> = (props) => {
|
export const TagSelect: React.FC<IFilterProps> = (props) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const { data, loading } = useAllTagsForFilter();
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>(props.ids ?? []);
|
|
||||||
const { data, loading: dataLoading } = useAllTagsForFilter();
|
|
||||||
const [createTag] = useTagCreate({ name: "" });
|
const [createTag] = useTagCreate({ name: "" });
|
||||||
const Toast = useToast();
|
|
||||||
const placeholder = props.noSelectionString ?? "Select tags...";
|
const placeholder = props.noSelectionString ?? "Select tags...";
|
||||||
|
|
||||||
const selectedTags = props.ids ?? selectedIds;
|
|
||||||
|
|
||||||
const tags = data?.allTagsSlim ?? [];
|
const tags = data?.allTagsSlim ?? [];
|
||||||
const selected = tags
|
|
||||||
.filter((tag) => selectedTags.indexOf(tag.id) !== -1)
|
|
||||||
.map((tag) => ({ value: tag.id, label: tag.name }));
|
|
||||||
const items: Option[] = tags.map((item) => ({
|
|
||||||
value: item.id,
|
|
||||||
label: item.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const onCreate = async (tagName: string) => {
|
const onCreate = async (name: string) => {
|
||||||
|
const result = await createTag({
|
||||||
|
variables: { name },
|
||||||
|
});
|
||||||
|
return { item: result.data!.tagCreate!, message: "Created tag" };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterSelectComponent
|
||||||
|
{...props}
|
||||||
|
items={tags}
|
||||||
|
creatable
|
||||||
|
type="tags"
|
||||||
|
placeholder={placeholder}
|
||||||
|
isLoading={loading}
|
||||||
|
onCreate={onCreate}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FilterSelectComponent: React.FC<
|
||||||
|
IFilterComponentProps & ITypeProps & IFilterSelectProps
|
||||||
|
> = (props) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const selectedIds = props.ids ?? [];
|
||||||
|
const Toast = useToast();
|
||||||
|
|
||||||
|
const { items } = props;
|
||||||
|
const options = items.map((i) => ({
|
||||||
|
value: i.id,
|
||||||
|
label: i.name ?? "",
|
||||||
|
}));
|
||||||
|
const selectedOptions = options.filter((option) =>
|
||||||
|
selectedIds.includes(option.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChange = (selectedItems: ValueType<Option>) => {
|
||||||
|
const selectedValues = getSelectedValues(selectedItems);
|
||||||
|
props.onSelect?.(items.filter((item) => selectedValues.includes(item.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCreate = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const result = await createTag({
|
const { item: newItem, message } = await props.onCreate!(name);
|
||||||
variables: { name: tagName },
|
props.onSelect?.([
|
||||||
|
...items.filter((item) => selectedIds.includes(item.id)),
|
||||||
|
newItem,
|
||||||
|
]);
|
||||||
|
setLoading(false);
|
||||||
|
Toast.success({
|
||||||
|
content: (
|
||||||
|
<span>
|
||||||
|
{message}: <b>{name}</b>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.data?.tagCreate) {
|
|
||||||
setSelectedIds([...selectedIds, result.data.tagCreate.id]);
|
|
||||||
props.onSelect?.([
|
|
||||||
...tags.filter((item) => selectedIds.indexOf(item.id) !== -1),
|
|
||||||
result.data.tagCreate,
|
|
||||||
]);
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
Toast.success({
|
|
||||||
content: (
|
|
||||||
<span>
|
|
||||||
Created tag: <b>{tagName}</b>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.error(e);
|
Toast.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChange = (selectedItems: ValueType<Option>) => {
|
|
||||||
const selectedValues = getSelectedValues(selectedItems);
|
|
||||||
setSelectedIds(selectedValues);
|
|
||||||
props.onSelect?.(
|
|
||||||
tags.filter((item) => selectedValues.indexOf(item.id) !== -1)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectComponent
|
<SelectComponent
|
||||||
{...props}
|
{...(props as ITypeProps)}
|
||||||
|
{...(props as IFilterSelectProps)}
|
||||||
|
isLoading={props.isLoading || loading}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
creatable
|
items={options}
|
||||||
type="tags"
|
selectedOptions={selectedOptions}
|
||||||
placeholder={placeholder}
|
onCreateOption={props.creatable ? onCreate : undefined}
|
||||||
isLoading={loading || dataLoading}
|
|
||||||
items={items}
|
|
||||||
onCreateOption={onCreate}
|
|
||||||
selectedOptions={selected}
|
|
||||||
closeMenuOnSelect={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const getClient = () => client;
|
|||||||
const invalidateQueries = (queries: string[]) => {
|
const invalidateQueries = (queries: string[]) => {
|
||||||
if (cache) {
|
if (cache) {
|
||||||
const keyMatchers = queries.map((query) => {
|
const keyMatchers = queries.map((query) => {
|
||||||
return new RegExp(`^${query}`);
|
return new RegExp(`^${query}`, "i");
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Hack to invalidate, manipulating private data
|
// TODO: Hack to invalidate, manipulating private data
|
||||||
@@ -194,22 +194,26 @@ export const useDirectory = (path?: string) =>
|
|||||||
GQL.useDirectoryQuery({ variables: { path } });
|
GQL.useDirectoryQuery({ variables: { path } });
|
||||||
|
|
||||||
export const performerMutationImpactedQueries = [
|
export const performerMutationImpactedQueries = [
|
||||||
"findPerformers",
|
"FindPerformers",
|
||||||
"findScenes",
|
"FindScenes",
|
||||||
"findSceneMarkers",
|
"FindSceneMarkers",
|
||||||
"allPerformers",
|
"AllPerformers",
|
||||||
|
"AllPerformersForFilter",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const usePerformerCreate = () =>
|
export const usePerformerCreate = () =>
|
||||||
GQL.usePerformerCreateMutation({
|
GQL.usePerformerCreateMutation({
|
||||||
|
refetchQueries: performerMutationImpactedQueries,
|
||||||
update: () => invalidateQueries(performerMutationImpactedQueries),
|
update: () => invalidateQueries(performerMutationImpactedQueries),
|
||||||
});
|
});
|
||||||
export const usePerformerUpdate = () =>
|
export const usePerformerUpdate = () =>
|
||||||
GQL.usePerformerUpdateMutation({
|
GQL.usePerformerUpdateMutation({
|
||||||
|
refetchQueries: performerMutationImpactedQueries,
|
||||||
update: () => invalidateQueries(performerMutationImpactedQueries),
|
update: () => invalidateQueries(performerMutationImpactedQueries),
|
||||||
});
|
});
|
||||||
export const usePerformerDestroy = () =>
|
export const usePerformerDestroy = () =>
|
||||||
GQL.usePerformerDestroyMutation({
|
GQL.usePerformerDestroyMutation({
|
||||||
|
refetchQueries: performerMutationImpactedQueries,
|
||||||
update: () => invalidateQueries(performerMutationImpactedQueries),
|
update: () => invalidateQueries(performerMutationImpactedQueries),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,14 +286,16 @@ export const useSceneGenerateScreenshot = () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const studioMutationImpactedQueries = [
|
export const studioMutationImpactedQueries = [
|
||||||
"findStudios",
|
"FindStudios",
|
||||||
"findScenes",
|
"FindScenes",
|
||||||
"allStudios",
|
"AllStudios",
|
||||||
|
"AllStudiosForFilter",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const useStudioCreate = (input: GQL.StudioCreateInput) =>
|
export const useStudioCreate = (input: GQL.StudioCreateInput) =>
|
||||||
GQL.useStudioCreateMutation({
|
GQL.useStudioCreateMutation({
|
||||||
variables: input,
|
variables: input,
|
||||||
|
refetchQueries: studioMutationImpactedQueries,
|
||||||
update: () => invalidateQueries(studioMutationImpactedQueries),
|
update: () => invalidateQueries(studioMutationImpactedQueries),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user