Rebuild Studio page by splitting view and edit (#1629)

* Rebuild Studio page by splitting view and edit
* Fix parent studio id, open studio link in same tab

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
gitgiggety 2021-08-11 06:44:18 +02:00 committed by GitHub
parent 53489106a6
commit d4d45d5a06
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 450 additions and 259 deletions

View file

@ -6,6 +6,7 @@
* Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568)) * Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568))
### 🎨 Improvements ### 🎨 Improvements
* Improve Studio UI. ([#1629](https://github.com/stashapp/stash/pull/1629))
* Improve link styling and ensure links open in a new tab. ([#1622](https://github.com/stashapp/stash/pull/1622)) * Improve link styling and ensure links open in a new tab. ([#1622](https://github.com/stashapp/stash/pull/1622))
* Added zh-CN language option. ([#1620](https://github.com/stashapp/stash/pull/1620)) * Added zh-CN language option. ([#1620](https://github.com/stashapp/stash/pull/1620))
* Moved scraping settings into the Scraping settings page. ([#1548](https://github.com/stashapp/stash/pull/1548)) * Moved scraping settings into the Scraping settings page. ([#1548](https://github.com/stashapp/stash/pull/1548))

View file

@ -259,7 +259,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
new ScrapeResult<string>(props.gallery.details, props.scraped.details) new ScrapeResult<string>(props.gallery.details, props.scraped.details)
); );
const [createStudio] = useStudioCreate({ name: "" }); const [createStudio] = useStudioCreate();
const [createPerformer] = usePerformerCreate(); const [createPerformer] = usePerformerCreate();
const [createTag] = useTagCreate(); const [createTag] = useTagCreate();

View file

@ -321,7 +321,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
new ScrapeResult<string>(props.scene.cover_image, props.scraped.image) new ScrapeResult<string>(props.scene.cover_image, props.scraped.image)
); );
const [createStudio] = useStudioCreate({ name: "" }); const [createStudio] = useStudioCreate();
const [createPerformer] = usePerformerCreate(); const [createPerformer] = usePerformerCreate();
const [createMovie] = useMovieCreate(); const [createMovie] = useMovieCreate();
const [createTag] = useTagCreate(); const [createTag] = useTagCreate();

View file

@ -428,7 +428,7 @@ export const StudioSelect: React.FC<
IFilterProps & { excludeIds?: string[] } IFilterProps & { excludeIds?: string[] }
> = (props) => { > = (props) => {
const { data, loading } = useAllStudiosForFilter(); const { data, loading } = useAllStudiosForFilter();
const [createStudio] = useStudioCreate({ name: "" }); const [createStudio] = useStudioCreate();
const exclude = props.excludeIds ?? []; const exclude = props.excludeIds ?? [];
const studios = (data?.allStudios ?? []).filter( const studios = (data?.allStudios ?? []).filter(

View file

@ -1,6 +1,6 @@
import { Button, Table, Tabs, Tab } from "react-bootstrap"; import { Tabs, Tab } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory, Link } from "react-router-dom"; import { useParams, useHistory } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames"; import cx from "classnames";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
@ -13,21 +13,21 @@ import {
useStudioDestroy, useStudioDestroy,
mutateMetadataAutoTag, mutateMetadataAutoTag,
} from "src/core/StashService"; } from "src/core/StashService";
import { ImageUtils, TableUtils } from "src/utils"; import { ImageUtils } from "src/utils";
import { import {
Icon,
DetailsEditNavbar, DetailsEditNavbar,
Modal, Modal,
LoadingIndicator, LoadingIndicator,
StudioSelect, ErrorMessage,
} from "src/components/Shared"; } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { StudioScenesPanel } from "./StudioScenesPanel"; import { StudioScenesPanel } from "./StudioScenesPanel";
import { StudioGalleriesPanel } from "./StudioGalleriesPanel"; import { StudioGalleriesPanel } from "./StudioGalleriesPanel";
import { StudioImagesPanel } from "./StudioImagesPanel"; import { StudioImagesPanel } from "./StudioImagesPanel";
import { StudioChildrenPanel } from "./StudioChildrenPanel"; import { StudioChildrenPanel } from "./StudioChildrenPanel";
import { StudioPerformersPanel } from "./StudioPerformersPanel"; import { StudioPerformersPanel } from "./StudioPerformersPanel";
import { StudioEditPanel } from "./StudioEditPanel";
import { StudioDetailsPanel } from "./StudioDetailsPanel";
interface IStudioParams { interface IStudioParams {
id?: string; id?: string;
@ -45,83 +45,23 @@ export const Studio: React.FC = () => {
const [isEditing, setIsEditing] = useState<boolean>(isNew); const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing studio state
const [image, setImage] = useState<string | null>();
const [name, setName] = useState<string>();
const [url, setUrl] = useState<string>();
const [parentStudioId, setParentStudioId] = useState<string>();
const [rating, setRating] = useState<number | undefined>(undefined);
const [details, setDetails] = useState<string>();
const [stashIDs, setStashIDs] = useState<GQL.StashIdInput[]>([]);
// Studio state // Studio state
const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({}); const [image, setImage] = useState<string | null>();
const [imagePreview, setImagePreview] = useState<string | null>();
const { data, error, loading } = useFindStudio(id); const { data, error } = useFindStudio(id);
const studio = data?.findStudio;
const [isLoading, setIsLoading] = useState(false);
const [updateStudio] = useStudioUpdate(); const [updateStudio] = useStudioUpdate();
const [createStudio] = useStudioCreate( const [createStudio] = useStudioCreate();
getStudioInput() as GQL.StudioCreateInput const [deleteStudio] = useStudioDestroy({ id });
);
const [deleteStudio] = useStudioDestroy(
getStudioInput() as GQL.StudioDestroyInput
);
function updateStudioEditState(state: Partial<GQL.StudioDataFragment>) {
setName(state.name);
setUrl(state.url ?? undefined);
setParentStudioId(state?.parent_studio?.id ?? undefined);
setRating(state.rating ?? undefined);
setDetails(state.details ?? undefined);
setStashIDs(state.stash_ids ?? []);
}
function updateStudioData(studioData: Partial<GQL.StudioDataFragment>) {
setImage(undefined);
updateStudioEditState(studioData);
setImagePreview(studioData.image_path ?? undefined);
setStudio(studioData);
setRating(studioData.rating ?? undefined);
}
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
if (isEditing) {
Mousetrap.bind("s s", () => onSave());
}
Mousetrap.bind("e", () => setIsEditing(true)); Mousetrap.bind("e", () => setIsEditing(true));
Mousetrap.bind("d d", () => onDelete()); Mousetrap.bind("d d", () => onDelete());
// numeric keypresses get caught by jwplayer, so blur the element
// if the rating sequence is started
Mousetrap.bind("r", () => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
Mousetrap.bind("0", () => setRating(NaN));
Mousetrap.bind("1", () => setRating(1));
Mousetrap.bind("2", () => setRating(2));
Mousetrap.bind("3", () => setRating(3));
Mousetrap.bind("4", () => setRating(4));
Mousetrap.bind("5", () => setRating(5));
setTimeout(() => {
Mousetrap.unbind("0");
Mousetrap.unbind("1");
Mousetrap.unbind("2");
Mousetrap.unbind("3");
Mousetrap.unbind("4");
Mousetrap.unbind("5");
}, 1000);
});
return () => { return () => {
if (isEditing) {
Mousetrap.unbind("s s");
}
Mousetrap.unbind("e"); Mousetrap.unbind("e");
Mousetrap.unbind("d d"); Mousetrap.unbind("d d");
}; };
@ -130,58 +70,36 @@ export const Studio: React.FC = () => {
useEffect(() => { useEffect(() => {
if (data && data.findStudio) { if (data && data.findStudio) {
setImage(undefined); setImage(undefined);
updateStudioEditState(data.findStudio);
setImagePreview(data.findStudio.image_path ?? undefined);
setStudio(data.findStudio);
} }
}, [data]); }, [data]);
function onImageLoad(imageData: string) { function onImageLoad(imageData: string) {
setImagePreview(imageData);
setImage(imageData); setImage(imageData);
} }
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
if (!isNew && !isEditing) { async function onSave(
if (!data?.findStudio || loading || !studio.id) return <LoadingIndicator />; input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput>
if (error) return <div>{error.message}</div>; ) {
}
function getStudioInput() {
const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = {
name,
url,
image,
details,
parent_id: parentStudioId ?? null,
rating: rating ?? null,
stash_ids: stashIDs.map((s) => ({
stash_id: s.stash_id,
endpoint: s.endpoint,
})),
};
if (!isNew) {
(input as GQL.StudioUpdateInput).id = id;
}
return input;
}
async function onSave() {
try { try {
setIsLoading(true);
if (!isNew) { if (!isNew) {
const result = await updateStudio({ const result = await updateStudio({
variables: { variables: {
input: getStudioInput() as GQL.StudioUpdateInput, input: input as GQL.StudioUpdateInput,
}, },
}); });
if (result.data?.studioUpdate) { if (result.data?.studioUpdate) {
updateStudioData(result.data.studioUpdate);
setIsEditing(false); setIsEditing(false);
} }
} else { } else {
const result = await createStudio(); const result = await createStudio({
variables: {
input: input as GQL.StudioCreateInput,
},
});
if (result.data?.studioCreate?.id) { if (result.data?.studioCreate?.id) {
history.push(`/studios/${result.data.studioCreate.id}`); history.push(`/studios/${result.data.studioCreate.id}`);
setIsEditing(false); setIsEditing(false);
@ -189,11 +107,13 @@ export const Studio: React.FC = () => {
} }
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} finally {
setIsLoading(false);
} }
} }
async function onAutoTag() { async function onAutoTag() {
if (!studio.id) return; if (!studio?.id) return;
try { try {
await mutateMetadataAutoTag({ studios: [studio.id] }); await mutateMetadataAutoTag({ studios: [studio.id] });
Toast.success({ Toast.success({
@ -215,19 +135,6 @@ export const Studio: React.FC = () => {
history.push(`/studios`); history.push(`/studios`);
} }
const removeStashID = (stashID: GQL.StashIdInput) => {
setStashIDs(
stashIDs.filter(
(s) =>
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
)
);
};
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function renderDeleteAlert() { function renderDeleteAlert() {
return ( return (
<Modal <Modal
@ -245,7 +152,7 @@ export const Studio: React.FC = () => {
id="dialogs.delete_confirm" id="dialogs.delete_confirm"
values={{ values={{
entityName: entityName:
name ?? studio?.name ??
intl.formatMessage({ id: "studio" }).toLocaleLowerCase(), intl.formatMessage({ id: "studio" }).toLocaleLowerCase(),
}} }}
/> />
@ -254,64 +161,25 @@ export const Studio: React.FC = () => {
); );
} }
function renderStashIDs() {
if (!studio.stash_ids?.length) {
return;
}
return (
<tr>
<td>StashIDs</td>
<td>
<ul className="pl-0">
{stashIDs.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
const link = base ? (
<a
href={`${base}studios/${stashID.stash_id}`}
target="_blank"
rel="noopener noreferrer"
>
{stashID.stash_id}
</a>
) : (
stashID.stash_id
);
return (
<li key={stashID.stash_id} className="row no-gutters">
{isEditing && (
<Button
variant="danger"
className="mr-2 py-0"
title={intl.formatMessage(
{ id: "actions.delete_entity" },
{ entityType: intl.formatMessage({ id: "stash_id" }) }
)}
onClick={() => removeStashID(stashID)}
>
<Icon icon="trash-alt" />
</Button>
)}
{link}
</li>
);
})}
</ul>
</td>
</tr>
);
}
function onToggleEdit() { function onToggleEdit() {
setIsEditing(!isEditing); setIsEditing(!isEditing);
updateStudioData(studio);
} }
function onClearImage() { function renderImage() {
setImage(null); let studioImage = studio?.image_path;
setImagePreview( if (isEditing) {
studio.image_path ? `${studio.image_path}&default=true` : undefined if (image === null) {
); studioImage = `${studioImage}&default=true`;
} else if (image) {
studioImage = image;
}
}
if (studioImage) {
return (
<img className="logo" alt={studio?.name ?? ""} src={studioImage} />
);
}
} }
const activeTabKey = const activeTabKey =
@ -328,28 +196,10 @@ export const Studio: React.FC = () => {
} }
}; };
function renderStudio() { if (isLoading) return <LoadingIndicator />;
if (isEditing || !parentStudioId) { if (error) return <ErrorMessage error={error.message} />;
return ( if (!studio?.id && !isNew)
<StudioSelect return <ErrorMessage error={`No studio found with id ${id}.`} />;
onSelect={(items) =>
setParentStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={parentStudioId ? [parentStudioId] : []}
isDisabled={!isEditing}
excludeIds={studio.id ? [studio.id] : []}
/>
);
}
if (studio.parent_studio) {
return (
<Link to={`/studios/${studio.parent_studio.id}`}>
{studio.parent_studio.name}
</Link>
);
}
}
return ( return (
<div className="row"> <div className="row">
@ -370,66 +220,36 @@ export const Studio: React.FC = () => {
<div className="text-center"> <div className="text-center">
{imageEncoding ? ( {imageEncoding ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
) : imagePreview ? (
<img className="logo" alt={name} src={imagePreview} />
) : ( ) : (
"" renderImage()
)} )}
</div> </div>
<Table> {!isEditing && !isNew && studio ? (
<tbody> <>
{TableUtils.renderInputGroup({ <StudioDetailsPanel studio={studio} />
title: intl.formatMessage({ id: "name" }), <DetailsEditNavbar
value: name ?? "", objectName={studio.name ?? intl.formatMessage({ id: "studio" })}
isEditing: !!isEditing, isNew={isNew}
onChange: setName, isEditing={isEditing}
})} onToggleEdit={onToggleEdit}
{TableUtils.renderInputGroup({ onSave={() => {}}
title: intl.formatMessage({ id: "url" }), onImageChange={() => {}}
value: url, onClearImage={() => {}}
isEditing: !!isEditing, onAutoTag={onAutoTag}
onChange: setUrl, onDelete={onDelete}
})} />
{TableUtils.renderTextArea({ </>
title: intl.formatMessage({ id: "details" }), ) : (
value: details, <StudioEditPanel
isEditing: !!isEditing, studio={studio ?? ({} as Partial<GQL.Studio>)}
onChange: setDetails, onSubmit={onSave}
})} onCancel={onToggleEdit}
<tr> onDelete={onDelete}
<td>{intl.formatMessage({ id: "parent_studios" })}</td> onImageChange={setImage}
<td>{renderStudio()}</td> />
</tr> )}
<tr>
<td>{intl.formatMessage({ id: "rating" })}:</td>
<td>
<RatingStars
value={rating}
disabled={!isEditing}
onSetRating={(value) => setRating(value ?? NaN)}
/>
</td>
</tr>
{renderStashIDs()}
</tbody>
</Table>
<DetailsEditNavbar
objectName={name ?? "studio"}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={onToggleEdit}
onSave={onSave}
onImageChange={onImageChangeHandler}
onImageChangeURL={onImageLoad}
onClearImage={() => {
onClearImage();
}}
onAutoTag={onAutoTag}
onDelete={onDelete}
acceptSVG
/>
</div> </div>
{!isNew && ( {studio?.id && (
<div className="col col-md-8"> <div className="col col-md-8">
<Tabs <Tabs
id="studio-tabs" id="studio-tabs"

View file

@ -0,0 +1,59 @@
import React from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { TextField, URLField } from "src/utils/field";
interface IStudioDetailsPanel {
studio: Partial<GQL.StudioDataFragment>;
}
export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
studio,
}) => {
const intl = useIntl();
function renderRatingField() {
if (!studio.rating) {
return;
}
return (
<>
<dt>{intl.formatMessage({ id: "rating" })}</dt>
<dd>
<RatingStars value={studio.rating} disabled />
</dd>
</>
);
}
return (
<div className="studio-details">
<div>
<h2>{studio.name}</h2>
</div>
<dl className="details-list">
<URLField
id="url"
value={studio.url}
url={TextUtils.sanitiseURL(studio.url ?? "")}
/>
<TextField id="details" value={studio.details} />
<URLField
id="parent_studios"
value={studio.parent_studio?.name}
url={`/studios/${studio.parent_studio?.id}`}
trusted
target="_self"
/>
{renderRatingField()}
</dl>
</div>
);
};

View file

@ -0,0 +1,305 @@
import React, { useEffect } from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import Mousetrap from "mousetrap";
import { Icon, StudioSelect, DetailsEditNavbar } from "src/components/Shared";
import { Button, Form, Col, Row } from "react-bootstrap";
import { FormUtils, ImageUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>;
onSubmit: (
studio: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput>
) => void;
onCancel: () => void;
onDelete: () => void;
onImageChange?: (image?: string | null) => void;
onImageEncoding?: (loading?: boolean) => void;
}
export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
studio,
onSubmit,
onCancel,
onDelete,
onImageChange,
onImageEncoding,
}) => {
const intl = useIntl();
const isNew = !studio || !studio.id;
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
const schema = yup.object({
name: yup.string().required(),
url: yup.string().optional().nullable(),
details: yup.string().optional().nullable(),
image: yup.string().optional().nullable(),
rating: yup.number().optional().nullable(),
parent_id: yup.string().optional().nullable(),
stash_ids: yup.mixed<GQL.StashIdInput>().optional().nullable(),
});
const initialValues = {
name: studio.name ?? "",
url: studio.url ?? "",
details: studio.details ?? "",
image: undefined,
rating: studio.rating ?? null,
parent_id: studio.parent_studio?.id,
stash_ids: studio.stash_ids ?? undefined,
};
type InputValues = typeof initialValues;
const formik = useFormik({
initialValues,
validationSchema: schema,
onSubmit: (values) => onSubmit(getStudioInput(values)),
});
function setRating(v: number) {
formik.setFieldValue("rating", v);
}
function onImageLoad(imageData: string) {
formik.setFieldValue("image", imageData);
}
function getStudioInput(values: InputValues) {
const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = {
...values,
};
if (studio && studio.id) {
(input as GQL.StudioUpdateInput).id = studio.id;
}
return input;
}
// set up hotkeys
useEffect(() => {
Mousetrap.bind("s s", () => formik.handleSubmit());
// numeric keypresses get caught by jwplayer, so blur the element
// if the rating sequence is started
Mousetrap.bind("r", () => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
Mousetrap.bind("0", () => setRating(NaN));
Mousetrap.bind("1", () => setRating(1));
Mousetrap.bind("2", () => setRating(2));
Mousetrap.bind("3", () => setRating(3));
Mousetrap.bind("4", () => setRating(4));
Mousetrap.bind("5", () => setRating(5));
setTimeout(() => {
Mousetrap.unbind("0");
Mousetrap.unbind("1");
Mousetrap.unbind("2");
Mousetrap.unbind("3");
Mousetrap.unbind("4");
Mousetrap.unbind("5");
}, 1000);
});
return () => {
Mousetrap.unbind("s s");
Mousetrap.unbind("e");
};
});
useEffect(() => {
if (onImageChange) {
onImageChange(formik.values.image);
}
return () => onImageChange?.();
}, [formik.values.image, onImageChange]);
useEffect(() => onImageEncoding?.(imageEncoding), [
onImageEncoding,
imageEncoding,
]);
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function onImageChangeURL(url: string) {
formik.setFieldValue("image", url);
}
const removeStashID = (stashID: GQL.StashIdInput) => {
formik.setFieldValue(
"stash_ids",
(formik.values.stash_ids ?? []).filter(
(s) =>
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
)
);
};
function renderStashIDs() {
if (!formik.values.stash_ids?.length) {
return;
}
return (
<Row>
<Form.Label column>StashIDs</Form.Label>
<Col xs={9}>
<ul className="pl-0">
{formik.values.stash_ids.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
const link = base ? (
<a
href={`${base}studios/${stashID.stash_id}`}
target="_blank"
rel="noopener noreferrer"
>
{stashID.stash_id}
</a>
) : (
stashID.stash_id
);
return (
<li key={stashID.stash_id} className="row no-gutters">
<Button
variant="danger"
className="mr-2 py-0"
title={intl.formatMessage(
{ id: "actions.delete_entity" },
{ entityType: intl.formatMessage({ id: "stash_id" }) }
)}
onClick={() => removeStashID(stashID)}
>
<Icon icon="trash-alt" />
</Button>
{link}
</li>
);
})}
</ul>
</Col>
</Row>
);
}
return (
<>
<Prompt
when={formik.dirty}
message="Unsaved changes. Are you sure you want to leave?"
/>
<Form noValidate onSubmit={formik.handleSubmit} id="studio-edit">
<Form.Group controlId="name" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "name" }),
})}
<Col xs={9}>
<Form.Control
className="text-input"
{...formik.getFieldProps("name")}
isInvalid={!!formik.errors.name}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.name}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="url" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "url" }),
})}
<Col xs={9}>
<Form.Control
className="text-input"
{...formik.getFieldProps("url")}
isInvalid={!!formik.errors.url}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.url}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="details" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "details" }),
})}
<Col xs={9}>
<Form.Control
as="textarea"
className="text-input"
{...formik.getFieldProps("details")}
isInvalid={!!formik.errors.details}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.details}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="parent_studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "parent_studios" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"parent_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.parent_id ? [formik.values.parent_id] : []}
excludeIds={studio.id ? [studio.id] : []}
/>
</Col>
</Form.Group>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={formik.values.rating ?? undefined}
onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null)
}
/>
</Col>
</Form.Group>
{renderStashIDs()}
</Form>
<DetailsEditNavbar
objectName={studio?.name ?? "studio"}
isNew={isNew}
isEditing
onToggleEdit={onCancel}
onSave={() => formik.handleSubmit()}
saveDisabled={!formik.dirty}
onImageChange={onImageChangeHandler}
onImageChangeURL={onImageChangeURL}
onClearImage={() => {
formik.setFieldValue("image", null);
}}
onDelete={onDelete}
acceptSVG
/>
</>
);
};

View file

@ -598,9 +598,8 @@ export const studioMutationImpactedQueries = [
GQL.AllStudiosForFilterDocument, GQL.AllStudiosForFilterDocument,
]; ];
export const useStudioCreate = (input: GQL.StudioCreateInput) => export const useStudioCreate = () =>
GQL.useStudioCreateMutation({ GQL.useStudioCreateMutation({
variables: { input },
refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]), refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]),
update: deleteCache([ update: deleteCache([
GQL.FindStudiosDocument, GQL.FindStudiosDocument,

View file

@ -43,6 +43,9 @@ interface IURLField {
value?: string | null; value?: string | null;
url?: string | null; url?: string | null;
truncate?: boolean | null; truncate?: boolean | null;
target?: string;
// use for internal links
trusted?: boolean;
} }
export const URLField: React.FC<IURLField> = ({ export const URLField: React.FC<IURLField> = ({
@ -53,6 +56,8 @@ export const URLField: React.FC<IURLField> = ({
abbr, abbr,
truncate, truncate,
children, children,
target,
trusted,
}) => { }) => {
if (!value && !children) { if (!value && !children) {
return null; return null;
@ -62,12 +67,14 @@ export const URLField: React.FC<IURLField> = ({
<>{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}:</> <>{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}:</>
); );
const rel = !trusted ? "noopener noreferrer" : undefined;
return ( return (
<> <>
<dt>{abbr ? <abbr title={abbr}>{message}</abbr> : message}</dt> <dt>{abbr ? <abbr title={abbr}>{message}</abbr> : message}</dt>
<dd> <dd>
{url ? ( {url ? (
<a href={url} target="_blank" rel="noopener noreferrer"> <a href={url} target={target || "_blank"} rel={rel}>
{value ? ( {value ? (
truncate ? ( truncate ? (
<TruncatedText text={value} /> <TruncatedText text={value} />