mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
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:
@@ -1,2 +1,3 @@
|
|||||||
BROWSER=none
|
BROWSER=none
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
ESLINT_NO_DEV_ERRORS=true
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||||
"@types/react-select": "^3.1.2",
|
"@types/react-select": "^3.1.2",
|
||||||
|
"@types/yup": "^0.29.11",
|
||||||
"apollo-upload-client": "^14.1.3",
|
"apollo-upload-client": "^14.1.3",
|
||||||
"axios": "0.21.1",
|
"axios": "0.21.1",
|
||||||
"base64-blob": "^1.4.1",
|
"base64-blob": "^1.4.1",
|
||||||
@@ -67,7 +68,8 @@
|
|||||||
"sass": "^1.32.5",
|
"sass": "^1.32.5",
|
||||||
"string.prototype.replaceall": "^1.0.4",
|
"string.prototype.replaceall": "^1.0.4",
|
||||||
"subscriptions-transport-ws": "^0.9.18",
|
"subscriptions-transport-ws": "^0.9.18",
|
||||||
"universal-cookie": "^4.0.4"
|
"universal-cookie": "^4.0.4",
|
||||||
|
"yup": "^0.32.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/add": "^2.0.2",
|
"@graphql-codegen/add": "^2.0.2",
|
||||||
@@ -99,7 +101,7 @@
|
|||||||
"extract-react-intl-messages": "^4.1.1",
|
"extract-react-intl-messages": "^4.1.1",
|
||||||
"postcss-safe-parser": "^5.0.2",
|
"postcss-safe-parser": "^5.0.2",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.2.1",
|
||||||
"react-scripts": "^4.0.1",
|
"react-scripts": "^4.0.3",
|
||||||
"stylelint": "^13.9.0",
|
"stylelint": "^13.9.0",
|
||||||
"stylelint-config-prettier": "^8.0.2",
|
"stylelint-config-prettier": "^8.0.2",
|
||||||
"stylelint-order": "^4.1.0",
|
"stylelint-order": "^4.1.0",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Improved performer details and edit UI pages.
|
||||||
* Resolve python executable to `python3` or `python` for python script scrapers.
|
* Resolve python executable to `python3` or `python` for python script scrapers.
|
||||||
* Add `url` field to `URLReplace`, and make `queryURLReplace` available when scraping by URL.
|
* Add `url` field to `URLReplace`, and make `queryURLReplace` available when scraping by URL.
|
||||||
* Make logging format consistent across platforms and include full timestamp.
|
* Make logging format consistent across platforms and include full timestamp.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
|
|||||||
import { PerformerScenesPanel } from "./PerformerScenesPanel";
|
import { PerformerScenesPanel } from "./PerformerScenesPanel";
|
||||||
import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel";
|
import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel";
|
||||||
import { PerformerImagesPanel } from "./PerformerImagesPanel";
|
import { PerformerImagesPanel } from "./PerformerImagesPanel";
|
||||||
|
import { PerformerEditPanel } from "./PerformerEditPanel";
|
||||||
|
|
||||||
interface IPerformerParams {
|
interface IPerformerParams {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -126,11 +127,7 @@ export const Performer: React.FC = () => {
|
|||||||
unmountOnExit
|
unmountOnExit
|
||||||
>
|
>
|
||||||
<Tab eventKey="details" title="Details">
|
<Tab eventKey="details" title="Details">
|
||||||
<PerformerDetailsPanel
|
<PerformerDetailsPanel performer={performer} />
|
||||||
performer={performer}
|
|
||||||
isEditing={false}
|
|
||||||
isVisible={activeTabKey === "details"}
|
|
||||||
/>
|
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab eventKey="scenes" title="Scenes">
|
<Tab eventKey="scenes" title="Scenes">
|
||||||
<PerformerScenesPanel performer={performer} />
|
<PerformerScenesPanel performer={performer} />
|
||||||
@@ -142,9 +139,8 @@ export const Performer: React.FC = () => {
|
|||||||
<PerformerImagesPanel performer={performer} />
|
<PerformerImagesPanel performer={performer} />
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab eventKey="edit" title="Edit">
|
<Tab eventKey="edit" title="Edit">
|
||||||
<PerformerDetailsPanel
|
<PerformerEditPanel
|
||||||
performer={performer}
|
performer={performer}
|
||||||
isEditing
|
|
||||||
isVisible={activeTabKey === "edit"}
|
isVisible={activeTabKey === "edit"}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -256,21 +252,22 @@ export const Performer: React.FC = () => {
|
|||||||
return <LoadingIndicator message="Encoding image..." />;
|
return <LoadingIndicator message="Encoding image..." />;
|
||||||
}
|
}
|
||||||
if (activeImage) {
|
if (activeImage) {
|
||||||
return <img className="photo" src={activeImage} alt="Performer" />;
|
return <img className="performer" src={activeImage} alt="Performer" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNew)
|
if (isNew)
|
||||||
return (
|
return (
|
||||||
<div className="row new-view">
|
<div className="row new-view" id="performer-page">
|
||||||
<div className="col-4">{renderPerformerImage()}</div>
|
<div className="performer-image-container col-md-4 text-center">
|
||||||
<div className="col-6">
|
{renderPerformerImage()}
|
||||||
|
</div>
|
||||||
|
<div className="col-md-8">
|
||||||
<h2>Create Performer</h2>
|
<h2>Create Performer</h2>
|
||||||
<PerformerDetailsPanel
|
<PerformerEditPanel
|
||||||
performer={performer}
|
performer={performer}
|
||||||
isEditing
|
|
||||||
isVisible
|
isVisible
|
||||||
isNew={isNew}
|
isNew
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onImageChange={onImageChange}
|
onImageChange={onImageChange}
|
||||||
onImageEncoding={onImageEncoding}
|
onImageEncoding={onImageEncoding}
|
||||||
|
|||||||
@@ -1,672 +1,31 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { useIntl } from "react-intl";
|
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 * as GQL from "src/core/generated-graphql";
|
||||||
import {
|
import { genderToString } from "src/core/StashService";
|
||||||
getGenderStrings,
|
import { TextUtils } from "src/utils";
|
||||||
useListPerformerScrapers,
|
import { TextField, URLField } from "src/utils/field";
|
||||||
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";
|
|
||||||
|
|
||||||
interface IPerformerDetails {
|
interface IPerformerDetails {
|
||||||
performer: Partial<GQL.PerformerDataFragment>;
|
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> = ({
|
export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
||||||
performer,
|
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
|
// Network state
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const intl = useIntl();
|
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() {
|
function renderStashIDs() {
|
||||||
if (!performer.stash_ids?.length) {
|
if (!performer.stash_ids?.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<dl className="row">
|
||||||
<td>StashIDs</td>
|
<dt className="col-3 col-xl-2">StashIDs</dt>
|
||||||
<td>
|
<dd className="col-9 col-xl-10">
|
||||||
<ul className="pl-0">
|
<ul className="pl-0">
|
||||||
{stashIDs.map((stashID) => {
|
{performer.stash_ids.map((stashID) => {
|
||||||
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||||
const link = base ? (
|
const link = base ? (
|
||||||
<a
|
<a
|
||||||
@@ -681,30 +40,17 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<li key={stashID.stash_id} className="row no-gutters">
|
<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}
|
{link}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</td>
|
</dd>
|
||||||
</tr>
|
</dl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatHeight = () => {
|
const formatHeight = (height?: string | null) => {
|
||||||
if (isEditing) {
|
|
||||||
return height;
|
|
||||||
}
|
|
||||||
if (!height) {
|
if (!height) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -717,92 +63,45 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderDeleteAlert()}
|
<TextField
|
||||||
{renderScraperDialog()}
|
name="Gender"
|
||||||
{maybeRenderScrapeDialog()}
|
value={genderToString(performer.gender ?? undefined)}
|
||||||
|
/>
|
||||||
<Table id="performer-details" className="w-100">
|
<TextField
|
||||||
<tbody>
|
name="Birthdate"
|
||||||
{maybeRenderName()}
|
value={TextUtils.formatDate(intl, performer.birthdate ?? undefined)}
|
||||||
{maybeRenderAliases()}
|
/>
|
||||||
{renderGender()}
|
<TextField name="Ethnicity" value={performer.ethnicity} />
|
||||||
{TableUtils.renderInputGroup({
|
<TextField name="Eye Color" value={performer.eye_color} />
|
||||||
title: "Birthdate",
|
<TextField name="Country" value={performer.country} />
|
||||||
value: isEditing
|
<TextField name="Height" value={formatHeight(performer.height)} />
|
||||||
? birthdate
|
<TextField name="Measurements" value={performer.measurements} />
|
||||||
: TextUtils.formatDate(intl, birthdate),
|
<TextField name="Fake Tits" value={performer.fake_tits} />
|
||||||
isEditing: !!isEditing,
|
<TextField name="Career Length" value={performer.career_length} />
|
||||||
onChange: setBirthdate,
|
<TextField name="Tattoos" value={performer.tattoos} />
|
||||||
})}
|
<TextField name="Piercings" value={performer.piercings} />
|
||||||
{renderEthnicity()}
|
<URLField
|
||||||
{TableUtils.renderInputGroup({
|
name="URL"
|
||||||
title: "Eye Color",
|
value={performer.url}
|
||||||
value: eyeColor,
|
url={TextUtils.sanitiseURL(performer.url ?? "")}
|
||||||
isEditing: !!isEditing,
|
/>
|
||||||
onChange: setEyeColor,
|
<URLField
|
||||||
})}
|
name="Twitter"
|
||||||
{TableUtils.renderInputGroup({
|
value={performer.twitter}
|
||||||
title: "Country",
|
url={TextUtils.sanitiseURL(
|
||||||
value: country,
|
performer.twitter ?? "",
|
||||||
isEditing: !!isEditing,
|
TextUtils.twitterURL
|
||||||
onChange: setCountry,
|
)}
|
||||||
})}
|
/>
|
||||||
{TableUtils.renderInputGroup({
|
<URLField
|
||||||
title: `Height ${isEditing ? "(cm)" : ""}`,
|
name="Instagram"
|
||||||
value: formatHeight(),
|
value={performer.instagram}
|
||||||
isEditing: !!isEditing,
|
url={TextUtils.sanitiseURL(
|
||||||
onChange: setHeight,
|
performer.instagram ?? "",
|
||||||
})}
|
TextUtils.instagramURL
|
||||||
{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()}
|
{renderStashIDs()}
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
{maybeRenderButtons()}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
#performer-edit {
|
||||||
.scrape-url-button {
|
.scrape-url-button:disabled {
|
||||||
color: $text-color;
|
opacity: 0.5;
|
||||||
float: right;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -406,7 +406,6 @@ div.dropdown-menu {
|
|||||||
|
|
||||||
.image-input {
|
.image-input {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
input[type="file"], /* FF, IE7+, chrome (except button) */
|
input[type="file"], /* FF, IE7+, chrome (except button) */
|
||||||
|
|||||||
43
ui/v2.5/src/utils/field.tsx
Normal file
43
ui/v2.5/src/utils/field.tsx
Normal file
@@ -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
Reference in New Issue
Block a user