mirror of https://github.com/stashapp/stash.git
Performer UI improvements (#1168)
* Refactor performer edit page with Formik * Upgrade react-scripts * Make eslint errors warnings in dev environment * Refactor performer details * Prompt if leaving dirty performer edit page
This commit is contained in:
parent
c2c06d8f8d
commit
9d1b716f48
|
@ -1,2 +1,3 @@
|
|||
BROWSER=none
|
||||
PORT=3000
|
||||
ESLINT_NO_DEV_ERRORS=true
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||
"@types/react-select": "^3.1.2",
|
||||
"@types/yup": "^0.29.11",
|
||||
"apollo-upload-client": "^14.1.3",
|
||||
"axios": "0.21.1",
|
||||
"base64-blob": "^1.4.1",
|
||||
|
@ -67,7 +68,8 @@
|
|||
"sass": "^1.32.5",
|
||||
"string.prototype.replaceall": "^1.0.4",
|
||||
"subscriptions-transport-ws": "^0.9.18",
|
||||
"universal-cookie": "^4.0.4"
|
||||
"universal-cookie": "^4.0.4",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/add": "^2.0.2",
|
||||
|
@ -99,7 +101,7 @@
|
|||
"extract-react-intl-messages": "^4.1.1",
|
||||
"postcss-safe-parser": "^5.0.2",
|
||||
"prettier": "2.2.1",
|
||||
"react-scripts": "^4.0.1",
|
||||
"react-scripts": "^4.0.3",
|
||||
"stylelint": "^13.9.0",
|
||||
"stylelint-config-prettier": "^8.0.2",
|
||||
"stylelint-order": "^4.1.0",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
### 🎨 Improvements
|
||||
* Improved performer details and edit UI pages.
|
||||
* Resolve python executable to `python3` or `python` for python script scrapers.
|
||||
* Add `url` field to `URLReplace`, and make `queryURLReplace` available when scraping by URL.
|
||||
* Make logging format consistent across platforms and include full timestamp.
|
||||
|
|
|
@ -22,6 +22,7 @@ import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
|
|||
import { PerformerScenesPanel } from "./PerformerScenesPanel";
|
||||
import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel";
|
||||
import { PerformerImagesPanel } from "./PerformerImagesPanel";
|
||||
import { PerformerEditPanel } from "./PerformerEditPanel";
|
||||
|
||||
interface IPerformerParams {
|
||||
id?: string;
|
||||
|
@ -126,11 +127,7 @@ export const Performer: React.FC = () => {
|
|||
unmountOnExit
|
||||
>
|
||||
<Tab eventKey="details" title="Details">
|
||||
<PerformerDetailsPanel
|
||||
performer={performer}
|
||||
isEditing={false}
|
||||
isVisible={activeTabKey === "details"}
|
||||
/>
|
||||
<PerformerDetailsPanel performer={performer} />
|
||||
</Tab>
|
||||
<Tab eventKey="scenes" title="Scenes">
|
||||
<PerformerScenesPanel performer={performer} />
|
||||
|
@ -142,9 +139,8 @@ export const Performer: React.FC = () => {
|
|||
<PerformerImagesPanel performer={performer} />
|
||||
</Tab>
|
||||
<Tab eventKey="edit" title="Edit">
|
||||
<PerformerDetailsPanel
|
||||
<PerformerEditPanel
|
||||
performer={performer}
|
||||
isEditing
|
||||
isVisible={activeTabKey === "edit"}
|
||||
isNew={isNew}
|
||||
onDelete={onDelete}
|
||||
|
@ -256,21 +252,22 @@ export const Performer: React.FC = () => {
|
|||
return <LoadingIndicator message="Encoding image..." />;
|
||||
}
|
||||
if (activeImage) {
|
||||
return <img className="photo" src={activeImage} alt="Performer" />;
|
||||
return <img className="performer" src={activeImage} alt="Performer" />;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
return (
|
||||
<div className="row new-view">
|
||||
<div className="col-4">{renderPerformerImage()}</div>
|
||||
<div className="col-6">
|
||||
<div className="row new-view" id="performer-page">
|
||||
<div className="performer-image-container col-md-4 text-center">
|
||||
{renderPerformerImage()}
|
||||
</div>
|
||||
<div className="col-md-8">
|
||||
<h2>Create Performer</h2>
|
||||
<PerformerDetailsPanel
|
||||
<PerformerEditPanel
|
||||
performer={performer}
|
||||
isEditing
|
||||
isVisible
|
||||
isNew={isNew}
|
||||
isNew
|
||||
onDelete={onDelete}
|
||||
onImageChange={onImageChange}
|
||||
onImageEncoding={onImageEncoding}
|
||||
|
|
|
@ -1,672 +1,31 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Button, Popover, OverlayTrigger, Table } from "react-bootstrap";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
getGenderStrings,
|
||||
useListPerformerScrapers,
|
||||
genderToString,
|
||||
stringToGender,
|
||||
queryScrapePerformer,
|
||||
queryScrapePerformerURL,
|
||||
mutateReloadScrapers,
|
||||
usePerformerUpdate,
|
||||
usePerformerCreate,
|
||||
} from "src/core/StashService";
|
||||
import {
|
||||
Icon,
|
||||
Modal,
|
||||
ImageInput,
|
||||
ScrapePerformerSuggest,
|
||||
LoadingIndicator,
|
||||
} from "src/components/Shared";
|
||||
import {
|
||||
ImageUtils,
|
||||
TableUtils,
|
||||
TextUtils,
|
||||
EditableTextUtils,
|
||||
} from "src/utils";
|
||||
import { useToast } from "src/hooks";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
|
||||
import { genderToString } from "src/core/StashService";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { TextField, URLField } from "src/utils/field";
|
||||
|
||||
interface IPerformerDetails {
|
||||
performer: Partial<GQL.PerformerDataFragment>;
|
||||
isNew?: boolean;
|
||||
isEditing?: boolean;
|
||||
isVisible: boolean;
|
||||
onDelete?: () => void;
|
||||
onImageChange?: (image?: string | null) => void;
|
||||
onImageEncoding?: (loading?: boolean) => void;
|
||||
}
|
||||
|
||||
export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
||||
performer,
|
||||
isNew,
|
||||
isEditing,
|
||||
isVisible,
|
||||
onDelete,
|
||||
onImageChange,
|
||||
onImageEncoding,
|
||||
}) => {
|
||||
const Toast = useToast();
|
||||
const history = useHistory();
|
||||
|
||||
// Editing state
|
||||
const [
|
||||
isDisplayingScraperDialog,
|
||||
setIsDisplayingScraperDialog,
|
||||
] = useState<GQL.Scraper>();
|
||||
const [
|
||||
scrapePerformerDetails,
|
||||
setScrapePerformerDetails,
|
||||
] = useState<GQL.ScrapedPerformerDataFragment>();
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
||||
// Editing performer state
|
||||
const [image, setImage] = useState<string | null>();
|
||||
const [name, setName] = useState<string>(performer?.name ?? "");
|
||||
const [aliases, setAliases] = useState<string>(performer.aliases ?? "");
|
||||
const [birthdate, setBirthdate] = useState<string>(performer.birthdate ?? "");
|
||||
const [ethnicity, setEthnicity] = useState<string>(performer.ethnicity ?? "");
|
||||
const [country, setCountry] = useState<string>(performer.country ?? "");
|
||||
const [eyeColor, setEyeColor] = useState<string>(performer.eye_color ?? "");
|
||||
const [height, setHeight] = useState<string>(performer.height ?? "");
|
||||
const [measurements, setMeasurements] = useState<string>(
|
||||
performer.measurements ?? ""
|
||||
);
|
||||
const [fakeTits, setFakeTits] = useState<string>(performer.fake_tits ?? "");
|
||||
const [careerLength, setCareerLength] = useState<string>(
|
||||
performer.career_length ?? ""
|
||||
);
|
||||
const [tattoos, setTattoos] = useState<string>(performer.tattoos ?? "");
|
||||
const [piercings, setPiercings] = useState<string>(performer.piercings ?? "");
|
||||
const [url, setUrl] = useState<string>(performer.url ?? "");
|
||||
const [twitter, setTwitter] = useState<string>(performer.twitter ?? "");
|
||||
const [instagram, setInstagram] = useState<string>(performer.instagram ?? "");
|
||||
const [gender, setGender] = useState<string | undefined>(
|
||||
genderToString(performer.gender ?? undefined)
|
||||
);
|
||||
const [stashIDs, setStashIDs] = useState<GQL.StashIdInput[]>(
|
||||
performer.stash_ids ?? []
|
||||
);
|
||||
const favorite = performer.favorite ?? false;
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const [updatePerformer] = usePerformerUpdate();
|
||||
const [createPerformer] = usePerformerCreate();
|
||||
|
||||
const Scrapers = useListPerformerScrapers();
|
||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
||||
|
||||
const [scrapedPerformer, setScrapedPerformer] = useState<
|
||||
GQL.ScrapedPerformer | undefined
|
||||
>();
|
||||
|
||||
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
|
||||
|
||||
function translateScrapedGender(scrapedGender?: string) {
|
||||
if (!scrapedGender) {
|
||||
return;
|
||||
}
|
||||
|
||||
let retEnum: GQL.GenderEnum | undefined;
|
||||
|
||||
// try to translate from enum values first
|
||||
const upperGender = scrapedGender?.toUpperCase();
|
||||
const asEnum = genderToString(upperGender as GQL.GenderEnum);
|
||||
if (asEnum) {
|
||||
retEnum = stringToGender(asEnum);
|
||||
} else {
|
||||
// try to match against gender strings
|
||||
const caseInsensitive = true;
|
||||
retEnum = stringToGender(scrapedGender, caseInsensitive);
|
||||
}
|
||||
|
||||
return genderToString(retEnum);
|
||||
}
|
||||
|
||||
function updatePerformerEditStateFromScraper(
|
||||
state: Partial<GQL.ScrapedPerformerDataFragment>
|
||||
) {
|
||||
if (state.name) {
|
||||
setName(state.name);
|
||||
}
|
||||
|
||||
if (state.aliases) {
|
||||
setAliases(state.aliases ?? undefined);
|
||||
}
|
||||
if (state.birthdate) {
|
||||
setBirthdate(state.birthdate ?? undefined);
|
||||
}
|
||||
if (state.ethnicity) {
|
||||
setEthnicity(state.ethnicity ?? undefined);
|
||||
}
|
||||
if (state.country) {
|
||||
setCountry(state.country ?? undefined);
|
||||
}
|
||||
if (state.eye_color) {
|
||||
setEyeColor(state.eye_color ?? undefined);
|
||||
}
|
||||
if (state.height) {
|
||||
setHeight(state.height ?? undefined);
|
||||
}
|
||||
if (state.measurements) {
|
||||
setMeasurements(state.measurements ?? undefined);
|
||||
}
|
||||
if (state.fake_tits) {
|
||||
setFakeTits(state.fake_tits ?? undefined);
|
||||
}
|
||||
if (state.career_length) {
|
||||
setCareerLength(state.career_length ?? undefined);
|
||||
}
|
||||
if (state.tattoos) {
|
||||
setTattoos(state.tattoos ?? undefined);
|
||||
}
|
||||
if (state.piercings) {
|
||||
setPiercings(state.piercings ?? undefined);
|
||||
}
|
||||
if (state.url) {
|
||||
setUrl(state.url ?? undefined);
|
||||
}
|
||||
if (state.twitter) {
|
||||
setTwitter(state.twitter ?? undefined);
|
||||
}
|
||||
if (state.instagram) {
|
||||
setInstagram(state.instagram ?? undefined);
|
||||
}
|
||||
if (state.gender) {
|
||||
// gender is a string in the scraper data
|
||||
setGender(translateScrapedGender(state.gender ?? undefined));
|
||||
}
|
||||
|
||||
// image is a base64 string
|
||||
// #404: don't overwrite image if it has been modified by the user
|
||||
// overwrite if not new since it came from a dialog
|
||||
// otherwise follow existing behaviour
|
||||
if (
|
||||
(!isNew || image === undefined) &&
|
||||
(state as GQL.ScrapedPerformerDataFragment).image !== undefined
|
||||
) {
|
||||
const imageStr = (state as GQL.ScrapedPerformerDataFragment).image;
|
||||
setImage(imageStr ?? undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function onImageLoad(imageData: string) {
|
||||
setImage(imageData);
|
||||
}
|
||||
|
||||
async function onSave(
|
||||
performerInput:
|
||||
| Partial<GQL.PerformerCreateInput>
|
||||
| Partial<GQL.PerformerUpdateInput>
|
||||
) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!isNew) {
|
||||
await updatePerformer({
|
||||
variables: {
|
||||
input: {
|
||||
...performerInput,
|
||||
stash_ids: performerInput?.stash_ids?.map((s) => ({
|
||||
endpoint: s.endpoint,
|
||||
stash_id: s.stash_id,
|
||||
})),
|
||||
} as GQL.PerformerUpdateInput,
|
||||
},
|
||||
});
|
||||
if (performerInput.image) {
|
||||
// Refetch image to bust browser cache
|
||||
await fetch(`/performer/${performer.id}/image`, { cache: "reload" });
|
||||
}
|
||||
} else {
|
||||
const result = await createPerformer({
|
||||
variables: performerInput as GQL.PerformerCreateInput,
|
||||
});
|
||||
if (result.data?.performerCreate) {
|
||||
history.push(`/performers/${result.data.performerCreate.id}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
if (isEditing && isVisible) {
|
||||
Mousetrap.bind("s s", () => {
|
||||
onSave?.(getPerformerInput());
|
||||
});
|
||||
|
||||
if (!isNew) {
|
||||
Mousetrap.bind("d d", () => {
|
||||
setIsDeleteAlertOpen(true);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("s s");
|
||||
|
||||
if (!isNew) {
|
||||
Mousetrap.unbind("d d");
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onImageChange) {
|
||||
onImageChange(image);
|
||||
}
|
||||
return () => onImageChange?.();
|
||||
}, [image, onImageChange]);
|
||||
|
||||
useEffect(() => onImageEncoding?.(imageEncoding), [
|
||||
onImageEncoding,
|
||||
imageEncoding,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const newQueryableScrapers = (
|
||||
Scrapers?.data?.listPerformerScrapers ?? []
|
||||
).filter((s) =>
|
||||
s.performer?.supported_scrapes.includes(GQL.ScrapeType.Name)
|
||||
);
|
||||
|
||||
setQueryableScrapers(newQueryableScrapers);
|
||||
}, [Scrapers]);
|
||||
|
||||
if (isLoading) return <LoadingIndicator />;
|
||||
|
||||
function getPerformerInput() {
|
||||
const performerInput: Partial<
|
||||
GQL.PerformerCreateInput | GQL.PerformerUpdateInput
|
||||
> = {
|
||||
name,
|
||||
aliases,
|
||||
favorite,
|
||||
birthdate,
|
||||
ethnicity,
|
||||
country,
|
||||
eye_color: eyeColor,
|
||||
height,
|
||||
measurements,
|
||||
fake_tits: fakeTits,
|
||||
career_length: careerLength,
|
||||
tattoos,
|
||||
piercings,
|
||||
url,
|
||||
twitter,
|
||||
instagram,
|
||||
image,
|
||||
gender: stringToGender(gender),
|
||||
stash_ids: stashIDs.map((s) => ({
|
||||
stash_id: s.stash_id,
|
||||
endpoint: s.endpoint,
|
||||
})),
|
||||
};
|
||||
|
||||
if (!isNew) {
|
||||
(performerInput as GQL.PerformerUpdateInput).id = performer.id!;
|
||||
}
|
||||
return performerInput;
|
||||
}
|
||||
|
||||
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
function onDisplayScrapeDialog(scraper: GQL.Scraper) {
|
||||
setIsDisplayingScraperDialog(scraper);
|
||||
}
|
||||
|
||||
async function onReloadScrapers() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await mutateReloadScrapers();
|
||||
|
||||
// reload the performer scrapers
|
||||
await Scrapers.refetch();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function getQueryScraperPerformerInput() {
|
||||
if (!scrapePerformerDetails) return {};
|
||||
|
||||
// image is not supported
|
||||
const { __typename, image: _image, ...ret } = scrapePerformerDetails;
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function onScrapePerformer() {
|
||||
setIsDisplayingScraperDialog(undefined);
|
||||
try {
|
||||
if (!scrapePerformerDetails || !isDisplayingScraperDialog) return;
|
||||
setIsLoading(true);
|
||||
const result = await queryScrapePerformer(
|
||||
isDisplayingScraperDialog.id,
|
||||
getQueryScraperPerformerInput()
|
||||
);
|
||||
if (!result?.data?.scrapePerformer) return;
|
||||
|
||||
// if this is a new performer, just dump the data
|
||||
if (isNew) {
|
||||
updatePerformerEditStateFromScraper(result.data.scrapePerformer);
|
||||
} else {
|
||||
setScrapedPerformer(result.data.scrapePerformer);
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onScrapePerformerURL() {
|
||||
if (!url) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await queryScrapePerformerURL(url);
|
||||
if (!result.data || !result.data.scrapePerformerURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if this is a new performer, just dump the data
|
||||
if (isNew) {
|
||||
updatePerformerEditStateFromScraper(result.data.scrapePerformerURL);
|
||||
} else {
|
||||
setScrapedPerformer(result.data.scrapePerformerURL);
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderEthnicity() {
|
||||
return TableUtils.renderInputGroup({
|
||||
title: "Ethnicity",
|
||||
value: ethnicity,
|
||||
isEditing: !!isEditing,
|
||||
placeholder: "Ethnicity",
|
||||
onChange: setEthnicity,
|
||||
});
|
||||
}
|
||||
|
||||
function renderScraperMenu() {
|
||||
if (!performer || !isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const popover = (
|
||||
<Popover id="performer-scraper-popover">
|
||||
<Popover.Content>
|
||||
<>
|
||||
{queryableScrapers
|
||||
? queryableScrapers.map((s) => (
|
||||
<div key={s.name}>
|
||||
<Button
|
||||
key={s.name}
|
||||
className="minimal"
|
||||
onClick={() => onDisplayScrapeDialog(s)}
|
||||
>
|
||||
{s.name}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
: ""}
|
||||
<div>
|
||||
<Button className="minimal" onClick={() => onReloadScrapers()}>
|
||||
<span className="fa-icon">
|
||||
<Icon icon="sync-alt" />
|
||||
</span>
|
||||
<span>Reload scrapers</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<OverlayTrigger trigger="click" placement="top" overlay={popover}>
|
||||
<Button variant="secondary" className="mr-2">
|
||||
Scrape with...
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
!!scrapedUrl &&
|
||||
(Scrapers?.data?.listPerformerScrapers ?? []).some((s) =>
|
||||
(s?.performer?.urls ?? []).some((u) => scrapedUrl.includes(u))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderScrapeButton() {
|
||||
if (!url || !isEditing || !urlScrapable(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
className="minimal scrape-url-button"
|
||||
onClick={() => onScrapePerformerURL()}
|
||||
>
|
||||
<Icon icon="file-upload" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderScrapeDialog() {
|
||||
if (!scrapedPerformer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPerformer: Partial<GQL.PerformerDataFragment> = {
|
||||
name,
|
||||
aliases,
|
||||
birthdate,
|
||||
ethnicity,
|
||||
country,
|
||||
eye_color: eyeColor,
|
||||
height,
|
||||
measurements,
|
||||
fake_tits: fakeTits,
|
||||
career_length: careerLength,
|
||||
tattoos,
|
||||
piercings,
|
||||
url,
|
||||
twitter,
|
||||
instagram,
|
||||
gender: stringToGender(gender),
|
||||
image_path: image ?? performer.image_path,
|
||||
};
|
||||
|
||||
return (
|
||||
<PerformerScrapeDialog
|
||||
performer={currentPerformer}
|
||||
scraped={scrapedPerformer}
|
||||
onClose={(p) => {
|
||||
onScrapeDialogClosed(p);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function onScrapeDialogClosed(p?: GQL.ScrapedPerformerDataFragment) {
|
||||
if (p) {
|
||||
updatePerformerEditStateFromScraper(p);
|
||||
}
|
||||
setScrapedPerformer(undefined);
|
||||
}
|
||||
|
||||
function renderURLField() {
|
||||
return (
|
||||
<tr>
|
||||
<td id="url-field">
|
||||
URL
|
||||
{maybeRenderScrapeButton()}
|
||||
</td>
|
||||
<td>
|
||||
{EditableTextUtils.renderInputGroup({
|
||||
title: "URL",
|
||||
value: url,
|
||||
url: TextUtils.sanitiseURL(url),
|
||||
isEditing: !!isEditing,
|
||||
onChange: setUrl,
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderButtons() {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="row">
|
||||
<Button
|
||||
className="mr-2"
|
||||
variant="primary"
|
||||
onClick={() => onSave?.(getPerformerInput())}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{!isNew ? (
|
||||
<Button
|
||||
className="mr-2"
|
||||
variant="danger"
|
||||
onClick={() => setIsDeleteAlertOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{renderScraperMenu()}
|
||||
<ImageInput
|
||||
isEditing={!!isEditing}
|
||||
onImageChange={onImageChangeHandler}
|
||||
/>
|
||||
{isEditing ? (
|
||||
<Button
|
||||
className="mx-2"
|
||||
variant="danger"
|
||||
onClick={() => setImage(null)}
|
||||
>
|
||||
Clear image
|
||||
</Button>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<Modal
|
||||
show={isDeleteAlertOpen}
|
||||
icon="trash-alt"
|
||||
accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
|
||||
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
|
||||
>
|
||||
<p>Are you sure you want to delete {name}?</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderName() {
|
||||
if (isEditing) {
|
||||
return TableUtils.renderInputGroup({
|
||||
title: "Name",
|
||||
value: name,
|
||||
isEditing: !!isEditing,
|
||||
placeholder: "Name",
|
||||
onChange: setName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderAliases() {
|
||||
if (isEditing) {
|
||||
return TableUtils.renderInputGroup({
|
||||
title: "Aliases",
|
||||
value: aliases,
|
||||
isEditing: !!isEditing,
|
||||
placeholder: "Aliases",
|
||||
onChange: setAliases,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderGender() {
|
||||
return TableUtils.renderHtmlSelect({
|
||||
title: "Gender",
|
||||
value: gender,
|
||||
isEditing: !!isEditing,
|
||||
onChange: (value: string) => setGender(value),
|
||||
selectOptions: [""].concat(getGenderStrings()),
|
||||
});
|
||||
}
|
||||
|
||||
const removeStashID = (stashID: GQL.StashIdInput) => {
|
||||
setStashIDs(
|
||||
stashIDs.filter(
|
||||
(s) =>
|
||||
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
function renderStashIDs() {
|
||||
if (!performer.stash_ids?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>StashIDs</td>
|
||||
<td>
|
||||
<dl className="row">
|
||||
<dt className="col-3 col-xl-2">StashIDs</dt>
|
||||
<dd className="col-9 col-xl-10">
|
||||
<ul className="pl-0">
|
||||
{stashIDs.map((stashID) => {
|
||||
{performer.stash_ids.map((stashID) => {
|
||||
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||
const link = base ? (
|
||||
<a
|
||||
|
@ -681,30 +40,17 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||
);
|
||||
return (
|
||||
<li key={stashID.stash_id} className="row no-gutters">
|
||||
{isEditing && (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="mr-2 py-0"
|
||||
title="Delete StashID"
|
||||
onClick={() => removeStashID(stashID)}
|
||||
>
|
||||
<Icon icon="trash-alt" />
|
||||
</Button>
|
||||
)}
|
||||
{link}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
const formatHeight = () => {
|
||||
if (isEditing) {
|
||||
return height;
|
||||
}
|
||||
const formatHeight = (height?: string | null) => {
|
||||
if (!height) {
|
||||
return "";
|
||||
}
|
||||
|
@ -717,92 +63,45 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{renderDeleteAlert()}
|
||||
{renderScraperDialog()}
|
||||
{maybeRenderScrapeDialog()}
|
||||
|
||||
<Table id="performer-details" className="w-100">
|
||||
<tbody>
|
||||
{maybeRenderName()}
|
||||
{maybeRenderAliases()}
|
||||
{renderGender()}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Birthdate",
|
||||
value: isEditing
|
||||
? birthdate
|
||||
: TextUtils.formatDate(intl, birthdate),
|
||||
isEditing: !!isEditing,
|
||||
onChange: setBirthdate,
|
||||
})}
|
||||
{renderEthnicity()}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Eye Color",
|
||||
value: eyeColor,
|
||||
isEditing: !!isEditing,
|
||||
onChange: setEyeColor,
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Country",
|
||||
value: country,
|
||||
isEditing: !!isEditing,
|
||||
onChange: setCountry,
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: `Height ${isEditing ? "(cm)" : ""}`,
|
||||
value: formatHeight(),
|
||||
isEditing: !!isEditing,
|
||||
onChange: setHeight,
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Measurements",
|
||||
value: measurements,
|
||||
isEditing: !!isEditing,
|
||||
onChange: setMeasurements,
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Fake Tits",
|
||||
value: fakeTits,
|
||||
isEditing: !!isEditing,
|
||||
onChange: setFakeTits,
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Career Length",
|
||||
value: careerLength,
|
||||
isEditing: !!isEditing,
|
||||
onChange: setCareerLength,
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Tattoos",
|
||||
value: tattoos,
|
||||
isEditing: !!isEditing,
|
||||
onChange: setTattoos,
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Piercings",
|
||||
value: piercings,
|
||||
isEditing: !!isEditing,
|
||||
onChange: setPiercings,
|
||||
})}
|
||||
{renderURLField()}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Twitter",
|
||||
value: twitter,
|
||||
url: TextUtils.sanitiseURL(twitter, TextUtils.twitterURL),
|
||||
isEditing: !!isEditing,
|
||||
onChange: setTwitter,
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Instagram",
|
||||
value: instagram,
|
||||
url: TextUtils.sanitiseURL(instagram, TextUtils.instagramURL),
|
||||
isEditing: !!isEditing,
|
||||
onChange: setInstagram,
|
||||
})}
|
||||
{renderStashIDs()}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
{maybeRenderButtons()}
|
||||
<TextField
|
||||
name="Gender"
|
||||
value={genderToString(performer.gender ?? undefined)}
|
||||
/>
|
||||
<TextField
|
||||
name="Birthdate"
|
||||
value={TextUtils.formatDate(intl, performer.birthdate ?? undefined)}
|
||||
/>
|
||||
<TextField name="Ethnicity" value={performer.ethnicity} />
|
||||
<TextField name="Eye Color" value={performer.eye_color} />
|
||||
<TextField name="Country" value={performer.country} />
|
||||
<TextField name="Height" value={formatHeight(performer.height)} />
|
||||
<TextField name="Measurements" value={performer.measurements} />
|
||||
<TextField name="Fake Tits" value={performer.fake_tits} />
|
||||
<TextField name="Career Length" value={performer.career_length} />
|
||||
<TextField name="Tattoos" value={performer.tattoos} />
|
||||
<TextField name="Piercings" value={performer.piercings} />
|
||||
<URLField
|
||||
name="URL"
|
||||
value={performer.url}
|
||||
url={TextUtils.sanitiseURL(performer.url ?? "")}
|
||||
/>
|
||||
<URLField
|
||||
name="Twitter"
|
||||
value={performer.twitter}
|
||||
url={TextUtils.sanitiseURL(
|
||||
performer.twitter ?? "",
|
||||
TextUtils.twitterURL
|
||||
)}
|
||||
/>
|
||||
<URLField
|
||||
name="Instagram"
|
||||
value={performer.instagram}
|
||||
url={TextUtils.sanitiseURL(
|
||||
performer.instagram ?? "",
|
||||
TextUtils.instagramURL
|
||||
)}
|
||||
/>
|
||||
{renderStashIDs()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,814 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
OverlayTrigger,
|
||||
Form,
|
||||
Col,
|
||||
InputGroup,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import * as yup from "yup";
|
||||
import {
|
||||
getGenderStrings,
|
||||
useListPerformerScrapers,
|
||||
genderToString,
|
||||
stringToGender,
|
||||
queryScrapePerformer,
|
||||
mutateReloadScrapers,
|
||||
usePerformerUpdate,
|
||||
usePerformerCreate,
|
||||
queryScrapePerformerURL,
|
||||
} from "src/core/StashService";
|
||||
import {
|
||||
Icon,
|
||||
Modal,
|
||||
ImageInput,
|
||||
ScrapePerformerSuggest,
|
||||
LoadingIndicator,
|
||||
} from "src/components/Shared";
|
||||
import { ImageUtils } from "src/utils";
|
||||
import { useToast } from "src/hooks";
|
||||
import { Prompt, useHistory } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
|
||||
|
||||
interface IPerformerDetails {
|
||||
performer: Partial<GQL.PerformerDataFragment>;
|
||||
isNew?: boolean;
|
||||
isVisible: boolean;
|
||||
onDelete?: () => void;
|
||||
onImageChange?: (image?: string | null) => void;
|
||||
onImageEncoding?: (loading?: boolean) => void;
|
||||
}
|
||||
|
||||
export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
||||
performer,
|
||||
isNew,
|
||||
isVisible,
|
||||
onDelete,
|
||||
onImageChange,
|
||||
onImageEncoding,
|
||||
}) => {
|
||||
const Toast = useToast();
|
||||
const history = useHistory();
|
||||
|
||||
// Editing state
|
||||
const [
|
||||
isDisplayingScraperDialog,
|
||||
setIsDisplayingScraperDialog,
|
||||
] = useState<GQL.Scraper>();
|
||||
const [
|
||||
scrapePerformerDetails,
|
||||
setScrapePerformerDetails,
|
||||
] = useState<GQL.ScrapedPerformerDataFragment>();
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [updatePerformer] = usePerformerUpdate();
|
||||
const [createPerformer] = usePerformerCreate();
|
||||
|
||||
const Scrapers = useListPerformerScrapers();
|
||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
||||
|
||||
const [scrapedPerformer, setScrapedPerformer] = useState<
|
||||
GQL.ScrapedPerformer | undefined
|
||||
>();
|
||||
|
||||
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
|
||||
|
||||
const genderOptions = [""].concat(getGenderStrings());
|
||||
|
||||
const schema = yup.object({
|
||||
name: yup.string().required(),
|
||||
aliases: yup.string().optional(),
|
||||
gender: yup.string().optional().oneOf(genderOptions),
|
||||
birthdate: yup.string().optional(),
|
||||
ethnicity: yup.string().optional(),
|
||||
eye_color: yup.string().optional(),
|
||||
country: yup.string().optional(),
|
||||
height: yup.string().optional(),
|
||||
measurements: yup.string().optional(),
|
||||
fake_tits: yup.string().optional(),
|
||||
career_length: yup.string().optional(),
|
||||
tattoos: yup.string().optional(),
|
||||
piercings: yup.string().optional(),
|
||||
url: yup.string().optional(),
|
||||
twitter: yup.string().optional(),
|
||||
instagram: yup.string().optional(),
|
||||
stash_ids: yup.mixed<GQL.StashIdInput>().optional(),
|
||||
image: yup.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const initialValues = {
|
||||
name: performer.name ?? "",
|
||||
aliases: performer.aliases ?? "",
|
||||
gender: genderToString(performer.gender ?? undefined),
|
||||
birthdate: performer.birthdate ?? "",
|
||||
ethnicity: performer.ethnicity ?? "",
|
||||
eye_color: performer.eye_color ?? "",
|
||||
country: performer.country ?? "",
|
||||
height: performer.height ?? "",
|
||||
measurements: performer.measurements ?? "",
|
||||
fake_tits: performer.fake_tits ?? "",
|
||||
career_length: performer.career_length ?? "",
|
||||
tattoos: performer.tattoos ?? "",
|
||||
piercings: performer.piercings ?? "",
|
||||
url: performer.url ?? "",
|
||||
twitter: performer.twitter ?? "",
|
||||
instagram: performer.instagram ?? "",
|
||||
stash_ids: performer.stash_ids ?? undefined,
|
||||
image: undefined,
|
||||
};
|
||||
|
||||
type InputValues = typeof initialValues;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues,
|
||||
validationSchema: schema,
|
||||
onSubmit: (values) => onSave(getPerformerInput(values)),
|
||||
});
|
||||
|
||||
function translateScrapedGender(scrapedGender?: string) {
|
||||
if (!scrapedGender) {
|
||||
return;
|
||||
}
|
||||
|
||||
let retEnum: GQL.GenderEnum | undefined;
|
||||
|
||||
// try to translate from enum values first
|
||||
const upperGender = scrapedGender?.toUpperCase();
|
||||
const asEnum = genderToString(upperGender as GQL.GenderEnum);
|
||||
if (asEnum) {
|
||||
retEnum = stringToGender(asEnum);
|
||||
} else {
|
||||
// try to match against gender strings
|
||||
const caseInsensitive = true;
|
||||
retEnum = stringToGender(scrapedGender, caseInsensitive);
|
||||
}
|
||||
|
||||
return genderToString(retEnum);
|
||||
}
|
||||
|
||||
function updatePerformerEditStateFromScraper(
|
||||
state: Partial<GQL.ScrapedPerformerDataFragment>
|
||||
) {
|
||||
if (state.name) {
|
||||
formik.setFieldValue("name", state.name);
|
||||
}
|
||||
|
||||
if (state.aliases) {
|
||||
formik.setFieldValue("aliases", state.aliases);
|
||||
}
|
||||
if (state.birthdate) {
|
||||
formik.setFieldValue("birthdate", state.birthdate);
|
||||
}
|
||||
if (state.ethnicity) {
|
||||
formik.setFieldValue("ethnicity", state.ethnicity);
|
||||
}
|
||||
if (state.country) {
|
||||
formik.setFieldValue("country", state.country);
|
||||
}
|
||||
if (state.eye_color) {
|
||||
formik.setFieldValue("eye_color", state.eye_color);
|
||||
}
|
||||
if (state.height) {
|
||||
formik.setFieldValue("height", state.height);
|
||||
}
|
||||
if (state.measurements) {
|
||||
formik.setFieldValue("measurements", state.measurements);
|
||||
}
|
||||
if (state.fake_tits) {
|
||||
formik.setFieldValue("fake_tits", state.fake_tits);
|
||||
}
|
||||
if (state.career_length) {
|
||||
formik.setFieldValue("career_length", state.career_length);
|
||||
}
|
||||
if (state.tattoos) {
|
||||
formik.setFieldValue("tattoos", state.tattoos);
|
||||
}
|
||||
if (state.piercings) {
|
||||
formik.setFieldValue("piercings", state.piercings);
|
||||
}
|
||||
if (state.url) {
|
||||
formik.setFieldValue("url", state.url);
|
||||
}
|
||||
if (state.twitter) {
|
||||
formik.setFieldValue("twitter", state.twitter);
|
||||
}
|
||||
if (state.instagram) {
|
||||
formik.setFieldValue("instagram", state.instagram);
|
||||
}
|
||||
if (state.gender) {
|
||||
// gender is a string in the scraper data
|
||||
formik.setFieldValue(
|
||||
"gender",
|
||||
translateScrapedGender(state.gender ?? undefined)
|
||||
);
|
||||
}
|
||||
|
||||
// image is a base64 string
|
||||
// #404: don't overwrite image if it has been modified by the user
|
||||
// overwrite if not new since it came from a dialog
|
||||
// otherwise follow existing behaviour
|
||||
if (
|
||||
(!isNew || formik.values.image === undefined) &&
|
||||
(state as GQL.ScrapedPerformerDataFragment).image !== undefined
|
||||
) {
|
||||
const imageStr = (state as GQL.ScrapedPerformerDataFragment).image;
|
||||
formik.setFieldValue("image", imageStr ?? undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function onImageLoad(imageData: string) {
|
||||
formik.setFieldValue("image", imageData);
|
||||
}
|
||||
|
||||
async function onSave(
|
||||
performerInput:
|
||||
| Partial<GQL.PerformerCreateInput>
|
||||
| Partial<GQL.PerformerUpdateInput>
|
||||
) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!isNew) {
|
||||
await updatePerformer({
|
||||
variables: {
|
||||
input: {
|
||||
...performerInput,
|
||||
stash_ids: performerInput?.stash_ids?.map((s) => ({
|
||||
endpoint: s.endpoint,
|
||||
stash_id: s.stash_id,
|
||||
})),
|
||||
} as GQL.PerformerUpdateInput,
|
||||
},
|
||||
});
|
||||
if (performerInput.image) {
|
||||
// Refetch image to bust browser cache
|
||||
await fetch(`/performer/${performer.id}/image`, { cache: "reload" });
|
||||
}
|
||||
|
||||
history.push(`/performers/${performer.id}`);
|
||||
} else {
|
||||
const result = await createPerformer({
|
||||
variables: performerInput as GQL.PerformerCreateInput,
|
||||
});
|
||||
if (result.data?.performerCreate) {
|
||||
history.push(`/performers/${result.data.performerCreate.id}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
Mousetrap.bind("s s", () => {
|
||||
onSave?.(getPerformerInput(formik.values));
|
||||
});
|
||||
|
||||
if (!isNew) {
|
||||
Mousetrap.bind("d d", () => {
|
||||
setIsDeleteAlertOpen(true);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("s s");
|
||||
|
||||
if (!isNew) {
|
||||
Mousetrap.unbind("d d");
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onImageChange) {
|
||||
onImageChange(formik.values.image);
|
||||
}
|
||||
return () => onImageChange?.();
|
||||
}, [formik.values.image, onImageChange]);
|
||||
|
||||
useEffect(() => onImageEncoding?.(imageEncoding), [
|
||||
onImageEncoding,
|
||||
imageEncoding,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const newQueryableScrapers = (
|
||||
Scrapers?.data?.listPerformerScrapers ?? []
|
||||
).filter((s) =>
|
||||
s.performer?.supported_scrapes.includes(GQL.ScrapeType.Name)
|
||||
);
|
||||
|
||||
setQueryableScrapers(newQueryableScrapers);
|
||||
}, [Scrapers]);
|
||||
|
||||
if (isLoading) return <LoadingIndicator />;
|
||||
|
||||
function getPerformerInput(values: InputValues) {
|
||||
const performerInput: Partial<
|
||||
GQL.PerformerCreateInput | GQL.PerformerUpdateInput
|
||||
> = {
|
||||
...values,
|
||||
gender: stringToGender(values.gender),
|
||||
};
|
||||
|
||||
if (!isNew) {
|
||||
(performerInput as GQL.PerformerUpdateInput).id = performer.id!;
|
||||
}
|
||||
return performerInput;
|
||||
}
|
||||
|
||||
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
function onDisplayScrapeDialog(scraper: GQL.Scraper) {
|
||||
setIsDisplayingScraperDialog(scraper);
|
||||
}
|
||||
|
||||
async function onReloadScrapers() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await mutateReloadScrapers();
|
||||
|
||||
// reload the performer scrapers
|
||||
await Scrapers.refetch();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function getQueryScraperPerformerInput() {
|
||||
if (!scrapePerformerDetails) return {};
|
||||
|
||||
// image is not supported
|
||||
const { __typename, image: _image, ...ret } = scrapePerformerDetails;
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function onScrapePerformer() {
|
||||
setIsDisplayingScraperDialog(undefined);
|
||||
try {
|
||||
if (!scrapePerformerDetails || !isDisplayingScraperDialog) return;
|
||||
setIsLoading(true);
|
||||
const result = await queryScrapePerformer(
|
||||
isDisplayingScraperDialog.id,
|
||||
getQueryScraperPerformerInput()
|
||||
);
|
||||
if (!result?.data?.scrapePerformer) return;
|
||||
|
||||
// if this is a new performer, just dump the data
|
||||
if (isNew) {
|
||||
updatePerformerEditStateFromScraper(result.data.scrapePerformer);
|
||||
} else {
|
||||
setScrapedPerformer(result.data.scrapePerformer);
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onScrapePerformerURL() {
|
||||
const { url } = formik.values;
|
||||
if (!url) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await queryScrapePerformerURL(url);
|
||||
if (!result.data || !result.data.scrapePerformerURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if this is a new performer, just dump the data
|
||||
if (isNew) {
|
||||
updatePerformerEditStateFromScraper(result.data.scrapePerformerURL);
|
||||
} else {
|
||||
setScrapedPerformer(result.data.scrapePerformerURL);
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderScraperMenu() {
|
||||
if (!performer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const popover = (
|
||||
<Popover id="performer-scraper-popover">
|
||||
<Popover.Content>
|
||||
<>
|
||||
{queryableScrapers
|
||||
? queryableScrapers.map((s) => (
|
||||
<div key={s.name}>
|
||||
<Button
|
||||
key={s.name}
|
||||
className="minimal"
|
||||
onClick={() => onDisplayScrapeDialog(s)}
|
||||
>
|
||||
{s.name}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
: ""}
|
||||
<div>
|
||||
<Button className="minimal" onClick={() => onReloadScrapers()}>
|
||||
<span className="fa-icon">
|
||||
<Icon icon="sync-alt" />
|
||||
</span>
|
||||
<span>Reload scrapers</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<OverlayTrigger trigger="click" placement="top" overlay={popover}>
|
||||
<Button variant="secondary" className="mr-2">
|
||||
Scrape with...
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
!!scrapedUrl &&
|
||||
(Scrapers?.data?.listPerformerScrapers ?? []).some((s) =>
|
||||
(s?.performer?.urls ?? []).some((u) => scrapedUrl.includes(u))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderScrapeDialog() {
|
||||
if (!scrapedPerformer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPerformer: Partial<GQL.PerformerDataFragment> = {
|
||||
...formik.values,
|
||||
gender: stringToGender(formik.values.gender),
|
||||
image_path: formik.values.image ?? performer.image_path,
|
||||
};
|
||||
|
||||
return (
|
||||
<PerformerScrapeDialog
|
||||
performer={currentPerformer}
|
||||
scraped={scrapedPerformer}
|
||||
onClose={(p) => {
|
||||
onScrapeDialogClosed(p);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function onScrapeDialogClosed(p?: GQL.ScrapedPerformerDataFragment) {
|
||||
if (p) {
|
||||
updatePerformerEditStateFromScraper(p);
|
||||
}
|
||||
setScrapedPerformer(undefined);
|
||||
}
|
||||
|
||||
function maybeRenderScrapeButton() {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!urlScrapable(formik.values.url)}
|
||||
className="scrape-url-button text-input"
|
||||
onClick={() => onScrapePerformerURL()}
|
||||
>
|
||||
<Icon icon="file-upload" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function renderButtons() {
|
||||
return (
|
||||
<Row>
|
||||
<Col className="mt-3" xs={12}>
|
||||
<Button
|
||||
className="mr-2"
|
||||
variant="primary"
|
||||
onClick={() => formik.submitForm()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{!isNew ? (
|
||||
<Button
|
||||
className="mr-2"
|
||||
variant="danger"
|
||||
onClick={() => setIsDeleteAlertOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{renderScraperMenu()}
|
||||
<ImageInput isEditing onImageChange={onImageChangeHandler} />
|
||||
<Button
|
||||
className="mx-2"
|
||||
variant="danger"
|
||||
onClick={() => formik.setFieldValue("image", null)}
|
||||
>
|
||||
Clear image
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<Modal
|
||||
show={isDeleteAlertOpen}
|
||||
icon="trash-alt"
|
||||
accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
|
||||
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
|
||||
>
|
||||
<p>Are you sure you want to delete {performer.name}?</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const removeStashID = (stashID: GQL.StashIdInput) => {
|
||||
formik.setFieldValue(
|
||||
"stash_ids",
|
||||
(formik.values.stash_ids ?? []).filter(
|
||||
(s) =>
|
||||
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
function renderStashIDs() {
|
||||
if (!formik.values.stash_ids?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col sm="auto">
|
||||
<div>StashIDs</div>
|
||||
<ul className="pl-0">
|
||||
{formik.values.stash_ids.map((stashID) => {
|
||||
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||
const link = base ? (
|
||||
<a
|
||||
href={`${base}performers/${stashID.stash_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{stashID.stash_id}
|
||||
</a>
|
||||
) : (
|
||||
stashID.stash_id
|
||||
);
|
||||
return (
|
||||
<li key={stashID.stash_id} className="row no-gutters mb-1">
|
||||
<Button
|
||||
variant="danger"
|
||||
className="mr-2 py-0"
|
||||
title="Delete StashID"
|
||||
onClick={() => removeStashID(stashID)}
|
||||
>
|
||||
<Icon icon="trash-alt" />
|
||||
</Button>
|
||||
{link}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderDeleteAlert()}
|
||||
{renderScraperDialog()}
|
||||
{maybeRenderScrapeDialog()}
|
||||
|
||||
<Prompt
|
||||
when={formik.dirty}
|
||||
message="Unsaved changes. Are you sure you want to leave?"
|
||||
/>
|
||||
|
||||
<Form noValidate onSubmit={formik.handleSubmit} id="performer-edit">
|
||||
<Form.Row>
|
||||
<Form.Group as={Col} sm="4">
|
||||
<Form.Label>Name</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
placeholder="Name"
|
||||
{...formik.getFieldProps("name")}
|
||||
isInvalid={!!formik.errors.name}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{formik.errors.name}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Form.Group as={Col} sm="8">
|
||||
<Form.Label>Aliases</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
placeholder="Aliases"
|
||||
{...formik.getFieldProps("aliases")}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
|
||||
<Form.Row>
|
||||
<Form.Group as={Col} md="auto">
|
||||
<Form.Label>Gender</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
className="input-control"
|
||||
{...formik.getFieldProps("gender")}
|
||||
>
|
||||
{genderOptions.map((opt) => (
|
||||
<option value={opt} key={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
|
||||
<Form.Row>
|
||||
<Form.Group as={Col} sm="4">
|
||||
<Form.Label>Birthdate</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
placeholder="Birthdate"
|
||||
{...formik.getFieldProps("birthdate")}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
|
||||
<Form.Row>
|
||||
<Form.Group as={Col} sm="4">
|
||||
<Form.Label>Country</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("country")}
|
||||
placeholder="Country"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group as={Col} sm="4">
|
||||
<Form.Label>Ethnicity</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("ethnicity")}
|
||||
placeholder="Ethnicity"
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
|
||||
<Form.Row>
|
||||
<Form.Group as={Col} sm="4">
|
||||
<Form.Label>Eye Color</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("eye_color")}
|
||||
placeholder="Eye Color"
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
|
||||
<Form.Row>
|
||||
<Form.Group as={Col} sm="4">
|
||||
<Form.Label>Height (cm)</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("height")}
|
||||
placeholder="Height (cm)"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group as={Col} sm="4">
|
||||
<Form.Label>Measurements</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("measurements")}
|
||||
placeholder="Measurements"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group as={Col} sm="4">
|
||||
<Form.Label>Fake Tits</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("fake_tits")}
|
||||
placeholder="Fake Tits"
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
|
||||
<Form.Row>
|
||||
<Form.Group as={Col} lg="6">
|
||||
<Form.Label>Tattoos</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("tattoos")}
|
||||
placeholder="Tattoos"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group as={Col} lg="6">
|
||||
<Form.Label>Piercings</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("piercings")}
|
||||
placeholder="Piercings"
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
|
||||
<Form.Row>
|
||||
<Form.Group as={Col} sm="4">
|
||||
<Form.Label>Career Length</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("career_length")}
|
||||
placeholder="Career Length"
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
|
||||
<Form.Row>
|
||||
<Form.Group as={Col} sm="6">
|
||||
<Form.Label>URL</Form.Label>
|
||||
<InputGroup>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("url")}
|
||||
placeholder="URL"
|
||||
/>
|
||||
<InputGroup.Append>{maybeRenderScrapeButton()}</InputGroup.Append>
|
||||
</InputGroup>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
|
||||
<Form.Row>
|
||||
<Form.Group as={Col} lg="6">
|
||||
<Form.Label>Twitter</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("twitter")}
|
||||
placeholder="Twitter"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group as={Col} lg="6">
|
||||
<Form.Label>Instagram</Form.Label>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("instagram")}
|
||||
placeholder="Instagram"
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Row>
|
||||
{renderStashIDs()}
|
||||
|
||||
{renderButtons()}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,8 +1,6 @@
|
|||
#performer-details {
|
||||
.scrape-url-button {
|
||||
color: $text-color;
|
||||
float: right;
|
||||
margin-right: 0.5rem;
|
||||
#performer-edit {
|
||||
.scrape-url-button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -406,7 +406,6 @@ div.dropdown-menu {
|
|||
|
||||
.image-input {
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
input[type="file"], /* FF, IE7+, chrome (except button) */
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import React from "react";
|
||||
|
||||
interface ITextField {
|
||||
name: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
export const TextField: React.FC<ITextField> = ({ name, value }) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="row mb-0">
|
||||
<dt className="col-3 col-xl-2">{name}:</dt>
|
||||
<dd className="col-9 col-xl-10">{value ?? undefined}</dd>
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
|
||||
interface IURLField {
|
||||
name: string;
|
||||
value?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export const URLField: React.FC<IURLField> = ({ name, value, url }) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<dl className="row">
|
||||
<dt className="col-3 col-xl-2">{name}:</dt>
|
||||
<dd className="col-9 col-xl-10">
|
||||
{url ? (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
{value}
|
||||
</a>
|
||||
) : undefined}
|
||||
</dd>
|
||||
</dl>
|
||||
);
|
||||
};
|
1284
ui/v2.5/yarn.lock
1284
ui/v2.5/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue