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:
WithoutPants 2021-03-05 15:46:20 +11:00 committed by GitHub
parent c2c06d8f8d
commit 9d1b716f48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1974 additions and 1010 deletions

View File

@ -1,2 +1,3 @@
BROWSER=none
PORT=3000
ESLINT_NO_DEV_ERRORS=true

View File

@ -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",

View File

@ -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.

View File

@ -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}

View File

@ -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()}
</>
);
};

View File

@ -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>
</>
);
};

View File

@ -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;
}
}

View File

@ -406,7 +406,6 @@ div.dropdown-menu {
.image-input {
margin-bottom: 0;
overflow: hidden;
position: relative;
input[type="file"], /* FF, IE7+, chrome (except button) */

View 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>
);
};

File diff suppressed because it is too large Load Diff