diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md index f4f3ea661..bdcc977f2 100644 --- a/ui/v2.5/src/components/Changelog/versions/v090.md +++ b/ui/v2.5/src/components/Changelog/versions/v090.md @@ -6,6 +6,7 @@ * Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568)) ### 🎨 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)) * 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)) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index c7ec3e965..701bbe019 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -259,7 +259,7 @@ export const GalleryScrapeDialog: React.FC = ( new ScrapeResult(props.gallery.details, props.scraped.details) ); - const [createStudio] = useStudioCreate({ name: "" }); + const [createStudio] = useStudioCreate(); const [createPerformer] = usePerformerCreate(); const [createTag] = useTagCreate(); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 221dcc9ad..86989d358 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -321,7 +321,7 @@ export const SceneScrapeDialog: React.FC = ( new ScrapeResult(props.scene.cover_image, props.scraped.image) ); - const [createStudio] = useStudioCreate({ name: "" }); + const [createStudio] = useStudioCreate(); const [createPerformer] = usePerformerCreate(); const [createMovie] = useMovieCreate(); const [createTag] = useTagCreate(); diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index c8f3d456c..22f89cf4a 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -428,7 +428,7 @@ export const StudioSelect: React.FC< IFilterProps & { excludeIds?: string[] } > = (props) => { const { data, loading } = useAllStudiosForFilter(); - const [createStudio] = useStudioCreate({ name: "" }); + const [createStudio] = useStudioCreate(); const exclude = props.excludeIds ?? []; const studios = (data?.allStudios ?? []).filter( diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index c6042eb0d..aaeb8ff4b 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -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 { useParams, useHistory, Link } from "react-router-dom"; +import { useParams, useHistory } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import cx from "classnames"; import Mousetrap from "mousetrap"; @@ -13,21 +13,21 @@ import { useStudioDestroy, mutateMetadataAutoTag, } from "src/core/StashService"; -import { ImageUtils, TableUtils } from "src/utils"; +import { ImageUtils } from "src/utils"; import { - Icon, DetailsEditNavbar, Modal, LoadingIndicator, - StudioSelect, + ErrorMessage, } from "src/components/Shared"; import { useToast } from "src/hooks"; -import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { StudioScenesPanel } from "./StudioScenesPanel"; import { StudioGalleriesPanel } from "./StudioGalleriesPanel"; import { StudioImagesPanel } from "./StudioImagesPanel"; import { StudioChildrenPanel } from "./StudioChildrenPanel"; import { StudioPerformersPanel } from "./StudioPerformersPanel"; +import { StudioEditPanel } from "./StudioEditPanel"; +import { StudioDetailsPanel } from "./StudioDetailsPanel"; interface IStudioParams { id?: string; @@ -45,83 +45,23 @@ export const Studio: React.FC = () => { const [isEditing, setIsEditing] = useState(isNew); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); - // Editing studio state - const [image, setImage] = useState(); - const [name, setName] = useState(); - const [url, setUrl] = useState(); - const [parentStudioId, setParentStudioId] = useState(); - const [rating, setRating] = useState(undefined); - const [details, setDetails] = useState(); - const [stashIDs, setStashIDs] = useState([]); - // Studio state - const [studio, setStudio] = useState>({}); - const [imagePreview, setImagePreview] = useState(); + const [image, setImage] = useState(); - const { data, error, loading } = useFindStudio(id); + const { data, error } = useFindStudio(id); + const studio = data?.findStudio; + + const [isLoading, setIsLoading] = useState(false); const [updateStudio] = useStudioUpdate(); - const [createStudio] = useStudioCreate( - getStudioInput() as GQL.StudioCreateInput - ); - const [deleteStudio] = useStudioDestroy( - getStudioInput() as GQL.StudioDestroyInput - ); - - function updateStudioEditState(state: Partial) { - 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) { - setImage(undefined); - updateStudioEditState(studioData); - setImagePreview(studioData.image_path ?? undefined); - setStudio(studioData); - setRating(studioData.rating ?? undefined); - } + const [createStudio] = useStudioCreate(); + const [deleteStudio] = useStudioDestroy({ id }); // set up hotkeys useEffect(() => { - if (isEditing) { - Mousetrap.bind("s s", () => onSave()); - } - Mousetrap.bind("e", () => setIsEditing(true)); 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 () => { - if (isEditing) { - Mousetrap.unbind("s s"); - } - Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; @@ -130,58 +70,36 @@ export const Studio: React.FC = () => { useEffect(() => { if (data && data.findStudio) { setImage(undefined); - updateStudioEditState(data.findStudio); - setImagePreview(data.findStudio.image_path ?? undefined); - setStudio(data.findStudio); } }, [data]); function onImageLoad(imageData: string) { - setImagePreview(imageData); setImage(imageData); } const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing); - if (!isNew && !isEditing) { - if (!data?.findStudio || loading || !studio.id) return ; - if (error) return
{error.message}
; - } - - function getStudioInput() { - const input: Partial = { - 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() { + async function onSave( + input: Partial + ) { try { + setIsLoading(true); + if (!isNew) { const result = await updateStudio({ variables: { - input: getStudioInput() as GQL.StudioUpdateInput, + input: input as GQL.StudioUpdateInput, }, }); if (result.data?.studioUpdate) { - updateStudioData(result.data.studioUpdate); setIsEditing(false); } } else { - const result = await createStudio(); + const result = await createStudio({ + variables: { + input: input as GQL.StudioCreateInput, + }, + }); if (result.data?.studioCreate?.id) { history.push(`/studios/${result.data.studioCreate.id}`); setIsEditing(false); @@ -189,11 +107,13 @@ export const Studio: React.FC = () => { } } catch (e) { Toast.error(e); + } finally { + setIsLoading(false); } } async function onAutoTag() { - if (!studio.id) return; + if (!studio?.id) return; try { await mutateMetadataAutoTag({ studios: [studio.id] }); Toast.success({ @@ -215,19 +135,6 @@ export const Studio: React.FC = () => { 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) { - ImageUtils.onImageChange(event, onImageLoad); - } - function renderDeleteAlert() { return ( { id="dialogs.delete_confirm" values={{ entityName: - name ?? + studio?.name ?? intl.formatMessage({ id: "studio" }).toLocaleLowerCase(), }} /> @@ -254,64 +161,25 @@ export const Studio: React.FC = () => { ); } - function renderStashIDs() { - if (!studio.stash_ids?.length) { - return; - } - - return ( - - StashIDs - -
    - {stashIDs.map((stashID) => { - const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; - const link = base ? ( - - {stashID.stash_id} - - ) : ( - stashID.stash_id - ); - return ( -
  • - {isEditing && ( - - )} - {link} -
  • - ); - })} -
- - - ); - } - function onToggleEdit() { setIsEditing(!isEditing); - updateStudioData(studio); } - function onClearImage() { - setImage(null); - setImagePreview( - studio.image_path ? `${studio.image_path}&default=true` : undefined - ); + function renderImage() { + let studioImage = studio?.image_path; + if (isEditing) { + if (image === null) { + studioImage = `${studioImage}&default=true`; + } else if (image) { + studioImage = image; + } + } + + if (studioImage) { + return ( + {studio?.name + ); + } } const activeTabKey = @@ -328,28 +196,10 @@ export const Studio: React.FC = () => { } }; - function renderStudio() { - if (isEditing || !parentStudioId) { - return ( - - setParentStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={parentStudioId ? [parentStudioId] : []} - isDisabled={!isEditing} - excludeIds={studio.id ? [studio.id] : []} - /> - ); - } - - if (studio.parent_studio) { - return ( - - {studio.parent_studio.name} - - ); - } - } + if (isLoading) return ; + if (error) return ; + if (!studio?.id && !isNew) + return ; return (
@@ -370,66 +220,36 @@ export const Studio: React.FC = () => {
{imageEncoding ? ( - ) : imagePreview ? ( - {name} ) : ( - "" + renderImage() )}
- - - {TableUtils.renderInputGroup({ - title: intl.formatMessage({ id: "name" }), - value: name ?? "", - isEditing: !!isEditing, - onChange: setName, - })} - {TableUtils.renderInputGroup({ - title: intl.formatMessage({ id: "url" }), - value: url, - isEditing: !!isEditing, - onChange: setUrl, - })} - {TableUtils.renderTextArea({ - title: intl.formatMessage({ id: "details" }), - value: details, - isEditing: !!isEditing, - onChange: setDetails, - })} - - - - - - - - - {renderStashIDs()} - -
{intl.formatMessage({ id: "parent_studios" })}{renderStudio()}
{intl.formatMessage({ id: "rating" })}: - setRating(value ?? NaN)} - /> -
- { - onClearImage(); - }} - onAutoTag={onAutoTag} - onDelete={onDelete} - acceptSVG - /> + {!isEditing && !isNew && studio ? ( + <> + + {}} + onImageChange={() => {}} + onClearImage={() => {}} + onAutoTag={onAutoTag} + onDelete={onDelete} + /> + + ) : ( + )} + onSubmit={onSave} + onCancel={onToggleEdit} + onDelete={onDelete} + onImageChange={setImage} + /> + )}
- {!isNew && ( + {studio?.id && (
; +} + +export const StudioDetailsPanel: React.FC = ({ + studio, +}) => { + const intl = useIntl(); + + function renderRatingField() { + if (!studio.rating) { + return; + } + + return ( + <> +
{intl.formatMessage({ id: "rating" })}
+
+ +
+ + ); + } + + return ( +
+
+

{studio.name}

+
+ +
+ + + + + + + {renderRatingField()} +
+
+ ); +}; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx new file mode 100644 index 000000000..e1222ac58 --- /dev/null +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -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; + onSubmit: ( + studio: Partial + ) => void; + onCancel: () => void; + onDelete: () => void; + onImageChange?: (image?: string | null) => void; + onImageEncoding?: (loading?: boolean) => void; +} + +export const StudioEditPanel: React.FC = ({ + 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().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 = { + ...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) { + 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 ( + + StashIDs + +
    + {formik.values.stash_ids.map((stashID) => { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( + stashID.stash_id + ); + return ( +
  • + + {link} +
  • + ); + })} +
+ +
+ ); + } + + return ( + <> + + +
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "name" }), + })} + + + + {formik.errors.name} + + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "url" }), + })} + + + + {formik.errors.url} + + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "details" }), + })} + + + + {formik.errors.details} + + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "parent_studios" }), + })} + + + 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] : []} + /> + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "rating" }), + })} + + + formik.setFieldValue("rating", value ?? null) + } + /> + + + + {renderStashIDs()} +
+ + formik.handleSubmit()} + saveDisabled={!formik.dirty} + onImageChange={onImageChangeHandler} + onImageChangeURL={onImageChangeURL} + onClearImage={() => { + formik.setFieldValue("image", null); + }} + onDelete={onDelete} + acceptSVG + /> + + ); +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 44fc90b10..1762a6ff9 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -598,9 +598,8 @@ export const studioMutationImpactedQueries = [ GQL.AllStudiosForFilterDocument, ]; -export const useStudioCreate = (input: GQL.StudioCreateInput) => +export const useStudioCreate = () => GQL.useStudioCreateMutation({ - variables: { input }, refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]), update: deleteCache([ GQL.FindStudiosDocument, diff --git a/ui/v2.5/src/utils/field.tsx b/ui/v2.5/src/utils/field.tsx index 68c8a8230..a22b042cc 100644 --- a/ui/v2.5/src/utils/field.tsx +++ b/ui/v2.5/src/utils/field.tsx @@ -43,6 +43,9 @@ interface IURLField { value?: string | null; url?: string | null; truncate?: boolean | null; + target?: string; + // use for internal links + trusted?: boolean; } export const URLField: React.FC = ({ @@ -53,6 +56,8 @@ export const URLField: React.FC = ({ abbr, truncate, children, + target, + trusted, }) => { if (!value && !children) { return null; @@ -62,12 +67,14 @@ export const URLField: React.FC = ({ <>{id ? : name}: ); + const rel = !trusted ? "noopener noreferrer" : undefined; + return ( <>
{abbr ? {message} : message}
{url ? ( - + {value ? ( truncate ? (