Add scenes tab to performer page (#280)

* Make performer page tabbed

* Add performer scenes tab

* Make performer scenes criteria smarter

* Adjust performer page layout. Add URL links

* Add lightbox for performer image

* Alias editing
This commit is contained in:
WithoutPants 2020-01-06 05:56:06 +11:00 committed by Leopere
parent 7fdaccf669
commit bab7c8f250
12 changed files with 761 additions and 333 deletions

View File

@ -1,11 +1,10 @@
import {
Button,
Classes,
Dialog,
EditableText,
HTMLTable,
Spinner,
FormGroup,
Tabs,
Tab,
Button,
AnchorButton,
IconName,
} from "@blueprintjs/core";
import _ from "lodash";
import React, { FunctionComponent, useEffect, useState } from "react";
@ -13,77 +12,29 @@ import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { IBaseProps } from "../../../models";
import { ErrorUtils } from "../../../utils/errors";
import { TableUtils } from "../../../utils/table";
import { ScrapePerformerSuggest } from "../../select/ScrapePerformerSuggest";
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
import { ToastUtils } from "../../../utils/toasts";
import { EditableTextUtils } from "../../../utils/editabletext";
import { ImageUtils } from "../../../utils/image";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel";
import { TextUtils } from "../../../utils/text";
import Lightbox from "react-images";
interface IPerformerProps extends IBaseProps {}
export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerProps) => {
const isNew = props.match.params.id === "new";
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<GQL.ListPerformerScrapersListPerformerScrapers | undefined>(undefined);
const [scrapePerformerDetails, setScrapePerformerDetails] = useState<GQL.ScrapePerformerListScrapePerformerList | undefined>(undefined);
// Editing performer state
const [image, setImage] = useState<string | undefined>(undefined);
const [name, setName] = useState<string | undefined>(undefined);
const [aliases, setAliases] = useState<string | undefined>(undefined);
const [favorite, setFavorite] = useState<boolean | undefined>(undefined);
const [birthdate, setBirthdate] = useState<string | undefined>(undefined);
const [ethnicity, setEthnicity] = useState<string | undefined>(undefined);
const [country, setCountry] = useState<string | undefined>(undefined);
const [eyeColor, setEyeColor] = useState<string | undefined>(undefined);
const [height, setHeight] = useState<string | undefined>(undefined);
const [measurements, setMeasurements] = useState<string | undefined>(undefined);
const [fakeTits, setFakeTits] = useState<string | undefined>(undefined);
const [careerLength, setCareerLength] = useState<string | undefined>(undefined);
const [tattoos, setTattoos] = useState<string | undefined>(undefined);
const [piercings, setPiercings] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined);
const [twitter, setTwitter] = useState<string | undefined>(undefined);
const [instagram, setInstagram] = useState<string | undefined>(undefined);
// Performer state
const [performer, setPerformer] = useState<Partial<GQL.PerformerDataFragment>>({});
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false);
// Network state
const [isLoading, setIsLoading] = useState(false);
const Scrapers = StashService.useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListPerformerScrapersListPerformerScrapers[]>([]);
const { data, error, loading } = StashService.useFindPerformer(props.match.params.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);
function updatePerformerEditState(state: Partial<GQL.PerformerDataFragment | GQL.ScrapeFreeonesScrapeFreeones>) {
if ((state as GQL.PerformerDataFragment).favorite !== undefined) {
setFavorite((state as GQL.PerformerDataFragment).favorite);
}
setName(state.name);
setAliases(state.aliases);
setBirthdate(state.birthdate);
setEthnicity(state.ethnicity);
setCountry(state.country);
setEyeColor(state.eye_color);
setHeight(state.height);
setMeasurements(state.measurements);
setFakeTits(state.fake_tits);
setCareerLength(state.career_length);
setTattoos(state.tattoos);
setPiercings(state.piercings);
setUrl(state.url);
setTwitter(state.twitter);
setInstagram(state.instagram);
}
const updatePerformer = StashService.usePerformerUpdate();
const createPerformer = StashService.usePerformerCreate();
const deletePerformer = StashService.usePerformerDestroy();
useEffect(() => {
setIsLoading(loading);
@ -93,73 +44,25 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
useEffect(() => {
setImagePreview(performer.image_path);
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.addPasteImageHook(onImageLoad);
useEffect(() => {
var newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = [];
if (!!Scrapers.data && Scrapers.data.listPerformerScrapers) {
newQueryableScrapers = Scrapers.data.listPerformerScrapers.filter((s) => {
return s.performer && s.performer.supported_scrapes.includes(GQL.ScrapeType.Name);
});
}
setQueryableScrapers(newQueryableScrapers);
}, [Scrapers.data]);
if ((!isNew && !isEditing && (!data || !data.findPerformer)) || isLoading) {
if ((!isNew && (!data || !data.findPerformer)) || isLoading) {
return <Spinner size={Spinner.SIZE_LARGE} />;
}
if (!!error) { return <>error...</>; }
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 = props.match.params.id;
}
return performerInput;
}
async function onSave() {
async function onSave(performer : Partial<GQL.PerformerCreateInput> | Partial<GQL.PerformerUpdateInput>) {
setIsLoading(true);
try {
if (!isNew) {
const result = await updatePerformer();
const result = await updatePerformer({variables: performer as GQL.PerformerUpdateInput});
setPerformer(result.data.performerUpdate);
} else {
const result = await createPerformer();
const result = await createPerformer({variables: performer as GQL.PerformerCreateInput});
setPerformer(result.data.performerCreate);
props.history.push(`/performers/${result.data.performerCreate.id}`);
}
@ -172,7 +75,7 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
async function onDelete() {
setIsLoading(true);
try {
const result = await deletePerformer();
await deletePerformer({variables: {id: props.match.params.id}});
} catch (e) {
ErrorUtils.handle(e);
}
@ -182,214 +85,164 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
props.history.push(`/performers`);
}
async function onAutoTag() {
if (!performer || !performer.id) {
return;
function renderTabs() {
function renderEditPanel() {
return (
<PerformerDetailsPanel
performer={performer}
isEditing={true}
isNew={isNew}
onDelete={onDelete}
onSave={onSave}
onImageChange={onImageChange}
/>
);
}
try {
await StashService.queryMetadataAutoTag({ performers: [performer.id]});
ToastUtils.success("Started auto tagging");
} catch (e) {
ErrorUtils.handle(e);
// render tabs if not new
if (!isNew) {
return (
<>
<Tabs
renderActiveTabPanelOnly={true}
large={true}
>
<Tab id="performer-details-panel" title="Details" panel={<PerformerDetailsPanel performer={performer} isEditing={false}/>} />
<Tab id="performer-scenes-panel" title="Scenes" panel={<PerformerScenesPanel performer={performer} base={props} />} />
<Tab id="performer-edit-panel" title="Edit" panel={renderEditPanel()} />
<Tab id="performer-operations-panel" title="Operations" panel={<PerformerOperationsPanel performer={performer} />} />
</Tabs>
</>
);
} else {
return renderEditPanel();
}
}
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function onDisplayFreeOnesDialog(scraper: GQL.ListPerformerScrapersListPerformerScrapers) {
setIsDisplayingScraperDialog(scraper);
}
function getQueryScraperPerformerInput() {
if (!scrapePerformerDetails) {
return {};
}
let ret = _.clone(scrapePerformerDetails);
delete ret.__typename;
return ret as GQL.ScrapedPerformerInput;
}
async function onScrapePerformer() {
setIsDisplayingScraperDialog(undefined);
setIsLoading(true);
try {
if (!scrapePerformerDetails || !isDisplayingScraperDialog) { return; }
const result = await StashService.queryScrapePerformer(isDisplayingScraperDialog.id, getQueryScraperPerformerInput());
if (!result.data || !result.data.scrapePerformer) { return; }
updatePerformerEditState(result.data.scrapePerformer);
} catch (e) {
ErrorUtils.handle(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) {
ErrorUtils.handle(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 (
<Dialog
isOpen={!!isDisplayingScraperDialog}
onClose={() => setIsDisplayingScraperDialog(undefined)}
title="Scrape"
>
<div className="dialog-content">
<ScrapePerformerSuggest
placeholder="Performer name"
style={{width: "100%"}}
scraperId={isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""}
onSelectPerformer={(query) => setScrapePerformerDetails(query)}
/>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={() => onScrapePerformer()}>Scrape</Button>
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>
</div>
</Dialog>
);
}
function urlScrapable(url: string) : boolean {
return !!url && !!Scrapers.data && Scrapers.data.listPerformerScrapers && Scrapers.data.listPerformerScrapers.some((s) => {
return !!s.performer && !!s.performer.urls && s.performer.urls.some((u) => { return url.includes(u); });
});
}
function maybeRenderScrapeButton() {
if (!url || !isEditing || !urlScrapable(url)) {
return undefined;
</>
);
}
return (
<Button
minimal={true}
icon="import"
id="scrape-url-button"
onClick={() => onScrapePerformerURL()}/>
)
}
function renderURLField() {
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) {
if (performer.url) {
if (!icon) {
icon = "link";
}
return (
<>
<AnchorButton
icon={icon}
href={performer.url}
minimal={true}
/>
</>
)
}
}
return (
<tr>
<td id="url-field">
URL
{maybeRenderScrapeButton()}
</td>
<td>
{EditableTextUtils.renderInputGroup({
value: url, isEditing, onChange: setUrl, placeholder: "URL"
})}
</td>
</tr>
<>
<span className="name-icons">
<Button
icon="heart"
className={performer.favorite ? "favorite" : "not-favorite"}
onClick={() => setFavorite(!performer.favorite)}
minimal={true}
/>
{maybeRenderURL(performer.url)}
{/* TODO - render instagram and twitter links with icons */}
</span>
</>
);
}
return (
<>
{renderScraperDialog()}
function renderNewView() {
return (
<div className="columns is-multiline no-spacing">
<div className="column is-half details-image-container">
<img className="performer" src={imagePreview} />
</div>
<div className="column is-half details-detail-container">
<DetailsEditNavbar
performer={performer}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={() => { setIsEditing(!isEditing); updatePerformerEditState(performer); }}
onSave={onSave}
onDelete={onDelete}
onImageChange={onImageChange}
scrapers={queryableScrapers}
onDisplayScraperDialog={onDisplayFreeOnesDialog}
onAutoTag={onAutoTag}
/>
<h1 className="bp3-heading">
<EditableText
disabled={!isEditing}
value={name}
placeholder="Name"
onChange={(value) => setName(value)}
/>
</h1>
<h6 className="bp3-heading">
<FormGroup className="aliases-field" inline={true} label="Aliases:">
{EditableTextUtils.renderInputGroup({
value: aliases, isEditing: isEditing, placeholder: "Aliases", onChange: setAliases
})}
</FormGroup>
</h6>
<div>
<span style={{fontWeight: 300}}>Favorite:</span>
<Button
icon="heart"
disabled={!isEditing}
className={favorite ? "favorite" : undefined}
onClick={() => setFavorite(!favorite)}
minimal={true}
/>
</div>
<HTMLTable 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>
</HTMLTable>
{renderTabs()}
</div>
</div>
);
}
const photos = [{src: imagePreview || "", caption: "Image"}];
function openLightbox() {
setLightboxIsOpen(true);
}
function closeLightbox() {
setLightboxIsOpen(false);
}
if (isNew) {
return renderNewView();
}
return (
<>
<div id="performer-page">
<div className="details-image-container">
<img className="performer" src={imagePreview} onClick={openLightbox} />
</div>
<div className="performer-head">
<h1 className="bp3-heading">
{performer.name}
{renderIcons()}
</h1>
{maybeRenderAliases()}
{maybeRenderAge()}
</div>
<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}
/>
</>
);
};

View File

@ -0,0 +1,404 @@
import {
Button,
Classes,
Dialog,
EditableText,
HTMLTable,
Spinner,
FormGroup,
Menu,
MenuItem,
Popover,
Alert,
FileInput,
} from "@blueprintjs/core";
import _ from "lodash";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { ErrorUtils } from "../../../utils/errors";
import { TableUtils } from "../../../utils/table";
import { ScrapePerformerSuggest } from "../../select/ScrapePerformerSuggest";
import { ToastUtils } from "../../../utils/toasts";
import { EditableTextUtils } from "../../../utils/editabletext";
import { ImageUtils } from "../../../utils/image";
interface IPerformerDetailsProps {
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: FunctionComponent<IPerformerDetailsProps> = (props: IPerformerDetailsProps) => {
// Editing state
const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<GQL.ListPerformerScrapersListPerformerScrapers | undefined>(undefined);
const [scrapePerformerDetails, setScrapePerformerDetails] = useState<GQL.ScrapePerformerListScrapePerformerList | undefined>(undefined);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing performer state
const [image, setImage] = useState<string | undefined>(undefined);
const [name, setName] = useState<string | undefined>(undefined);
const [aliases, setAliases] = useState<string | undefined>(undefined);
const [favorite, setFavorite] = useState<boolean | undefined>(undefined);
const [birthdate, setBirthdate] = useState<string | undefined>(undefined);
const [ethnicity, setEthnicity] = useState<string | undefined>(undefined);
const [country, setCountry] = useState<string | undefined>(undefined);
const [eyeColor, setEyeColor] = useState<string | undefined>(undefined);
const [height, setHeight] = useState<string | undefined>(undefined);
const [measurements, setMeasurements] = useState<string | undefined>(undefined);
const [fakeTits, setFakeTits] = useState<string | undefined>(undefined);
const [careerLength, setCareerLength] = useState<string | undefined>(undefined);
const [tattoos, setTattoos] = useState<string | undefined>(undefined);
const [piercings, setPiercings] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined);
const [twitter, setTwitter] = useState<string | undefined>(undefined);
const [instagram, setInstagram] = useState<string | undefined>(undefined);
// Network state
const [isLoading, setIsLoading] = useState(false);
const Scrapers = StashService.useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListPerformerScrapersListPerformerScrapers[]>([]);
function updatePerformerEditState(state: Partial<GQL.PerformerDataFragment | GQL.ScrapeFreeonesScrapeFreeones>) {
if ((state as GQL.PerformerDataFragment).favorite !== undefined) {
setFavorite((state as GQL.PerformerDataFragment).favorite);
}
setName(state.name);
setAliases(state.aliases);
setBirthdate(state.birthdate);
setEthnicity(state.ethnicity);
setCountry(state.country);
setEyeColor(state.eye_color);
setHeight(state.height);
setMeasurements(state.measurements);
setFakeTits(state.fake_tits);
setCareerLength(state.career_length);
setTattoos(state.tattoos);
setPiercings(state.piercings);
setUrl(state.url);
setTwitter(state.twitter);
setInstagram(state.instagram);
}
useEffect(() => {
setImage(undefined);
updatePerformerEditState(props.performer);
}, [props.performer]);
function onImageLoad(this: FileReader) {
setImage(this.result as string);
if (props.onImageChange) {
props.onImageChange(this.result as string);
}
}
if (props.isEditing) {
ImageUtils.addPasteImageHook(onImageLoad);
}
useEffect(() => {
var newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = [];
if (!!Scrapers.data && Scrapers.data.listPerformerScrapers) {
newQueryableScrapers = Scrapers.data.listPerformerScrapers.filter((s) => {
return s.performer && s.performer.supported_scrapes.includes(GQL.ScrapeType.Name);
});
}
setQueryableScrapers(newQueryableScrapers);
}, [Scrapers.data]);
if (isLoading) {
return <Spinner size={Spinner.SIZE_LARGE} />;
}
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 (!props.isNew) {
(performerInput as GQL.PerformerUpdateInput).id = props.performer.id!;
}
return performerInput;
}
function onSave() {
if (props.onSave) {
props.onSave(getPerformerInput());
}
}
function onDelete() {
if (props.onDelete) {
props.onDelete();
}
}
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function onDisplayFreeOnesDialog(scraper: GQL.ListPerformerScrapersListPerformerScrapers) {
setIsDisplayingScraperDialog(scraper);
}
function getQueryScraperPerformerInput() {
if (!scrapePerformerDetails) {
return {};
}
let ret = _.clone(scrapePerformerDetails);
delete ret.__typename;
return ret as GQL.ScrapedPerformerInput;
}
async function onScrapePerformer() {
setIsDisplayingScraperDialog(undefined);
setIsLoading(true);
try {
if (!scrapePerformerDetails || !isDisplayingScraperDialog) { return; }
const result = await StashService.queryScrapePerformer(isDisplayingScraperDialog.id, getQueryScraperPerformerInput());
if (!result.data || !result.data.scrapePerformer) { return; }
updatePerformerEditState(result.data.scrapePerformer);
} catch (e) {
ErrorUtils.handle(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) {
ErrorUtils.handle(e);
} finally {
setIsLoading(false);
}
}
function renderEthnicity() {
return TableUtils.renderHtmlSelect({
title: "Ethnicity",
value: ethnicity,
isEditing: !!props.isEditing,
onChange: (value: string) => setEthnicity(value),
selectOptions: ["white", "black", "asian", "hispanic"],
});
}
function renderScraperMenu() {
function renderScraperMenuItem(scraper : GQL.ListPerformerScrapersListPerformerScrapers) {
return (
<MenuItem
text={scraper.name}
onClick={() => { onDisplayFreeOnesDialog(scraper); }}
/>
);
}
if (!props.performer) { return; }
if (!props.isEditing) { return; }
const scraperMenu = (
<Menu>
{queryableScrapers ? queryableScrapers.map((s) => renderScraperMenuItem(s)) : undefined}
</Menu>
);
return (
<Popover content={scraperMenu} position="bottom">
<Button text="Scrape with..."/>
</Popover>
);
}
function renderScraperDialog() {
return (
<Dialog
isOpen={!!isDisplayingScraperDialog}
onClose={() => setIsDisplayingScraperDialog(undefined)}
title="Scrape"
>
<div className="dialog-content">
<ScrapePerformerSuggest
placeholder="Performer name"
style={{width: "100%"}}
scraperId={isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""}
onSelectPerformer={(query) => setScrapePerformerDetails(query)}
/>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={() => onScrapePerformer()}>Scrape</Button>
</div>
</div>
</Dialog>
);
}
function urlScrapable(url: string) : boolean {
return !!url && !!Scrapers.data && Scrapers.data.listPerformerScrapers && Scrapers.data.listPerformerScrapers.some((s) => {
return !!s.performer && !!s.performer.urls && s.performer.urls.some((u) => { return url.includes(u); });
});
}
function maybeRenderScrapeButton() {
if (!url || !props.isEditing || !urlScrapable(url)) {
return undefined;
}
return (
<Button
minimal={true}
icon="import"
id="scrape-url-button"
onClick={() => onScrapePerformerURL()}/>
)
}
function renderURLField() {
return (
<tr>
<td id="url-field">
URL
{maybeRenderScrapeButton()}
</td>
<td>
{EditableTextUtils.renderInputGroup({
value: url, asURL: true, isEditing: !!props.isEditing, onChange: setUrl, placeholder: "URL"
})}
</td>
</tr>
);
}
function renderImageInput() {
if (!props.isEditing) { return; }
return (
<>
<tr>
<td>Image</td>
<td><FileInput text="Choose image..." onInputChange={onImageChange} inputProps={{accept: ".jpg,.jpeg"}} /></td>
</tr>
</>
)
}
function maybeRenderButtons() {
if (props.isEditing) {
return (
<>
<Button className="edit-button" text="Save" intent="primary" onClick={() => onSave()}/>
{!props.isNew ? <Button className="edit-button" text="Delete" intent="danger" onClick={() => setIsDeleteAlertOpen(true)}/> : undefined}
{renderScraperMenu()}
</>
);
}
}
function renderDeleteAlert() {
return (
<Alert
cancelButtonText="Cancel"
confirmButtonText="Delete"
icon="trash"
intent="danger"
isOpen={isDeleteAlertOpen}
onCancel={() => setIsDeleteAlertOpen(false)}
onConfirm={() => onDelete()}
>
<p>
Are you sure you want to delete {name}?
</p>
</Alert>
);
}
function maybeRenderName() {
if (props.isEditing) {
return TableUtils.renderInputGroup(
{title: "Name", value: name, isEditing: !!props.isEditing, placeholder: "Name", onChange: setName});
}
}
function maybeRenderAliases() {
if (props.isEditing) {
return TableUtils.renderInputGroup(
{title: "Aliases", value: aliases, isEditing: !!props.isEditing, placeholder: "Aliases", onChange: setAliases});
}
}
const twitterPrefix = "https://twitter.com/";
const instagramPrefix = "https://www.instagram.com/";
return (
<>
{renderDeleteAlert()}
{renderScraperDialog()}
<HTMLTable id="performer-details" style={{width: "100%"}}>
<tbody>
{maybeRenderName()}
{maybeRenderAliases()}
{TableUtils.renderInputGroup(
{title: "Birthdate (YYYY-MM-DD)", value: birthdate, isEditing: !!props.isEditing, onChange: setBirthdate})}
{renderEthnicity()}
{TableUtils.renderInputGroup(
{title: "Eye Color", value: eyeColor, isEditing: !!props.isEditing, onChange: setEyeColor})}
{TableUtils.renderInputGroup(
{title: "Country", value: country, isEditing: !!props.isEditing, onChange: setCountry})}
{TableUtils.renderInputGroup(
{title: "Height (CM)", value: height, isEditing: !!props.isEditing, onChange: setHeight})}
{TableUtils.renderInputGroup(
{title: "Measurements", value: measurements, isEditing: !!props.isEditing, onChange: setMeasurements})}
{TableUtils.renderInputGroup(
{title: "Fake Tits", value: fakeTits, isEditing: !!props.isEditing, onChange: setFakeTits})}
{TableUtils.renderInputGroup(
{title: "Career Length", value: careerLength, isEditing: !!props.isEditing, onChange: setCareerLength})}
{TableUtils.renderInputGroup(
{title: "Tattoos", value: tattoos, isEditing: !!props.isEditing, onChange: setTattoos})}
{TableUtils.renderInputGroup(
{title: "Piercings", value: piercings, isEditing: !!props.isEditing, onChange: setPiercings})}
{renderURLField()}
{TableUtils.renderInputGroup(
{title: "Twitter", value: twitter, asURL: true, urlPrefix: twitterPrefix, isEditing: !!props.isEditing, onChange: setTwitter})}
{TableUtils.renderInputGroup(
{title: "Instagram", value: instagram, asURL: true, urlPrefix: instagramPrefix, isEditing: !!props.isEditing, onChange: setInstagram})}
{renderImageInput()}
</tbody>
</HTMLTable>
{maybeRenderButtons()}
</>
);
};

View File

@ -0,0 +1,35 @@
import {
Button,
} from "@blueprintjs/core";
import _ from "lodash";
import React, { FunctionComponent } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { ErrorUtils } from "../../../utils/errors";
import { ToastUtils } from "../../../utils/toasts";
interface IPerformerOperationsProps {
performer: Partial<GQL.PerformerDataFragment>
}
export const PerformerOperationsPanel: FunctionComponent<IPerformerOperationsProps> = (props: IPerformerOperationsProps) => {
async function onAutoTag() {
if (!props.performer || !props.performer.id) {
return;
}
try {
await StashService.queryMetadataAutoTag({ performers: [props.performer.id]});
ToastUtils.success("Started auto tagging");
} catch (e) {
ErrorUtils.handle(e);
}
}
return (
<>
<Button text="Auto Tag" onClick={onAutoTag} />
</>
);
};

View File

@ -0,0 +1,51 @@
import _ from "lodash";
import React, { FunctionComponent } from "react";
import * as GQL from "../../../core/generated-graphql";
import { IBaseProps } from "../../../models";
import { SceneList } from "../../scenes/SceneList";
import { PerformersCriterion } from "../../../models/list-filter/criteria/performers";
import { ListFilterModel } from "../../../models/list-filter/filter";
interface IPerformerDetailsProps {
performer: Partial<GQL.PerformerDataFragment>
base: IBaseProps
}
export const PerformerScenesPanel: FunctionComponent<IPerformerDetailsProps> = (props: IPerformerDetailsProps) => {
function filterHook(filter: ListFilterModel) {
let performerValue = {id: props.performer.id!, label: props.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 === props.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
base={props.base}
subComponent={true}
filterHook={filterHook}
/>
);
}

View File

@ -12,7 +12,11 @@ import { SceneListTable } from "./SceneListTable";
import { SceneSelectedOptions } from "./SceneSelectedOptions";
import { StashService } from "../../core/StashService";
interface ISceneListProps extends IBaseProps {}
interface ISceneListProps {
base : IBaseProps
subComponent?: boolean
filterHook?: (filter: ListFilterModel) => ListFilterModel;
}
export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListProps) => {
const otherOperations = [
@ -24,7 +28,9 @@ export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListP
const listData = ListHook.useList({
filterMode: FilterMode.Scenes,
props,
props: props.base,
subComponent: props.subComponent,
filterHook: props.filterHook,
zoomable: true,
otherOperations: otherOperations,
renderContent,
@ -44,7 +50,7 @@ export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListP
if (singleResult && singleResult.data && singleResult.data.findScenes && singleResult.data.findScenes.scenes.length === 1) {
let id = singleResult!.data!.findScenes!.scenes[0].id;
// navigate to the scene player page
props.history.push("/scenes/" + id + "?autoplay=true");
props.base.history.push("/scenes/" + id + "?autoplay=true");
}
}
}

View File

@ -0,0 +1,10 @@
import _ from "lodash";
import React, { FunctionComponent } from "react";
import { IBaseProps } from "../../models/base-props";
import { SceneList } from "./SceneList";
interface ISceneListPageProps extends IBaseProps {}
export const SceneListPage: FunctionComponent<ISceneListPageProps> = (props: ISceneListPageProps) => {
return <SceneList base={props}/>;
};

View File

@ -1,12 +1,12 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { Scene } from "./SceneDetails/Scene";
import { SceneList } from "./SceneList";
import { SceneMarkerList } from "./SceneMarkerList";
import { SceneListPage } from "./SceneListPage";
const Scenes = () => (
<Switch>
<Route exact={true} path="/scenes" component={SceneList} />
<Route exact={true} path="/scenes" component={SceneListPage} />
<Route exact={true} path="/scenes/markers" component={SceneMarkerList} />
<Route path="/scenes/:id" component={Scene} />
</Switch>

View File

@ -260,21 +260,18 @@ export class StashService {
"allPerformers"
];
public static usePerformerCreate(input: GQL.PerformerCreateInput) {
public static usePerformerCreate() {
return GQL.usePerformerCreate({
variables: input,
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
});
}
public static usePerformerUpdate(input: GQL.PerformerUpdateInput) {
public static usePerformerUpdate() {
return GQL.usePerformerUpdate({
variables: input,
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
});
}
public static usePerformerDestroy(input: GQL.PerformerDestroyInput) {
public static usePerformerDestroy() {
return GQL.usePerformerDestroy({
variables: input,
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
});
}

View File

@ -25,6 +25,8 @@ interface IListHookOperation {
export interface IListHookOptions {
filterMode: FilterMode;
subComponent?: boolean;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
props: IBaseProps;
zoomable?: boolean;
otherOperations?: IListHookOperation[];
@ -41,15 +43,28 @@ export class ListHook {
const [zoomIndex, setZoomIndex] = useState<number>(1);
// Update the filter when the query parameters change
useEffect(() => {
const queryParams = queryString.parse(options.props.location.search);
const newFilter = _.cloneDeep(filter);
newFilter.configureFromQueryParameters(queryParams);
setFilter(newFilter);
// don't use query parameters for sub-components
if (!options.subComponent) {
useEffect(() => {
const queryParams = queryString.parse(options.props!.location.search);
const newFilter = _.cloneDeep(filter);
newFilter.configureFromQueryParameters(queryParams);
setFilter(newFilter);
// TODO: Need this side effect to update the query params properly
filter.configureFromQueryParameters(queryParams);
}, [options.props.location.search]);
// TODO: Need this side effect to update the query params properly
filter.configureFromQueryParameters(queryParams);
}, [options.props.location.search]);
}
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);
}
let result: QueryHookResult<any, any>;
@ -97,7 +112,7 @@ export class ListHook {
}
}
result = getData(filter);
result = getData(getFilter());
useEffect(() => {
setTotalCount(getCount());
@ -108,11 +123,14 @@ export class ListHook {
}, [result.data])
// Update the query parameters when the data changes
useEffect(() => {
const location = Object.assign({}, options.props.history.location);
location.search = filter.makeQueryParameters();
options.props.history.replace(location);
}, [result.data, filter.displayMode]);
// don't use query parameters for sub-components
if (!options.subComponent) {
useEffect(() => {
const location = Object.assign({}, options.props.history.location);
location.search = filter.makeQueryParameters();
options.props.history.replace(location);
}, [result.data, filter.displayMode]);
}
// Update the total count
useEffect(() => {

View File

@ -446,6 +446,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;
}
}
#performer-details {
& td {
vertical-align: middle;

View File

@ -26,8 +26,23 @@ export class EditableTextUtils {
value: string | undefined,
isEditing: boolean,
placeholder?: string,
asLabel?: boolean,
asURL?: boolean,
urlPrefix?: string,
onChange: ((value: string) => void),
}) {
function maybeRenderURL() {
if (options.asURL) {
let url = options.value;
if (options.urlPrefix) {
url = options.urlPrefix + url;
}
return <a href={url}>{options.value}</a>
} else {
return options.value;
}
}
let element: JSX.Element;
if (options.isEditing) {
element = (
@ -38,7 +53,11 @@ export class EditableTextUtils {
/>
);
} else {
element = <Label>{options.value}</Label>;
if (options.asLabel) {
element = <Label>{maybeRenderURL()}</Label>;
} else {
element = <span>{maybeRenderURL()}</span>;
}
}
return element;
}

View File

@ -54,6 +54,8 @@ export class TableUtils {
placeholder?: string,
value: string | undefined,
isEditing: boolean,
asURL?: boolean,
urlPrefix?: string,
onChange: ((value: string) => void),
}) {
let optionsCopy = _.clone(options);