mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
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:
@@ -1,11 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Classes,
|
|
||||||
Dialog,
|
|
||||||
EditableText,
|
|
||||||
HTMLTable,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
FormGroup,
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Button,
|
||||||
|
AnchorButton,
|
||||||
|
IconName,
|
||||||
} from "@blueprintjs/core";
|
} from "@blueprintjs/core";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||||
@@ -13,77 +12,29 @@ import * as GQL from "../../../core/generated-graphql";
|
|||||||
import { StashService } from "../../../core/StashService";
|
import { StashService } from "../../../core/StashService";
|
||||||
import { IBaseProps } from "../../../models";
|
import { IBaseProps } from "../../../models";
|
||||||
import { ErrorUtils } from "../../../utils/errors";
|
import { ErrorUtils } from "../../../utils/errors";
|
||||||
import { TableUtils } from "../../../utils/table";
|
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
|
||||||
import { ScrapePerformerSuggest } from "../../select/ScrapePerformerSuggest";
|
import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
|
||||||
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
|
import { PerformerScenesPanel } from "./PerformerScenesPanel";
|
||||||
import { ToastUtils } from "../../../utils/toasts";
|
import { TextUtils } from "../../../utils/text";
|
||||||
import { EditableTextUtils } from "../../../utils/editabletext";
|
import Lightbox from "react-images";
|
||||||
import { ImageUtils } from "../../../utils/image";
|
|
||||||
|
|
||||||
interface IPerformerProps extends IBaseProps {}
|
interface IPerformerProps extends IBaseProps {}
|
||||||
|
|
||||||
export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerProps) => {
|
export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerProps) => {
|
||||||
const isNew = props.match.params.id === "new";
|
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
|
// Performer state
|
||||||
const [performer, setPerformer] = useState<Partial<GQL.PerformerDataFragment>>({});
|
const [performer, setPerformer] = useState<Partial<GQL.PerformerDataFragment>>({});
|
||||||
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
|
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
|
||||||
|
const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
// Network state
|
// Network state
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 { data, error, loading } = StashService.useFindPerformer(props.match.params.id);
|
||||||
const updatePerformer = StashService.usePerformerUpdate(getPerformerInput() as GQL.PerformerUpdateInput);
|
const updatePerformer = StashService.usePerformerUpdate();
|
||||||
const createPerformer = StashService.usePerformerCreate(getPerformerInput() as GQL.PerformerCreateInput);
|
const createPerformer = StashService.usePerformerCreate();
|
||||||
const deletePerformer = StashService.usePerformerDestroy(getPerformerInput() as GQL.PerformerDestroyInput);
|
const deletePerformer = StashService.usePerformerDestroy();
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
setIsLoading(loading);
|
setIsLoading(loading);
|
||||||
@@ -93,73 +44,25 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImagePreview(performer.image_path);
|
setImagePreview(performer.image_path);
|
||||||
setImage(undefined);
|
|
||||||
updatePerformerEditState(performer);
|
|
||||||
if (!isNew) {
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
|
||||||
}, [performer]);
|
}, [performer]);
|
||||||
|
|
||||||
function onImageLoad(this: FileReader) {
|
function onImageChange(image: string) {
|
||||||
setImagePreview(this.result as string);
|
setImagePreview(image);
|
||||||
setImage(this.result as string);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageUtils.addPasteImageHook(onImageLoad);
|
if ((!isNew && (!data || !data.findPerformer)) || isLoading) {
|
||||||
|
|
||||||
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) {
|
|
||||||
return <Spinner size={Spinner.SIZE_LARGE} />;
|
return <Spinner size={Spinner.SIZE_LARGE} />;
|
||||||
}
|
}
|
||||||
if (!!error) { return <>error...</>; }
|
if (!!error) { return <>error...</>; }
|
||||||
|
|
||||||
function getPerformerInput() {
|
async function onSave(performer : Partial<GQL.PerformerCreateInput> | Partial<GQL.PerformerUpdateInput>) {
|
||||||
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() {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
const result = await updatePerformer();
|
const result = await updatePerformer({variables: performer as GQL.PerformerUpdateInput});
|
||||||
setPerformer(result.data.performerUpdate);
|
setPerformer(result.data.performerUpdate);
|
||||||
} else {
|
} else {
|
||||||
const result = await createPerformer();
|
const result = await createPerformer({variables: performer as GQL.PerformerCreateInput});
|
||||||
setPerformer(result.data.performerCreate);
|
setPerformer(result.data.performerCreate);
|
||||||
props.history.push(`/performers/${result.data.performerCreate.id}`);
|
props.history.push(`/performers/${result.data.performerCreate.id}`);
|
||||||
}
|
}
|
||||||
@@ -172,7 +75,7 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
|
|||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await deletePerformer();
|
await deletePerformer({variables: {id: props.match.params.id}});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorUtils.handle(e);
|
ErrorUtils.handle(e);
|
||||||
}
|
}
|
||||||
@@ -182,214 +85,164 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
|
|||||||
props.history.push(`/performers`);
|
props.history.push(`/performers`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAutoTag() {
|
function renderTabs() {
|
||||||
if (!performer || !performer.id) {
|
function renderEditPanel() {
|
||||||
return;
|
return (
|
||||||
|
<PerformerDetailsPanel
|
||||||
|
performer={performer}
|
||||||
|
isEditing={true}
|
||||||
|
isNew={isNew}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onSave={onSave}
|
||||||
|
onImageChange={onImageChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await StashService.queryMetadataAutoTag({ performers: [performer.id]});
|
// render tabs if not new
|
||||||
ToastUtils.success("Started auto tagging");
|
if (!isNew) {
|
||||||
} catch (e) {
|
return (
|
||||||
ErrorUtils.handle(e);
|
<>
|
||||||
|
<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>) {
|
function maybeRenderAge() {
|
||||||
ImageUtils.onImageChange(event, onImageLoad);
|
if (performer && performer.birthdate) {
|
||||||
}
|
// calculate the age from birthdate. In future, this should probably be
|
||||||
|
// provided by the server
|
||||||
function onDisplayFreeOnesDialog(scraper: GQL.ListPerformerScrapersListPerformerScrapers) {
|
return (
|
||||||
setIsDisplayingScraperDialog(scraper);
|
<>
|
||||||
}
|
<div>
|
||||||
|
<span className="age">{TextUtils.age(performer.birthdate)}</span>
|
||||||
function getQueryScraperPerformerInput() {
|
<span className="age-tail"> years old</span>
|
||||||
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>
|
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<tr>
|
<>
|
||||||
<td id="url-field">
|
<span className="name-icons">
|
||||||
URL
|
<Button
|
||||||
{maybeRenderScrapeButton()}
|
icon="heart"
|
||||||
</td>
|
className={performer.favorite ? "favorite" : "not-favorite"}
|
||||||
<td>
|
onClick={() => setFavorite(!performer.favorite)}
|
||||||
{EditableTextUtils.renderInputGroup({
|
minimal={true}
|
||||||
value: url, isEditing, onChange: setUrl, placeholder: "URL"
|
/>
|
||||||
})}
|
{maybeRenderURL(performer.url)}
|
||||||
</td>
|
{/* TODO - render instagram and twitter links with icons */}
|
||||||
</tr>
|
</span>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function renderNewView() {
|
||||||
<>
|
return (
|
||||||
{renderScraperDialog()}
|
|
||||||
<div className="columns is-multiline no-spacing">
|
<div className="columns is-multiline no-spacing">
|
||||||
<div className="column is-half details-image-container">
|
<div className="column is-half details-image-container">
|
||||||
<img className="performer" src={imagePreview} />
|
<img className="performer" src={imagePreview} />
|
||||||
</div>
|
</div>
|
||||||
<div className="column is-half details-detail-container">
|
<div className="column is-half details-detail-container">
|
||||||
<DetailsEditNavbar
|
{renderTabs()}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,11 @@ import { SceneListTable } from "./SceneListTable";
|
|||||||
import { SceneSelectedOptions } from "./SceneSelectedOptions";
|
import { SceneSelectedOptions } from "./SceneSelectedOptions";
|
||||||
import { StashService } from "../../core/StashService";
|
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) => {
|
export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListProps) => {
|
||||||
const otherOperations = [
|
const otherOperations = [
|
||||||
@@ -24,7 +28,9 @@ export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListP
|
|||||||
|
|
||||||
const listData = ListHook.useList({
|
const listData = ListHook.useList({
|
||||||
filterMode: FilterMode.Scenes,
|
filterMode: FilterMode.Scenes,
|
||||||
props,
|
props: props.base,
|
||||||
|
subComponent: props.subComponent,
|
||||||
|
filterHook: props.filterHook,
|
||||||
zoomable: true,
|
zoomable: true,
|
||||||
otherOperations: otherOperations,
|
otherOperations: otherOperations,
|
||||||
renderContent,
|
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) {
|
if (singleResult && singleResult.data && singleResult.data.findScenes && singleResult.data.findScenes.scenes.length === 1) {
|
||||||
let id = singleResult!.data!.findScenes!.scenes[0].id;
|
let id = singleResult!.data!.findScenes!.scenes[0].id;
|
||||||
// navigate to the scene player page
|
// navigate to the scene player page
|
||||||
props.history.push("/scenes/" + id + "?autoplay=true");
|
props.base.history.push("/scenes/" + id + "?autoplay=true");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
ui/v2/src/components/scenes/SceneListPage.tsx
Normal file
10
ui/v2/src/components/scenes/SceneListPage.tsx
Normal 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}/>;
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch } from "react-router-dom";
|
||||||
import { Scene } from "./SceneDetails/Scene";
|
import { Scene } from "./SceneDetails/Scene";
|
||||||
import { SceneList } from "./SceneList";
|
|
||||||
import { SceneMarkerList } from "./SceneMarkerList";
|
import { SceneMarkerList } from "./SceneMarkerList";
|
||||||
|
import { SceneListPage } from "./SceneListPage";
|
||||||
|
|
||||||
const Scenes = () => (
|
const Scenes = () => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact={true} path="/scenes" component={SceneList} />
|
<Route exact={true} path="/scenes" component={SceneListPage} />
|
||||||
<Route exact={true} path="/scenes/markers" component={SceneMarkerList} />
|
<Route exact={true} path="/scenes/markers" component={SceneMarkerList} />
|
||||||
<Route path="/scenes/:id" component={Scene} />
|
<Route path="/scenes/:id" component={Scene} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -260,21 +260,18 @@ export class StashService {
|
|||||||
"allPerformers"
|
"allPerformers"
|
||||||
];
|
];
|
||||||
|
|
||||||
public static usePerformerCreate(input: GQL.PerformerCreateInput) {
|
public static usePerformerCreate() {
|
||||||
return GQL.usePerformerCreate({
|
return GQL.usePerformerCreate({
|
||||||
variables: input,
|
|
||||||
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
|
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
public static usePerformerUpdate(input: GQL.PerformerUpdateInput) {
|
public static usePerformerUpdate() {
|
||||||
return GQL.usePerformerUpdate({
|
return GQL.usePerformerUpdate({
|
||||||
variables: input,
|
|
||||||
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
|
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
public static usePerformerDestroy(input: GQL.PerformerDestroyInput) {
|
public static usePerformerDestroy() {
|
||||||
return GQL.usePerformerDestroy({
|
return GQL.usePerformerDestroy({
|
||||||
variables: input,
|
|
||||||
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
|
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface IListHookOperation {
|
|||||||
|
|
||||||
export interface IListHookOptions {
|
export interface IListHookOptions {
|
||||||
filterMode: FilterMode;
|
filterMode: FilterMode;
|
||||||
|
subComponent?: boolean;
|
||||||
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
props: IBaseProps;
|
props: IBaseProps;
|
||||||
zoomable?: boolean;
|
zoomable?: boolean;
|
||||||
otherOperations?: IListHookOperation[];
|
otherOperations?: IListHookOperation[];
|
||||||
@@ -41,15 +43,28 @@ export class ListHook {
|
|||||||
const [zoomIndex, setZoomIndex] = useState<number>(1);
|
const [zoomIndex, setZoomIndex] = useState<number>(1);
|
||||||
|
|
||||||
// Update the filter when the query parameters change
|
// Update the filter when the query parameters change
|
||||||
useEffect(() => {
|
// don't use query parameters for sub-components
|
||||||
const queryParams = queryString.parse(options.props.location.search);
|
if (!options.subComponent) {
|
||||||
const newFilter = _.cloneDeep(filter);
|
useEffect(() => {
|
||||||
newFilter.configureFromQueryParameters(queryParams);
|
const queryParams = queryString.parse(options.props!.location.search);
|
||||||
setFilter(newFilter);
|
const newFilter = _.cloneDeep(filter);
|
||||||
|
newFilter.configureFromQueryParameters(queryParams);
|
||||||
|
setFilter(newFilter);
|
||||||
|
|
||||||
// TODO: Need this side effect to update the query params properly
|
// TODO: Need this side effect to update the query params properly
|
||||||
filter.configureFromQueryParameters(queryParams);
|
filter.configureFromQueryParameters(queryParams);
|
||||||
}, [options.props.location.search]);
|
}, [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>;
|
let result: QueryHookResult<any, any>;
|
||||||
|
|
||||||
@@ -97,7 +112,7 @@ export class ListHook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = getData(filter);
|
result = getData(getFilter());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTotalCount(getCount());
|
setTotalCount(getCount());
|
||||||
@@ -108,11 +123,14 @@ export class ListHook {
|
|||||||
}, [result.data])
|
}, [result.data])
|
||||||
|
|
||||||
// Update the query parameters when the data changes
|
// Update the query parameters when the data changes
|
||||||
useEffect(() => {
|
// don't use query parameters for sub-components
|
||||||
const location = Object.assign({}, options.props.history.location);
|
if (!options.subComponent) {
|
||||||
location.search = filter.makeQueryParameters();
|
useEffect(() => {
|
||||||
options.props.history.replace(location);
|
const location = Object.assign({}, options.props.history.location);
|
||||||
}, [result.data, filter.displayMode]);
|
location.search = filter.makeQueryParameters();
|
||||||
|
options.props.history.replace(location);
|
||||||
|
}, [result.data, filter.displayMode]);
|
||||||
|
}
|
||||||
|
|
||||||
// Update the total count
|
// Update the total count
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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 {
|
#performer-details {
|
||||||
& td {
|
& td {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|||||||
@@ -26,8 +26,23 @@ export class EditableTextUtils {
|
|||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
isEditing: boolean,
|
isEditing: boolean,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
|
asLabel?: boolean,
|
||||||
|
asURL?: boolean,
|
||||||
|
urlPrefix?: string,
|
||||||
onChange: ((value: string) => void),
|
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;
|
let element: JSX.Element;
|
||||||
if (options.isEditing) {
|
if (options.isEditing) {
|
||||||
element = (
|
element = (
|
||||||
@@ -38,7 +53,11 @@ export class EditableTextUtils {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
element = <Label>{options.value}</Label>;
|
if (options.asLabel) {
|
||||||
|
element = <Label>{maybeRenderURL()}</Label>;
|
||||||
|
} else {
|
||||||
|
element = <span>{maybeRenderURL()}</span>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export class TableUtils {
|
|||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
isEditing: boolean,
|
isEditing: boolean,
|
||||||
|
asURL?: boolean,
|
||||||
|
urlPrefix?: string,
|
||||||
onChange: ((value: string) => void),
|
onChange: ((value: string) => void),
|
||||||
}) {
|
}) {
|
||||||
let optionsCopy = _.clone(options);
|
let optionsCopy = _.clone(options);
|
||||||
|
|||||||
Reference in New Issue
Block a user