mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
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:
parent
53489106a6
commit
d4d45d5a06
9 changed files with 450 additions and 259 deletions
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
305
ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx
Normal file
305
ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue