mirror of https://github.com/stashapp/stash.git
Add scenes tab to performer page (#280)
This commit is contained in:
parent
dcfd445040
commit
63cc97d199
|
@ -1,18 +1,20 @@
|
|||
/* eslint-disable react/no-this-in-sfc */
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Form, Spinner, Table } from "react-bootstrap";
|
||||
import { Button, Spinner, Tabs, Tab } from "react-bootstrap";
|
||||
import { useParams, useHistory } from "react-router-dom";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { StashService } from "src/core/StashService";
|
||||
import {
|
||||
DetailsEditNavbar,
|
||||
Icon,
|
||||
Modal,
|
||||
ScrapePerformerSuggest
|
||||
} from "src/components/Shared";
|
||||
import { ImageUtils, TableUtils } from "src/utils";
|
||||
import { useToast } from "src/hooks";
|
||||
import { useToast } from 'src/hooks';
|
||||
import { TextUtils } from "src/utils";
|
||||
import Lightbox from 'react-images';
|
||||
import { IconName } from "@fortawesome/fontawesome-svg-core";
|
||||
import { PerformerDetailsPanel } from './PerformerDetailsPanel';
|
||||
import { PerformerOperationsPanel } from './PerformerOperationsPanel';
|
||||
import { PerformerScenesPanel } from './PerformerScenesPanel';
|
||||
|
||||
export const Performer: React.FC = () => {
|
||||
const Toast = useToast();
|
||||
|
@ -20,73 +22,19 @@ export const Performer: React.FC = () => {
|
|||
const { id = "new" } = useParams();
|
||||
const isNew = id === "new";
|
||||
|
||||
// Editing state
|
||||
const [isEditing, setIsEditing] = useState<boolean>(isNew);
|
||||
const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<GQL.Scraper>();
|
||||
const [scrapePerformerDetails, setScrapePerformerDetails] = useState<GQL.ScrapedPerformerDataFragment>();
|
||||
|
||||
// Editing performer state
|
||||
const [image, setImage] = useState<string>();
|
||||
const [name, setName] = useState<string>();
|
||||
const [aliases, setAliases] = useState<string>();
|
||||
const [favorite, setFavorite] = useState<boolean>();
|
||||
const [birthdate, setBirthdate] = useState<string>();
|
||||
const [ethnicity, setEthnicity] = useState<string>();
|
||||
const [country, setCountry] = useState<string>();
|
||||
const [eyeColor, setEyeColor] = useState<string>();
|
||||
const [height, setHeight] = useState<string>();
|
||||
const [measurements, setMeasurements] = useState<string>();
|
||||
const [fakeTits, setFakeTits] = useState<string>();
|
||||
const [careerLength, setCareerLength] = useState<string>();
|
||||
const [tattoos, setTattoos] = useState<string>();
|
||||
const [piercings, setPiercings] = useState<string>();
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [twitter, setTwitter] = useState<string>();
|
||||
const [instagram, setInstagram] = useState<string>();
|
||||
|
||||
// Performer state
|
||||
const [performer, setPerformer] = useState<Partial<GQL.PerformerDataFragment>>({});
|
||||
const [imagePreview, setImagePreview] = useState<string>();
|
||||
const [lightboxIsOpen, setLightboxIsOpen] = useState(false);
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const Scrapers = StashService.useListPerformerScrapers();
|
||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
||||
|
||||
const { data, error } = StashService.useFindPerformer(id);
|
||||
const [updatePerformer] = StashService.usePerformerUpdate(
|
||||
getPerformerInput() as GQL.PerformerUpdateInput
|
||||
);
|
||||
const [createPerformer] = StashService.usePerformerCreate(
|
||||
getPerformerInput() as GQL.PerformerCreateInput
|
||||
);
|
||||
const [deletePerformer] = StashService.usePerformerDestroy(
|
||||
getPerformerInput() as GQL.PerformerDestroyInput
|
||||
);
|
||||
const [updatePerformer] = StashService.usePerformerUpdate();
|
||||
const [createPerformer] = StashService.usePerformerCreate();
|
||||
const [deletePerformer] = StashService.usePerformerDestroy();
|
||||
|
||||
function updatePerformerEditState(
|
||||
state: Partial<GQL.PerformerDataFragment | GQL.ScrapedPerformer>
|
||||
) {
|
||||
if ((state as GQL.PerformerDataFragment).favorite !== undefined) {
|
||||
setFavorite((state as GQL.PerformerDataFragment).favorite);
|
||||
}
|
||||
setName(state.name ?? undefined);
|
||||
setAliases(state.aliases ?? undefined);
|
||||
setBirthdate(state.birthdate ?? undefined);
|
||||
setEthnicity(state.ethnicity ?? undefined);
|
||||
setCountry(state.country ?? undefined);
|
||||
setEyeColor(state.eye_color ?? undefined);
|
||||
setHeight(state.height ?? undefined);
|
||||
setMeasurements(state.measurements ?? undefined);
|
||||
setFakeTits(state.fake_tits ?? undefined);
|
||||
setCareerLength(state.career_length ?? undefined);
|
||||
setTattoos(state.tattoos ?? undefined);
|
||||
setPiercings(state.piercings ?? undefined);
|
||||
setUrl(state.url ?? undefined);
|
||||
setTwitter(state.twitter ?? undefined);
|
||||
setInstagram(state.instagram ?? undefined);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(false);
|
||||
|
@ -95,69 +43,26 @@ export const Performer: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
setImagePreview(performer.image_path ?? undefined);
|
||||
setImage(undefined);
|
||||
updatePerformerEditState(performer);
|
||||
if (!isNew)
|
||||
setIsEditing(false);
|
||||
}, [performer]);
|
||||
|
||||
function onImageLoad(this: FileReader) {
|
||||
setImagePreview(this.result as string);
|
||||
setImage(this.result as string);
|
||||
function onImageChange(image: string) {
|
||||
setImagePreview(image);
|
||||
}
|
||||
|
||||
ImageUtils.usePasteImage(onImageLoad);
|
||||
|
||||
useEffect(() => {
|
||||
const newQueryableScrapers = (Scrapers?.data?.listPerformerScrapers ?? []).filter(s => (
|
||||
s.performer?.supported_scrapes.includes(GQL.ScrapeType.Name)
|
||||
));
|
||||
|
||||
setQueryableScrapers(newQueryableScrapers);
|
||||
}, [Scrapers]);
|
||||
|
||||
if ((!isNew && !isEditing && !data?.findPerformer) || isLoading)
|
||||
if ((!isNew && (!data || !data.findPerformer)) || isLoading)
|
||||
return <Spinner animation="border" variant="light" />;
|
||||
|
||||
if (error) return <div>{error.message}</div>;
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
if (!isNew) {
|
||||
(performerInput as GQL.PerformerUpdateInput).id = id;
|
||||
}
|
||||
return performerInput;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
async function onSave(performerInput: Partial<GQL.PerformerCreateInput> | Partial<GQL.PerformerUpdateInput>) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!isNew) {
|
||||
const result = await updatePerformer();
|
||||
const result = await updatePerformer({variables: performerInput as GQL.PerformerUpdateInput});
|
||||
if(result.data?.performerUpdate)
|
||||
setPerformer(result.data?.performerUpdate);
|
||||
} else {
|
||||
const result = await createPerformer();
|
||||
const result = await createPerformer({variables: performerInput as GQL.PerformerCreateInput});
|
||||
if(result.data?.performerCreate) {
|
||||
setPerformer(result.data.performerCreate);
|
||||
history.push(`/performers/${result.data.performerCreate.id}`);
|
||||
|
@ -172,7 +77,7 @@ export const Performer: React.FC = () => {
|
|||
async function onDelete() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deletePerformer();
|
||||
await deletePerformer({variables: { id }});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
|
@ -182,273 +87,159 @@ export const Performer: React.FC = () => {
|
|||
history.push("/performers");
|
||||
}
|
||||
|
||||
async function onAutoTag() {
|
||||
if (!performer.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await StashService.queryMetadataAutoTag({ performers: [performer.id] });
|
||||
Toast.success({ content: "Started auto tagging" });
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
function onDisplayFreeOnesDialog(
|
||||
scraper: GQL.Scraper
|
||||
) {
|
||||
setIsDisplayingScraperDialog(scraper);
|
||||
}
|
||||
|
||||
function getQueryScraperPerformerInput() {
|
||||
if (!scrapePerformerDetails) return {};
|
||||
|
||||
const { __typename, ...ret } = scrapePerformerDetails;
|
||||
debugger;
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function onScrapePerformer() {
|
||||
setIsDisplayingScraperDialog(undefined);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!scrapePerformerDetails || !isDisplayingScraperDialog) return;
|
||||
const result = await StashService.queryScrapePerformer(
|
||||
isDisplayingScraperDialog.id,
|
||||
getQueryScraperPerformerInput()
|
||||
function renderTabs() {
|
||||
function renderEditPanel() {
|
||||
return (
|
||||
<PerformerDetailsPanel
|
||||
performer={performer}
|
||||
isEditing
|
||||
isNew={isNew}
|
||||
onDelete={onDelete}
|
||||
onSave={onSave}
|
||||
onImageChange={onImageChange}
|
||||
/>
|
||||
);
|
||||
if (!result?.data?.scrapePerformer) return;
|
||||
updatePerformerEditState(result.data.scrapePerformer);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
// render tabs if not new
|
||||
if (!isNew) {
|
||||
return (
|
||||
<Tabs defaultActiveKey="details" id="performer-details">
|
||||
<Tab eventKey="details" title="Details">
|
||||
<PerformerDetailsPanel performer={performer} isEditing={false} />
|
||||
</Tab>
|
||||
<Tab eventKey="scenes" title="Scenes">
|
||||
<PerformerScenesPanel performer={performer} />
|
||||
</Tab>
|
||||
<Tab eventKey="edit" title="Edit">
|
||||
{ renderEditPanel() }
|
||||
</Tab>
|
||||
<Tab eventKey="operations" title="Operations">
|
||||
<PerformerOperationsPanel performer={performer} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
return renderEditPanel();
|
||||
}
|
||||
|
||||
async function onScrapePerformerURL() {
|
||||
if (!url) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await StashService.queryScrapePerformerURL(url);
|
||||
if (!result.data || !result.data.scrapePerformerURL) {
|
||||
return;
|
||||
function maybeRenderAge() {
|
||||
if (performer && performer.birthdate) {
|
||||
// calculate the age from birthdate. In future, this should probably be
|
||||
// provided by the server
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<span className="age">{TextUtils.age(performer.birthdate)}</span>
|
||||
<span className="age-tail"> years old</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderAliases() {
|
||||
if (performer && performer.aliases) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<span className="alias-head">Also known as </span>
|
||||
<span className="alias">{performer.aliases}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setFavorite(v : boolean) {
|
||||
performer.favorite = v;
|
||||
onSave(performer);
|
||||
}
|
||||
|
||||
function renderIcons() {
|
||||
function maybeRenderURL(url?: string, icon: IconName = "link") {
|
||||
if (performer.url) {
|
||||
return (
|
||||
<Button>
|
||||
<a href={performer.url}>
|
||||
<Icon icon={icon} />
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
updatePerformerEditState(result.data.scrapePerformerURL);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderEthnicity() {
|
||||
return TableUtils.renderHtmlSelect({
|
||||
title: "Ethnicity",
|
||||
value: ethnicity,
|
||||
isEditing,
|
||||
onChange: (value: string) => setEthnicity(value),
|
||||
selectOptions: ["white", "black", "asian", "hispanic"]
|
||||
});
|
||||
}
|
||||
|
||||
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)}
|
||||
/>
|
||||
<>
|
||||
<span className="name-icons">
|
||||
<Button
|
||||
className={performer.favorite ? "favorite" : "not-favorite"}
|
||||
onClick={() => setFavorite(!performer.favorite)}
|
||||
><Icon icon="heart" />
|
||||
</Button>
|
||||
{maybeRenderURL(performer.url ?? undefined)}
|
||||
{/* TODO - render instagram and twitter links with icons */}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNewView() {
|
||||
return (
|
||||
<div className="columns is-multiline no-spacing">
|
||||
<div className="column is-half details-image-container">
|
||||
<img className="performer" src={imagePreview} alt='' />
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="column is-half details-detail-container">
|
||||
{renderTabs()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function urlScrapable(scrapedUrl: string) {
|
||||
return (
|
||||
!!scrapedUrl &&
|
||||
(Scrapers?.data?.listPerformerScrapers ?? []).some(s =>
|
||||
(s?.performer?.urls ?? []).some(u => scrapedUrl.includes(u))
|
||||
)
|
||||
);
|
||||
const photos = [{src: imagePreview || "", caption: "Image"}];
|
||||
|
||||
function openLightbox() {
|
||||
setLightboxIsOpen(true);
|
||||
}
|
||||
|
||||
function maybeRenderScrapeButton() {
|
||||
if (!url || !isEditing || !urlScrapable(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<Button id="scrape-url-button" onClick={() => onScrapePerformerURL()}>
|
||||
<Icon icon="file-upload" />
|
||||
</Button>
|
||||
);
|
||||
function closeLightbox() {
|
||||
setLightboxIsOpen(false);
|
||||
}
|
||||
|
||||
function renderURLField() {
|
||||
return (
|
||||
<tr>
|
||||
<td id="url-field">
|
||||
URL
|
||||
{maybeRenderScrapeButton()}
|
||||
</td>
|
||||
<td>
|
||||
<Form.Control
|
||||
value={url}
|
||||
readOnly={!isEditing}
|
||||
plaintext={!isEditing}
|
||||
placeholder="URL"
|
||||
onChange={(event: React.FormEvent<HTMLInputElement>) =>
|
||||
setUrl(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
if (isNew) {
|
||||
return renderNewView();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderScraperDialog()}
|
||||
<div className="row is-multiline no-spacing">
|
||||
<div className="col-6 details-image-container">
|
||||
<img className="performer" alt="" src={imagePreview} />
|
||||
<div id="performer-page">
|
||||
<div className="details-image-container">
|
||||
<img className="performer" src={imagePreview} onClick={openLightbox} alt='' />
|
||||
</div>
|
||||
<div className="col-6 details-detail-container">
|
||||
<DetailsEditNavbar
|
||||
performer={performer}
|
||||
isNew={isNew}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => {
|
||||
setIsEditing(!isEditing);
|
||||
updatePerformerEditState(performer);
|
||||
}}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
onImageChange={onImageChangeHandler}
|
||||
scrapers={queryableScrapers}
|
||||
onDisplayScraperDialog={onDisplayFreeOnesDialog}
|
||||
onAutoTag={onAutoTag}
|
||||
/>
|
||||
<h1>
|
||||
<Form.Control
|
||||
readOnly={!isEditing}
|
||||
plaintext={!isEditing}
|
||||
defaultValue={name}
|
||||
placeholder="Name"
|
||||
onChange={(event: any) => setName(event.target.value)}
|
||||
/>
|
||||
<div className="performer-head">
|
||||
<h1 className="bp3-heading">
|
||||
{performer.name}
|
||||
{renderIcons()}
|
||||
</h1>
|
||||
<h6>
|
||||
<Form.Group className="aliases-field" controlId="aliases">
|
||||
<Form.Label>Aliases:</Form.Label>
|
||||
<Form.Control
|
||||
value={aliases}
|
||||
readOnly={!isEditing}
|
||||
plaintext={!isEditing}
|
||||
placeholder="Aliases"
|
||||
onChange={(event: React.FormEvent<HTMLInputElement>) =>
|
||||
setAliases(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
</h6>
|
||||
<div>
|
||||
<span style={{ fontWeight: 300 }}>Favorite:</span>
|
||||
<Button
|
||||
disabled={!isEditing}
|
||||
className={favorite ? "favorite" : undefined}
|
||||
onClick={() => setFavorite(!favorite)}
|
||||
>
|
||||
<Icon icon="heart" />
|
||||
</Button>
|
||||
</div>
|
||||
{maybeRenderAliases()}
|
||||
{maybeRenderAge()}
|
||||
</div>
|
||||
|
||||
<Table id="performer-details" style={{ width: "100%" }}>
|
||||
<tbody>
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Birthdate (YYYY-MM-DD)",
|
||||
value: birthdate,
|
||||
isEditing,
|
||||
onChange: setBirthdate
|
||||
})}
|
||||
{renderEthnicity()}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Eye Color",
|
||||
value: eyeColor,
|
||||
isEditing,
|
||||
onChange: setEyeColor
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Country",
|
||||
value: country,
|
||||
isEditing,
|
||||
onChange: setCountry
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Height (CM)",
|
||||
value: height,
|
||||
isEditing,
|
||||
onChange: setHeight
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Measurements",
|
||||
value: measurements,
|
||||
isEditing,
|
||||
onChange: setMeasurements
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Fake Tits",
|
||||
value: fakeTits,
|
||||
isEditing,
|
||||
onChange: setFakeTits
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Career Length",
|
||||
value: careerLength,
|
||||
isEditing,
|
||||
onChange: setCareerLength
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Tattoos",
|
||||
value: tattoos,
|
||||
isEditing,
|
||||
onChange: setTattoos
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Piercings",
|
||||
value: piercings,
|
||||
isEditing,
|
||||
onChange: setPiercings
|
||||
})}
|
||||
{renderURLField()}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Twitter",
|
||||
value: twitter,
|
||||
isEditing,
|
||||
onChange: setTwitter
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Instagram",
|
||||
value: instagram,
|
||||
isEditing,
|
||||
onChange: setInstagram
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
<div className="performer-body">
|
||||
<div className="details-detail-container">
|
||||
{renderTabs()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Lightbox
|
||||
images={photos}
|
||||
onClose={closeLightbox}
|
||||
currentImage={0}
|
||||
isOpen={lightboxIsOpen}
|
||||
onClickImage={() => window.open(imagePreview, "_blank")}
|
||||
width={9999}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,434 @@
|
|||
/* eslint-disable react/no-this-in-sfc */
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Form, Popover, OverlayTrigger, Spinner, Table } from "react-bootstrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { StashService } from "src/core/StashService";
|
||||
import {
|
||||
Icon,
|
||||
Modal,
|
||||
ScrapePerformerSuggest
|
||||
} from "src/components/Shared";
|
||||
import { ImageUtils, TableUtils } from "src/utils";
|
||||
import { useToast } from "src/hooks";
|
||||
|
||||
interface IPerformerDetails {
|
||||
performer: Partial<GQL.PerformerDataFragment>;
|
||||
isNew?: boolean;
|
||||
isEditing?: boolean;
|
||||
onSave?: (performer: Partial<GQL.PerformerCreateInput> | Partial<GQL.PerformerUpdateInput>) => void;
|
||||
onDelete?: () => void;
|
||||
onImageChange?: (image: string) => void;
|
||||
}
|
||||
|
||||
export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({ performer, isNew, isEditing, onSave, onDelete, onImageChange }) => {
|
||||
const Toast = useToast();
|
||||
|
||||
// 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>();
|
||||
const [name, setName] = useState<string>();
|
||||
const [aliases, setAliases] = useState<string>();
|
||||
const [favorite, setFavorite] = useState<boolean>();
|
||||
const [birthdate, setBirthdate] = useState<string>();
|
||||
const [ethnicity, setEthnicity] = useState<string>();
|
||||
const [country, setCountry] = useState<string>();
|
||||
const [eyeColor, setEyeColor] = useState<string>();
|
||||
const [height, setHeight] = useState<string>();
|
||||
const [measurements, setMeasurements] = useState<string>();
|
||||
const [fakeTits, setFakeTits] = useState<string>();
|
||||
const [careerLength, setCareerLength] = useState<string>();
|
||||
const [tattoos, setTattoos] = useState<string>();
|
||||
const [piercings, setPiercings] = useState<string>();
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [twitter, setTwitter] = useState<string>();
|
||||
const [instagram, setInstagram] = useState<string>();
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const Scrapers = StashService.useListPerformerScrapers();
|
||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
||||
|
||||
|
||||
function updatePerformerEditState(
|
||||
state: Partial<GQL.PerformerDataFragment | GQL.ScrapedPerformer>
|
||||
) {
|
||||
if ((state as GQL.PerformerDataFragment).favorite !== undefined) {
|
||||
setFavorite((state as GQL.PerformerDataFragment).favorite);
|
||||
}
|
||||
setName(state.name ?? undefined);
|
||||
setAliases(state.aliases ?? undefined);
|
||||
setBirthdate(state.birthdate ?? undefined);
|
||||
setEthnicity(state.ethnicity ?? undefined);
|
||||
setCountry(state.country ?? undefined);
|
||||
setEyeColor(state.eye_color ?? undefined);
|
||||
setHeight(state.height ?? undefined);
|
||||
setMeasurements(state.measurements ?? undefined);
|
||||
setFakeTits(state.fake_tits ?? undefined);
|
||||
setCareerLength(state.career_length ?? undefined);
|
||||
setTattoos(state.tattoos ?? undefined);
|
||||
setPiercings(state.piercings ?? undefined);
|
||||
setUrl(state.url ?? undefined);
|
||||
setTwitter(state.twitter ?? undefined);
|
||||
setInstagram(state.instagram ?? undefined);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setImage(undefined);
|
||||
updatePerformerEditState(performer);
|
||||
}, [performer]);
|
||||
|
||||
function onImageLoad(this: FileReader) {
|
||||
setImage(this.result as string);
|
||||
if (onImageChange) {
|
||||
onImageChange(this.result as string);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing)
|
||||
ImageUtils.usePasteImage(onImageLoad);
|
||||
|
||||
useEffect(() => {
|
||||
const newQueryableScrapers = (Scrapers?.data?.listPerformerScrapers ?? []).filter(s => (
|
||||
s.performer?.supported_scrapes.includes(GQL.ScrapeType.Name)
|
||||
));
|
||||
|
||||
setQueryableScrapers(newQueryableScrapers);
|
||||
}, [Scrapers]);
|
||||
|
||||
if (isLoading)
|
||||
return <Spinner animation="border" variant="light" />;
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
if (!isNew) {
|
||||
(performerInput as GQL.PerformerUpdateInput).id = performer.id!;
|
||||
}
|
||||
return performerInput;
|
||||
}
|
||||
|
||||
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
function onDisplayFreeOnesDialog(
|
||||
scraper: GQL.Scraper
|
||||
) {
|
||||
setIsDisplayingScraperDialog(scraper);
|
||||
}
|
||||
|
||||
function getQueryScraperPerformerInput() {
|
||||
if (!scrapePerformerDetails) return {};
|
||||
|
||||
const { __typename, ...ret } = scrapePerformerDetails;
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function onScrapePerformer() {
|
||||
setIsDisplayingScraperDialog(undefined);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!scrapePerformerDetails || !isDisplayingScraperDialog) return;
|
||||
const result = await StashService.queryScrapePerformer(
|
||||
isDisplayingScraperDialog.id,
|
||||
getQueryScraperPerformerInput()
|
||||
);
|
||||
if (!result?.data?.scrapePerformer) return;
|
||||
updatePerformerEditState(result.data.scrapePerformer);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function onScrapePerformerURL() {
|
||||
if (!url) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await StashService.queryScrapePerformerURL(url);
|
||||
if (!result.data || !result.data.scrapePerformerURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// leave URL as is if not set explicitly
|
||||
if (!result.data.scrapePerformerURL.url) {
|
||||
result.data.scrapePerformerURL.url = url;
|
||||
}
|
||||
updatePerformerEditState(result.data.scrapePerformerURL);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderEthnicity() {
|
||||
return TableUtils.renderHtmlSelect({
|
||||
title: "Ethnicity",
|
||||
value: ethnicity,
|
||||
isEditing: !!isEditing,
|
||||
onChange: (value: string) => setEthnicity(value),
|
||||
selectOptions: ["white", "black", "asian", "hispanic"]
|
||||
});
|
||||
}
|
||||
|
||||
function renderScraperMenu() {
|
||||
if (!performer || !isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const popover = (
|
||||
<Popover id="scraper-popover">
|
||||
<Popover.Content>
|
||||
<div>
|
||||
{queryableScrapers
|
||||
? queryableScrapers.map(s => (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() =>
|
||||
onDisplayFreeOnesDialog(s)
|
||||
}
|
||||
>
|
||||
{s.name}
|
||||
</Button>
|
||||
))
|
||||
: ""}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<OverlayTrigger trigger="click" placement="bottom" overlay={popover}>
|
||||
<Button>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 id="scrape-url-button" onClick={() => onScrapePerformerURL()}>
|
||||
<Icon icon="file-upload" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function renderURLField() {
|
||||
return (
|
||||
<tr>
|
||||
<td id="url-field">
|
||||
URL
|
||||
{maybeRenderScrapeButton()}
|
||||
</td>
|
||||
<td>
|
||||
<Form.Control
|
||||
value={url}
|
||||
readOnly={!isEditing}
|
||||
plaintext={!isEditing}
|
||||
placeholder="URL"
|
||||
onChange={(event: React.FormEvent<HTMLInputElement>) =>
|
||||
setUrl(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderButtons() {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<>
|
||||
<Button className="edit-button" variant="primary" onClick={() => onSave?.(getPerformerInput())}>Save</Button>
|
||||
{!isNew ? <Button className="edit-button" variant="danger" onClick={() => setIsDeleteAlertOpen(true)}>Delete</Button> : ''}
|
||||
{renderScraperMenu()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 renderImageInput() {
|
||||
if (!isEditing) { return; }
|
||||
return (
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td><Form.Control type="file" onChange={onImageChangeHandler} accept=".jpg,.jpeg" /></td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderDeleteAlert()}
|
||||
{renderScraperDialog()}
|
||||
|
||||
<Table id="performer-details" style={{ width: "100%" }}>
|
||||
<tbody>
|
||||
{maybeRenderName()}
|
||||
{maybeRenderAliases()}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Birthdate (YYYY-MM-DD)",
|
||||
value: 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 (CM)",
|
||||
value: height,
|
||||
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,
|
||||
isEditing: !!isEditing,
|
||||
onChange: setTwitter
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Instagram",
|
||||
value: instagram,
|
||||
isEditing: !!isEditing,
|
||||
onChange: setInstagram
|
||||
})}
|
||||
{renderImageInput()}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
{maybeRenderButtons()}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
import {
|
||||
Button,
|
||||
} from "react-bootstrap";
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { StashService } from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
|
||||
interface IPerformerOperationsProps {
|
||||
performer: Partial<GQL.PerformerDataFragment>
|
||||
}
|
||||
|
||||
export const PerformerOperationsPanel: React.FC<IPerformerOperationsProps> = ({ performer }) => {
|
||||
const Toast = useToast();
|
||||
|
||||
async function onAutoTag() {
|
||||
if (!performer?.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await StashService.queryMetadataAutoTag({ performers: [performer.id]});
|
||||
Toast.success({ content: "Started auto tagging" });
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return <Button onClick={onAutoTag}>Auto Tag</Button>;
|
||||
};
|
|
@ -0,0 +1,47 @@
|
|||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { SceneList } from "../../scenes/SceneList";
|
||||
|
||||
interface IPerformerDetailsProps {
|
||||
performer: Partial<GQL.PerformerDataFragment>
|
||||
}
|
||||
|
||||
export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({ performer }) => {
|
||||
|
||||
function filterHook(filter: ListFilterModel) {
|
||||
const performerValue = {id: performer.id!, label: performer.name!};
|
||||
// if performers is already present, then we modify it, otherwise add
|
||||
let performerCriterion = filter.criteria.find((c) => {
|
||||
return c.type === "performers";
|
||||
});
|
||||
|
||||
if (performerCriterion &&
|
||||
(performerCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||
performerCriterion.modifier === GQL.CriterionModifier.Includes)) {
|
||||
// add the performer if not present
|
||||
if (!performerCriterion.value.find((p : any) => {
|
||||
return p.id === performer.id;
|
||||
})) {
|
||||
performerCriterion.value.push(performerValue);
|
||||
}
|
||||
|
||||
performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
performerCriterion = new PerformersCriterion();
|
||||
performerCriterion.value = [performerValue];
|
||||
filter.criteria.push(performerCriterion);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
return (
|
||||
<SceneList
|
||||
subComponent
|
||||
filterHook={filterHook}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -14,7 +14,12 @@ import { SceneCard } from "./SceneCard";
|
|||
import { SceneListTable } from "./SceneListTable";
|
||||
import { SceneSelectedOptions } from "./SceneSelectedOptions";
|
||||
|
||||
export const SceneList: React.FC = () => {
|
||||
interface ISceneList {
|
||||
subComponent?: boolean;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
}
|
||||
|
||||
export const SceneList: React.FC<ISceneList> = ({ subComponent, filterHook }) => {
|
||||
const history = useHistory();
|
||||
const otherOperations = [
|
||||
{
|
||||
|
@ -27,7 +32,9 @@ export const SceneList: React.FC = () => {
|
|||
zoomable: true,
|
||||
otherOperations,
|
||||
renderContent,
|
||||
renderSelectedOptions
|
||||
renderSelectedOptions,
|
||||
subComponent,
|
||||
filterHook
|
||||
});
|
||||
|
||||
async function playRandom(
|
||||
|
|
|
@ -290,27 +290,24 @@ export class StashService {
|
|||
"allPerformers"
|
||||
];
|
||||
|
||||
public static usePerformerCreate(input: GQL.PerformerCreateInput) {
|
||||
public static usePerformerCreate() {
|
||||
return GQL.usePerformerCreateMutation({
|
||||
variables: input,
|
||||
update: () =>
|
||||
StashService.invalidateQueries(
|
||||
StashService.performerMutationImpactedQueries
|
||||
)
|
||||
});
|
||||
}
|
||||
public static usePerformerUpdate(input: GQL.PerformerUpdateInput) {
|
||||
public static usePerformerUpdate() {
|
||||
return GQL.usePerformerUpdateMutation({
|
||||
variables: input,
|
||||
update: () =>
|
||||
StashService.invalidateQueries(
|
||||
StashService.performerMutationImpactedQueries
|
||||
)
|
||||
});
|
||||
}
|
||||
public static usePerformerDestroy(input: GQL.PerformerDestroyInput) {
|
||||
public static usePerformerDestroy() {
|
||||
return GQL.usePerformerDestroyMutation({
|
||||
variables: input,
|
||||
update: () =>
|
||||
StashService.invalidateQueries(
|
||||
StashService.performerMutationImpactedQueries
|
||||
|
|
|
@ -39,6 +39,8 @@ interface IListHookOperation<T> {
|
|||
}
|
||||
|
||||
interface IListHookOptions<T> {
|
||||
subComponent?: boolean;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
zoomable?: boolean;
|
||||
otherOperations?: IListHookOperation<T>[];
|
||||
renderContent: (
|
||||
|
@ -75,17 +77,27 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
|
|||
const [filter, setFilter] = useState<ListFilterModel>(
|
||||
new ListFilterModel(
|
||||
options.filterMode,
|
||||
queryString.parse(history.location.search)
|
||||
options.subComponent ? '' : queryString.parse(history.location.search)
|
||||
)
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastClickedId, setLastClickedId] = useState<string | undefined>();
|
||||
const [zoomIndex, setZoomIndex] = useState<number>(1);
|
||||
|
||||
const result = options.useData(filter);
|
||||
const result = options.useData(getFilter());
|
||||
const totalCount = options.getCount(result);
|
||||
const items = options.getData(result);
|
||||
|
||||
function getFilter() {
|
||||
if (!options.filterHook) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
// make a copy of the filter and call the hook
|
||||
let newFilter = _.cloneDeep(filter);
|
||||
return options.filterHook(newFilter);
|
||||
}
|
||||
|
||||
function updateQueryParams(listfilter: ListFilterModel) {
|
||||
const newLocation = { ...history.location };
|
||||
newLocation.search = listfilter.makeQueryParameters();
|
||||
|
|
|
@ -456,6 +456,39 @@ span.block {
|
|||
}
|
||||
}
|
||||
|
||||
#performer-page {
|
||||
margin: 10px auto;
|
||||
width: 75%;
|
||||
|
||||
& .details-image-container {
|
||||
max-height: 400px;
|
||||
display: inline-block;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
& .performer-head {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
font-size: 1.2em;
|
||||
|
||||
& .name-icons {
|
||||
margin-left: 10px;
|
||||
|
||||
& .not-favorite .bp3-icon {
|
||||
color: rgba(191, 204, 214, 0.5) !important;
|
||||
}
|
||||
|
||||
& .favorite .bp3-icon {
|
||||
color: #ff7373 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .alias {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.zoom-slider {
|
||||
margin: auto 5px;
|
||||
width: 100px;
|
||||
|
|
|
@ -55,6 +55,8 @@ const renderInputGroup = (options: {
|
|||
placeholder?: string;
|
||||
value: string | undefined;
|
||||
isEditing: boolean;
|
||||
asURL?: boolean,
|
||||
urlPrefix?: string,
|
||||
onChange: (value: string) => void;
|
||||
}) => (
|
||||
<tr>
|
||||
|
|
Loading…
Reference in New Issue