Touch up Performer Page (#2200)

* Moves "Edit" and "Autotag" out of performer tabs
* Smoothen out fedit submission behavior
This commit is contained in:
kermieisinthehouse 2022-01-04 19:18:57 -08:00 committed by GitHub
parent be5dc7e545
commit baf148625c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 160 additions and 188 deletions

View file

@ -1,4 +1,5 @@
### 🎨 Improvements
* Made Performer page consistent with Studio and Tag pages. ([#2200](https://github.com/stashapp/stash/pull/2200))
* Add gender icons to performers. ([#2179](https://github.com/stashapp/stash/pull/2179))
* Add button to test credentials when adding/editing stash-box endpoints. ([#2173](https://github.com/stashapp/stash/pull/2173))
* Show counts on list tabs in Performer, Studio and Tag pages. ([#2169](https://github.com/stashapp/stash/pull/2169))

View file

@ -470,7 +470,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
</Form>
<DetailsEditNavbar
objectName={movie?.name ?? "movie"}
objectName={movie?.name ?? intl.formatMessage({ id: "movie" })}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={onCancel}

View file

@ -24,7 +24,7 @@ const GenderIcon: React.FC<IIconProps> = ({ gender, className }) => {
: faTransgenderAlt;
return (
<FontAwesomeIcon
title={intl.formatMessage({ id: "gender." + gender })}
title={intl.formatMessage({ id: "gender_types." + gender })}
className={className}
icon={icon}
/>

View file

@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { Button, Tabs, Tab, Badge } from "react-bootstrap";
import { Button, Tabs, Tab, Badge, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { useParams, useHistory } from "react-router-dom";
import { Helmet } from "react-helmet";
@ -10,9 +10,11 @@ import {
useFindPerformer,
usePerformerUpdate,
usePerformerDestroy,
mutateMetadataAutoTag,
} from "src/core/StashService";
import {
CountryFlag,
DetailsEditNavbar,
ErrorMessage,
Icon,
LoadingIndicator,
@ -21,7 +23,6 @@ import { useLightbox, useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel";
import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel";
import { PerformerMoviesPanel } from "./PerformerMoviesPanel";
@ -44,6 +45,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
const [imagePreview, setImagePreview] = useState<string | null>();
const [imageEncoding, setImageEncoding] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState<boolean>(false);
// if undefined then get the existing image
// if null then get the default (no) image
@ -68,9 +70,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
tab === "scenes" ||
tab === "galleries" ||
tab === "images" ||
tab === "movies" ||
tab === "edit" ||
tab === "operations"
tab === "movies"
? tab
: "details";
const setActiveTabKey = (newTab: string | null) => {
@ -84,14 +84,24 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
const onImageEncoding = (isEncoding = false) => setImageEncoding(isEncoding);
async function onAutoTag() {
try {
await mutateMetadataAutoTag({ performers: [performer.id] });
Toast.success({
content: intl.formatMessage({ id: "toast.started_auto_tagging" }),
});
} catch (e) {
Toast.error(e);
}
}
// set up hotkeys
useEffect(() => {
Mousetrap.bind("a", () => setActiveTabKey("details"));
Mousetrap.bind("e", () => setActiveTabKey("edit"));
Mousetrap.bind("e", () => setIsEditing(!isEditing));
Mousetrap.bind("c", () => setActiveTabKey("scenes"));
Mousetrap.bind("g", () => setActiveTabKey("galleries"));
Mousetrap.bind("m", () => setActiveTabKey("movies"));
Mousetrap.bind("o", () => setActiveTabKey("operations"));
Mousetrap.bind("f", () => setFavorite(!performer.favorite));
// numeric keypresses get caught by jwplayer, so blur the element
@ -139,85 +149,108 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
}
const renderTabs = () => (
<Tabs
activeKey={activeTabKey}
onSelect={setActiveTabKey}
id="performer-details"
unmountOnExit
>
<Tab eventKey="details" title={intl.formatMessage({ id: "details" })}>
<PerformerDetailsPanel performer={performer} />
</Tab>
<Tab
eventKey="scenes"
title={
<React.Fragment>
{intl.formatMessage({ id: "scenes" })}
<Badge className="left-spacing" pill variant="secondary">
{intl.formatNumber(performer.scene_count ?? 0)}
</Badge>
</React.Fragment>
}
<React.Fragment>
<Col>
<Row xs={8}>
<DetailsEditNavbar
objectName={
performer?.name ?? intl.formatMessage({ id: "performer" })
}
onToggleEdit={() => {
setIsEditing(!isEditing);
}}
onDelete={onDelete}
onAutoTag={onAutoTag}
isNew={false}
isEditing={false}
onSave={() => {}}
onImageChange={() => {}}
/>
</Row>
</Col>
<Tabs
activeKey={activeTabKey}
onSelect={setActiveTabKey}
id="performer-details"
unmountOnExit
>
<PerformerScenesPanel performer={performer} />
</Tab>
<Tab
eventKey="galleries"
title={
<React.Fragment>
{intl.formatMessage({ id: "galleries" })}
<Badge className="left-spacing" pill variant="secondary">
{intl.formatNumber(performer.gallery_count ?? 0)}
</Badge>
</React.Fragment>
}
>
<PerformerGalleriesPanel performer={performer} />
</Tab>
<Tab
eventKey="images"
title={
<React.Fragment>
{intl.formatMessage({ id: "images" })}
<Badge className="left-spacing" pill variant="secondary">
{intl.formatNumber(performer.image_count ?? 0)}
</Badge>
</React.Fragment>
}
>
<PerformerImagesPanel performer={performer} />
</Tab>
<Tab
eventKey="movies"
title={
<React.Fragment>
{intl.formatMessage({ id: "movies" })}
<Badge className="left-spacing" pill variant="secondary">
{intl.formatNumber(performer.movie_count ?? 0)}
</Badge>
</React.Fragment>
}
>
<PerformerMoviesPanel performer={performer} />
</Tab>
<Tab eventKey="edit" title={intl.formatMessage({ id: "actions.edit" })}>
<Tab eventKey="details" title={intl.formatMessage({ id: "details" })}>
<PerformerDetailsPanel performer={performer} />
</Tab>
<Tab
eventKey="scenes"
title={
<React.Fragment>
{intl.formatMessage({ id: "scenes" })}
<Badge className="left-spacing" pill variant="secondary">
{intl.formatNumber(performer.scene_count ?? 0)}
</Badge>
</React.Fragment>
}
>
<PerformerScenesPanel performer={performer} />
</Tab>
<Tab
eventKey="galleries"
title={
<React.Fragment>
{intl.formatMessage({ id: "galleries" })}
<Badge className="left-spacing" pill variant="secondary">
{intl.formatNumber(performer.gallery_count ?? 0)}
</Badge>
</React.Fragment>
}
>
<PerformerGalleriesPanel performer={performer} />
</Tab>
<Tab
eventKey="images"
title={
<React.Fragment>
{intl.formatMessage({ id: "images" })}
<Badge className="left-spacing" pill variant="secondary">
{intl.formatNumber(performer.image_count ?? 0)}
</Badge>
</React.Fragment>
}
>
<PerformerImagesPanel performer={performer} />
</Tab>
<Tab
eventKey="movies"
title={
<React.Fragment>
{intl.formatMessage({ id: "movies" })}
<Badge className="left-spacing" pill variant="secondary">
{intl.formatNumber(performer.movie_count ?? 0)}
</Badge>
</React.Fragment>
}
>
<PerformerMoviesPanel performer={performer} />
</Tab>
</Tabs>
</React.Fragment>
);
function renderTabsOrEditPanel() {
if (isEditing) {
return (
<PerformerEditPanel
performer={performer}
isVisible={activeTabKey === "edit"}
isVisible={isEditing}
isNew={false}
onDelete={onDelete}
onImageChange={onImageChange}
onImageEncoding={onImageEncoding}
onCancelEditing={() => {
setIsEditing(false);
}}
/>
</Tab>
<Tab
eventKey="operations"
title={intl.formatMessage({ id: "operations" })}
>
<PerformerOperationsPanel performer={performer} />
</Tab>
</Tabs>
);
);
} else {
return renderTabs();
}
}
function maybeRenderAge() {
if (performer?.birthdate) {
@ -375,7 +408,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
</div>
</div>
<div className="performer-body">
<div className="performer-tabs">{renderTabs()}</div>
<div className="performer-tabs">{renderTabsOrEditPanel()}</div>
</div>
</div>
</div>

View file

@ -96,10 +96,10 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return (
<dl className="details-list">
<TextField
id="gender.gender"
id="gender"
value={
performer.gender
? intl.formatMessage({ id: "gender." + performer.gender })
? intl.formatMessage({ id: "gender_types." + performer.gender })
: undefined
}
/>

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Button, Form, Col, Row, Badge, Dropdown } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { FormattedMessage } from "react-intl";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
@ -18,7 +18,6 @@ import {
ImageInput,
LoadingIndicator,
CollapseButton,
Modal,
TagSelect,
URLField,
} from "src/components/Shared";
@ -46,20 +45,19 @@ interface IPerformerDetails {
performer: Partial<GQL.PerformerDataFragment>;
isNew?: boolean;
isVisible: boolean;
onDelete?: () => void;
onImageChange?: (image?: string | null) => void;
onImageEncoding?: (loading?: boolean) => void;
onCancelEditing?: () => void;
}
export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
performer,
isNew,
isVisible,
onDelete,
onImageChange,
onImageEncoding,
onCancelEditing,
}) => {
const intl = useIntl();
const Toast = useToast();
const history = useHistory();
@ -67,7 +65,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const [scraper, setScraper] = useState<GQL.Scraper | IStashBox | undefined>();
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>();
const [isScraperModalOpen, setIsScraperModalOpen] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Network state
const [isLoading, setIsLoading] = useState(false);
@ -361,7 +358,17 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
async function onSave(performerInput: InputValues) {
setIsLoading(true);
try {
if (!isNew) {
if (isNew) {
const input = getCreateValues(performerInput);
const result = await createPerformer({
variables: {
input,
},
});
if (result.data?.performerCreate) {
history.push(`/performers/${result.data.performerCreate.id}`);
}
} else {
const input = getUpdateValues(performerInput);
await updatePerformer({
@ -372,20 +379,14 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
},
},
});
history.push(`/performers/${performer.id}`);
} else {
const input = getCreateValues(performerInput);
const result = await createPerformer({
variables: {
input,
},
});
if (result.data?.performerCreate) {
history.push(`/performers/${result.data.performerCreate.id}`);
}
}
} catch (e) {
Toast.error(e);
setIsLoading(false);
return;
}
if (!isNew && onCancelEditing) {
onCancelEditing();
}
setIsLoading(false);
}
@ -397,12 +398,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
onSave?.(formik.values);
});
if (!isNew) {
Mousetrap.bind("d d", () => {
setIsDeleteAlertOpen(true);
});
}
return () => {
Mousetrap.unbind("s s");
@ -655,25 +650,17 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
setScraper(undefined);
}
function renderButtons() {
function renderButtons(classNames: string) {
return (
<Row>
<Col className="mt-3" xs={12}>
<Button
className="mr-2"
variant="primary"
disabled={!formik.dirty}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
{!isNew ? (
<Col className={classNames} xs={12}>
{!isNew && onCancelEditing ? (
<Button
className="mr-2"
variant="danger"
onClick={() => setIsDeleteAlertOpen(true)}
variant="primary"
onClick={() => onCancelEditing()}
>
<FormattedMessage id="actions.delete" />
<FormattedMessage id="actions.cancel" />
</Button>
) : (
""
@ -685,12 +672,19 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
onImageURL={onImageChangeURL}
/>
<Button
className="mx-2"
className="mr-2"
variant="danger"
onClick={() => formik.setFieldValue("image", null)}
>
<FormattedMessage id="actions.clear_image" />
</Button>
<Button
variant="success"
disabled={!formik.dirty}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
</Col>
</Row>
);
@ -716,28 +710,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
) : undefined;
};
function renderDeleteAlert() {
return (
<Modal
show={isDeleteAlertOpen}
icon="trash-alt"
accept={{
text: intl.formatMessage({ id: "actions.delete" }),
variant: "danger",
onClick: onDelete,
}}
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
>
<p>
<FormattedMessage
id="dialogs.delete_confirm"
values={{ entityName: performer.name }}
/>
</p>
</Modal>
);
}
function renderTagsField() {
return (
<Form.Group controlId="tags" as={Row}>
@ -837,7 +809,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
return (
<>
{renderDeleteAlert()}
{renderScrapeModal()}
{maybeRenderScrapeDialog()}
@ -845,6 +816,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
when={formik.dirty}
message="Unsaved changes. Are you sure you want to leave?"
/>
{renderButtons("mb-3")}
<Form noValidate onSubmit={formik.handleSubmit} id="performer-edit">
<Form.Group controlId="name" as={Row}>
@ -880,7 +852,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<Form.Group as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="gender.gender" />
<FormattedMessage id="gender" />
</Form.Label>
<Col xs="auto">
<Form.Control
@ -970,7 +942,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{renderStashIDs()}
{renderButtons()}
{renderButtons("mt-3")}
</Form>
</>
);

View file

@ -1,34 +0,0 @@
import { Button } from "react-bootstrap";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { mutateMetadataAutoTag } from "src/core/StashService";
import { useToast } from "src/hooks";
interface IPerformerOperationsProps {
performer: GQL.PerformerDataFragment;
}
export const PerformerOperationsPanel: React.FC<IPerformerOperationsProps> = ({
performer,
}) => {
const Toast = useToast();
const intl = useIntl();
async function onAutoTag() {
try {
await mutateMetadataAutoTag({ performers: [performer.id] });
Toast.success({
content: intl.formatMessage({ id: "toast.started_auto_tagging" }),
});
} catch (e) {
Toast.error(e);
}
}
return (
<Button onClick={onAutoTag}>
<FormattedMessage id="actions.auto_tag" />
</Button>
);
};

View file

@ -429,7 +429,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
onChange={(value) => setAliases(value)}
/>
{renderScrapedGenderRow(
intl.formatMessage({ id: "gender.gender" }),
intl.formatMessage({ id: "gender" }),
gender,
(value) => setGender(value)
)}

View file

@ -318,7 +318,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
</Form>
<DetailsEditNavbar
objectName={studio?.name ?? "studio"}
objectName={studio?.name ?? intl.formatMessage({ id: "studio" })}
isNew={isNew}
isEditing
onToggleEdit={onCancel}

View file

@ -192,7 +192,7 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
{renderField(
"gender",
performer.gender
? intl.formatMessage({ id: "gender." + performer.gender })
? intl.formatMessage({ id: "gender_types." + performer.gender })
: ""
)}
{renderField("birthdate", performer.birthdate)}

View file

@ -214,7 +214,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
</Form>
<DetailsEditNavbar
objectName={tag?.name ?? "tag"}
objectName={tag?.name ?? intl.formatMessage({ id: "tag" })}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={onCancel}

View file

@ -691,8 +691,8 @@
"galleries": "Galleries",
"gallery": "Gallery",
"gallery_count": "Gallery Count",
"gender": {
"gender": "Gender",
"gender": "Gender",
"gender_types": {
"MALE": "Male",
"FEMALE": "Female",
"TRANSGENDER_MALE": "Transgender Male",