mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Improve performer scrape search modal (#1198)
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
* Added Performer tags.
|
* Added Performer tags.
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Improve performer scraper search modal.
|
||||||
* Add galleries tab to Tag details page.
|
* Add galleries tab to Tag details page.
|
||||||
* Allow scene/performer/studio image upload via URL.
|
* Allow scene/performer/studio image upload via URL.
|
||||||
* Add button to hide unmatched scenes in Tagger view.
|
* Add button to hide unmatched scenes in Tagger view.
|
||||||
|
|||||||
@@ -26,11 +26,10 @@ import {
|
|||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import {
|
import {
|
||||||
Icon,
|
Icon,
|
||||||
Modal,
|
|
||||||
ImageInput,
|
ImageInput,
|
||||||
ScrapePerformerSuggest,
|
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
CollapseButton,
|
CollapseButton,
|
||||||
|
Modal,
|
||||||
TagSelect,
|
TagSelect,
|
||||||
} from "src/components/Shared";
|
} from "src/components/Shared";
|
||||||
import { ImageUtils } from "src/utils";
|
import { ImageUtils } from "src/utils";
|
||||||
@@ -38,6 +37,7 @@ import { useToast } from "src/hooks";
|
|||||||
import { Prompt, useHistory } from "react-router-dom";
|
import { Prompt, useHistory } from "react-router-dom";
|
||||||
import { useFormik } from "formik";
|
import { useFormik } from "formik";
|
||||||
import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
|
import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
|
||||||
|
import PerformerScrapeModal from "./PerformerScrapeModal";
|
||||||
|
|
||||||
interface IPerformerDetails {
|
interface IPerformerDetails {
|
||||||
performer: Partial<GQL.PerformerDataFragment>;
|
performer: Partial<GQL.PerformerDataFragment>;
|
||||||
@@ -60,14 +60,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
// Editing state
|
// Editing state
|
||||||
const [
|
const [scraper, setScraper] = useState<GQL.Scraper | undefined>();
|
||||||
isDisplayingScraperDialog,
|
|
||||||
setIsDisplayingScraperDialog,
|
|
||||||
] = useState<GQL.Scraper>();
|
|
||||||
const [
|
|
||||||
scrapePerformerDetails,
|
|
||||||
setScrapePerformerDetails,
|
|
||||||
] = useState<GQL.ScrapedPerformerDataFragment>();
|
|
||||||
const [newTags, setNewTags] = useState<GQL.ScrapedSceneTag[]>();
|
const [newTags, setNewTags] = useState<GQL.ScrapedSceneTag[]>();
|
||||||
|
|
||||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||||
@@ -422,10 +415,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||||||
formik.setFieldValue("image", url);
|
formik.setFieldValue("image", url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDisplayScrapeDialog(scraper: GQL.Scraper) {
|
|
||||||
setIsDisplayingScraperDialog(scraper);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onReloadScrapers() {
|
async function onReloadScrapers() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -440,29 +429,22 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQueryScraperPerformerInput() {
|
async function onScrapePerformer(
|
||||||
if (!scrapePerformerDetails) return {};
|
selectedPerformer: GQL.ScrapedPerformerDataFragment
|
||||||
|
) {
|
||||||
// image is not supported
|
setScraper(undefined);
|
||||||
// remove tags as well
|
|
||||||
const {
|
|
||||||
__typename,
|
|
||||||
image: _image,
|
|
||||||
tags: _tags,
|
|
||||||
...ret
|
|
||||||
} = scrapePerformerDetails;
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onScrapePerformer() {
|
|
||||||
setIsDisplayingScraperDialog(undefined);
|
|
||||||
try {
|
try {
|
||||||
if (!scrapePerformerDetails || !isDisplayingScraperDialog) return;
|
if (!scraper) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const result = await queryScrapePerformer(
|
|
||||||
isDisplayingScraperDialog.id,
|
const {
|
||||||
getQueryScraperPerformerInput()
|
__typename,
|
||||||
);
|
image: _image,
|
||||||
|
tags: _tags,
|
||||||
|
...ret
|
||||||
|
} = selectedPerformer;
|
||||||
|
|
||||||
|
const result = await queryScrapePerformer(scraper.id, ret);
|
||||||
if (!result?.data?.scrapePerformer) return;
|
if (!result?.data?.scrapePerformer) return;
|
||||||
|
|
||||||
// if this is a new performer, just dump the data
|
// if this is a new performer, just dump the data
|
||||||
@@ -516,7 +498,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||||||
<Button
|
<Button
|
||||||
key={s.name}
|
key={s.name}
|
||||||
className="minimal"
|
className="minimal"
|
||||||
onClick={() => onDisplayScrapeDialog(s)}
|
onClick={() => setScraper(s)}
|
||||||
>
|
>
|
||||||
{s.name}
|
{s.name}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -550,27 +532,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderScraperDialog() {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
show={!!isDisplayingScraperDialog}
|
|
||||||
onHide={() => setIsDisplayingScraperDialog(undefined)}
|
|
||||||
header="Scrape"
|
|
||||||
accept={{ onClick: onScrapePerformer, text: "Scrape" }}
|
|
||||||
>
|
|
||||||
<div className="dialog-content">
|
|
||||||
<ScrapePerformerSuggest
|
|
||||||
placeholder="Performer name"
|
|
||||||
scraperId={
|
|
||||||
isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""
|
|
||||||
}
|
|
||||||
onSelectPerformer={(query) => setScrapePerformerDetails(query)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlScrapable(scrapedUrl?: string) {
|
function urlScrapable(scrapedUrl?: string) {
|
||||||
return (
|
return (
|
||||||
!!scrapedUrl &&
|
!!scrapedUrl &&
|
||||||
@@ -662,6 +623,16 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderScrapeModal = () =>
|
||||||
|
scraper !== undefined && (
|
||||||
|
<PerformerScrapeModal
|
||||||
|
scraper={scraper}
|
||||||
|
onHide={() => setScraper(undefined)}
|
||||||
|
onSelectPerformer={onScrapePerformer}
|
||||||
|
name={formik.values.name || ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
function renderDeleteAlert() {
|
function renderDeleteAlert() {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -753,7 +724,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderDeleteAlert()}
|
{renderDeleteAlert()}
|
||||||
{renderScraperDialog()}
|
{renderScrapeModal()}
|
||||||
{maybeRenderScrapeDialog()}
|
{maybeRenderScrapeDialog()}
|
||||||
|
|
||||||
<Prompt
|
<Prompt
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { Button, Form } from "react-bootstrap";
|
||||||
|
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { Modal, LoadingIndicator } from "src/components/Shared";
|
||||||
|
import { useScrapePerformerList } from "src/core/StashService";
|
||||||
|
|
||||||
|
const CLASSNAME = "PerformerScrapeModal";
|
||||||
|
const CLASSNAME_LIST = `${CLASSNAME}-list`;
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
scraper: GQL.Scraper;
|
||||||
|
onHide: () => void;
|
||||||
|
onSelectPerformer: (performer: GQL.ScrapedPerformerDataFragment) => void;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
const PerformerScrapeModal: React.FC<IProps> = ({
|
||||||
|
scraper,
|
||||||
|
name,
|
||||||
|
onHide,
|
||||||
|
onSelectPerformer,
|
||||||
|
}) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [query, setQuery] = useState<string>(name ?? "");
|
||||||
|
const { data, loading } = useScrapePerformerList(scraper.id, query);
|
||||||
|
|
||||||
|
const performers = data?.scrapePerformerList ?? [];
|
||||||
|
|
||||||
|
const onInputChange = debounce((input: string) => {
|
||||||
|
setQuery(input);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
useEffect(() => inputRef.current?.focus(), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
show
|
||||||
|
onHide={onHide}
|
||||||
|
header={`Scrape performer from ${scraper.name}`}
|
||||||
|
accept={{ text: "Cancel", onClick: onHide, variant: "secondary" }}
|
||||||
|
>
|
||||||
|
<div className={CLASSNAME}>
|
||||||
|
<Form.Control
|
||||||
|
onChange={(e) => onInputChange(e.currentTarget.value)}
|
||||||
|
defaultValue={name ?? ""}
|
||||||
|
placeholder="Performer name..."
|
||||||
|
className="text-input mb-4"
|
||||||
|
ref={inputRef}
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<div className="m-4 text-center">
|
||||||
|
<LoadingIndicator inline />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className={CLASSNAME_LIST}>
|
||||||
|
{performers.map((p) => (
|
||||||
|
<li key={p.url}>
|
||||||
|
<Button variant="link" onClick={() => onSelectPerformer(p)}>
|
||||||
|
{p.name}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PerformerScrapeModal;
|
||||||
@@ -90,3 +90,16 @@
|
|||||||
#performer-scraper-popover {
|
#performer-scraper-popover {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.PerformerScrapeModal {
|
||||||
|
&-list {
|
||||||
|
list-style-type: none;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-left: 1rem;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
useAllStudiosForFilter,
|
useAllStudiosForFilter,
|
||||||
useAllPerformersForFilter,
|
useAllPerformersForFilter,
|
||||||
useMarkerStrings,
|
useMarkerStrings,
|
||||||
useScrapePerformerList,
|
|
||||||
useTagCreate,
|
useTagCreate,
|
||||||
useStudioCreate,
|
useStudioCreate,
|
||||||
usePerformerCreate,
|
usePerformerCreate,
|
||||||
@@ -342,48 +341,6 @@ export const SceneSelect: React.FC<ISceneSelect> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IScrapePerformerSuggestProps {
|
|
||||||
scraperId: string;
|
|
||||||
onSelectPerformer: (performer: GQL.ScrapedPerformerDataFragment) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
export const ScrapePerformerSuggest: React.FC<IScrapePerformerSuggestProps> = (
|
|
||||||
props
|
|
||||||
) => {
|
|
||||||
const [query, setQuery] = useState<string>("");
|
|
||||||
const { data, loading } = useScrapePerformerList(props.scraperId, query);
|
|
||||||
|
|
||||||
const performers = data?.scrapePerformerList ?? [];
|
|
||||||
const items = performers.map((item) => ({
|
|
||||||
label: item.name ?? "",
|
|
||||||
value: item.name ?? "",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const onInputChange = debounce((input: string) => {
|
|
||||||
setQuery(input);
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
const onChange = (option: ValueType<Option, false>) => {
|
|
||||||
const performer = performers.find((p) => p.name === option?.value);
|
|
||||||
if (performer) props.onSelectPerformer(performer);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectComponent
|
|
||||||
isMulti={false}
|
|
||||||
onChange={onChange}
|
|
||||||
onInputChange={onInputChange}
|
|
||||||
isLoading={loading}
|
|
||||||
items={items}
|
|
||||||
initialIds={[]}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
className="select-suggest"
|
|
||||||
showDropdown={false}
|
|
||||||
noOptionsMessage={query === "" ? null : "No performers found."}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IMarkerSuggestProps {
|
interface IMarkerSuggestProps {
|
||||||
initialMarkerTitle?: string;
|
initialMarkerTitle?: string;
|
||||||
onChange: (title: string) => void;
|
onChange: (title: string) => void;
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
export {
|
export * from "./Select";
|
||||||
GallerySelect,
|
|
||||||
ScrapePerformerSuggest,
|
|
||||||
MarkerTitleSuggest,
|
|
||||||
FilterSelect,
|
|
||||||
PerformerSelect,
|
|
||||||
StudioSelect,
|
|
||||||
TagSelect,
|
|
||||||
SceneSelect,
|
|
||||||
} from "./Select";
|
|
||||||
|
|
||||||
export { default as Icon } from "./Icon";
|
export { default as Icon } from "./Icon";
|
||||||
export { default as Modal } from "./Modal";
|
export { default as Modal } from "./Modal";
|
||||||
export { CollapseButton } from "./CollapseButton";
|
export { CollapseButton } from "./CollapseButton";
|
||||||
|
|||||||
Reference in New Issue
Block a user