diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index ab2045aac..f255dfa1e 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -1,14 +1,16 @@ import React, { useEffect, useState } from "react"; -import { useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import Mousetrap from "mousetrap"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; -import { Button, Form } from "react-bootstrap"; +import { ImageInput } from "src/components/Shared/ImageInput"; +import cx from "classnames"; +import { Button, Dropdown, Form } from "react-bootstrap"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import ImageUtils from "src/utils/image"; import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; +import { stashboxDisplayName } from "src/utils/stashbox"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import isEqual from "lodash-es/isEqual"; @@ -21,6 +23,8 @@ import { Studio, StudioSelect } from "../StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Icon } from "src/components/Shared/Icon"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import StudioStashBoxModal, { IStashBox } from "./StudioStashBoxModal"; +import { StudioScrapeDialog } from "./StudioScrapeDialog"; interface IStudioEditPanel { studio: Partial; @@ -45,7 +49,10 @@ export const StudioEditPanel: React.FC = ({ const isNew = studio.id === undefined; - // Editing state + // Editing/scraper state + const [scraper, setScraper] = useState(); + const [isScraperModalOpen, setIsScraperModalOpen] = useState(false); + const [scrapedStudio, setScrapedStudio] = useState(); const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); // Network state @@ -86,8 +93,9 @@ export const StudioEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); - const { tagsControl } = useTagsEdit(studio.tags, (ids) => - formik.setFieldValue("tag_ids", ids) + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + studio.tags, + (ids) => formik.setFieldValue("tag_ids", ids) ); function onSetParentStudio(item: Studio | null) { @@ -159,6 +167,189 @@ export const StudioEditPanel: React.FC = ({ ); } + function updateStashIDs(remoteSiteID: string | null | undefined) { + if (remoteSiteID && scraper?.endpoint) { + const newIDs = + formik.values.stash_ids?.filter( + (s) => s.endpoint !== scraper.endpoint + ) ?? []; + newIDs.push({ + endpoint: scraper.endpoint, + stash_id: remoteSiteID, + updated_at: new Date().toISOString(), + }); + formik.setFieldValue("stash_ids", newIDs); + } + } + + function updateStudioEditStateFromScraper( + state: Partial + ) { + if (state.name) { + formik.setFieldValue("name", state.name); + } + if (state.urls) { + formik.setFieldValue("urls", state.urls); + } + if (state.details) { + formik.setFieldValue("details", state.details); + } + if (state.aliases) { + formik.setFieldValue( + "aliases", + state.aliases.split(",").map((a) => a.trim()) + ); + } + updateTagsStateFromScraper(state.tags ?? undefined); + + // image is a base64 string + // overwrite if not new since it came from a dialog + // overwrite if image is unset + if ((!isNew || !formik.values.image) && state.image) { + formik.setFieldValue("image", state.image); + } + + updateStashIDs(state.remote_site_id); + } + + function onScrapeStashBox(studioResult: GQL.ScrapedStudio) { + setIsScraperModalOpen(false); + + const result: GQL.ScrapedStudioDataFragment = { + ...studioResult, + __typename: "ScrapedStudio", + }; + + // if this is a new studio, just dump the data + if (isNew) { + updateStudioEditStateFromScraper(result); + setScraper(undefined); + } else { + setScrapedStudio(result); + } + } + + function onScraperSelected(s: IStashBox) { + setScraper(s); + setIsScraperModalOpen(true); + } + + function renderScraperMenu() { + if (!studio) { + return; + } + const stashBoxes = stashConfig?.general.stashBoxes ?? []; + + if (stashBoxes.length === 0) { + return; + } + + const popover = ( + + {stashBoxes.map((s, index) => ( + onScraperSelected({ ...s, index })} + > + {stashboxDisplayName(s.name, index)} + + ))} + + ); + + return ( + + + + + {popover} + + ); + } + + function renderButtons(classNames: string) { + return ( +
+ {!isNew && ( + + )} + {renderScraperMenu()} + +
+ +
+ +
+ ); + } + + function maybeRenderScrapeDialog() { + if (!scrapedStudio || !scraper) { + return; + } + + const currentStudio = { + ...formik.values, + image: formik.values.image ?? studio.image_path, + }; + + return ( + { + onScrapeDialogClosed(s); + }} + /> + ); + } + + function onScrapeDialogClosed(s?: GQL.ScrapedStudioDataFragment) { + if (s) { + updateStudioEditStateFromScraper(s); + } + setScrapedStudio(undefined); + setScraper(undefined); + } + + function renderScrapeModal() { + if (!isScraperModalOpen || !scraper) { + return; + } + + return ( + setScraper(undefined)} + onSelectStudio={onScrapeStashBox} + name={formik.values.name || ""} + /> + ); + } + const { renderField, renderInputField, @@ -189,6 +380,8 @@ export const StudioEditPanel: React.FC = ({ return ( <> + {renderScrapeModal()} + {maybeRenderScrapeDialog()} {isStashIDSearchOpen && ( = ({ {renderInputField("ignore_auto_tag", "checkbox")} - onImageLoad(null)} - onDelete={onDelete} - acceptSVG - /> + {renderButtons("mt-3")} ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioScrapeDialog.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioScrapeDialog.tsx new file mode 100644 index 000000000..b3208b50d --- /dev/null +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioScrapeDialog.tsx @@ -0,0 +1,182 @@ +import React, { useState } from "react"; +import { useIntl } from "react-intl"; +import * as GQL from "src/core/generated-graphql"; +import { + ScrapedInputGroupRow, + ScrapedImageRow, + ScrapedTextAreaRow, + ScrapedStringListRow, +} from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; +import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; +import { IStashBox } from "./StudioStashBoxModal"; +import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; +import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; +import { Tag } from "src/components/Tags/TagSelect"; +import { uniq } from "lodash-es"; + +interface IStudioScrapeDialogProps { + studio: Partial; + studioTags: Tag[]; + scraped: GQL.ScrapedStudio; + scraper: IStashBox; + onClose: (scrapedStudio?: GQL.ScrapedStudio) => void; +} + +export const StudioScrapeDialog: React.FC = ({ + studio, + studioTags, + scraped, + scraper, + onClose, +}) => { + const intl = useIntl(); + + const { endpoint } = scraper; + + function getCurrentRemoteSiteID() { + if (!endpoint) { + return; + } + + const stashIDs = (studio.stash_ids ?? []).filter( + (s) => s.endpoint === endpoint + ); + if (stashIDs.length > 1 && scraped.remote_site_id) { + const matchingID = stashIDs.find( + (s) => s.stash_id === scraped.remote_site_id + ); + if (matchingID) { + return matchingID.stash_id; + } + } + + return studio.stash_ids?.find((s) => s.endpoint === endpoint)?.stash_id; + } + + const [name, setName] = useState>( + new ScrapeResult(studio.name, scraped.name) + ); + + const [urls, setURLs] = useState>( + new ScrapeResult( + studio.urls, + scraped.urls + ? uniq((studio.urls ?? []).concat(scraped.urls ?? [])) + : undefined + ) + ); + + const [details, setDetails] = useState>( + new ScrapeResult(studio.details, scraped.details) + ); + + const [aliases, setAliases] = useState>( + new ScrapeResult( + studio.aliases?.join(", "), + scraped.aliases + ) + ); + + const [remoteSiteID, setRemoteSiteID] = useState>( + new ScrapeResult( + getCurrentRemoteSiteID(), + scraped.remote_site_id + ) + ); + + const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( + studioTags, + scraped.tags, + endpoint + ); + + const [image, setImage] = useState>( + new ScrapeResult(studio.image, scraped.image) + ); + + const allFields = [name, urls, details, aliases, tags, image, remoteSiteID]; + + // don't show the dialog if nothing was scraped + if (allFields.every((r) => !r.scraped) && newTags.length === 0) { + onClose(); + return <>; + } + + function makeNewScrapedItem(): GQL.ScrapedStudio { + return { + name: name.getNewValue() ?? "", + urls: urls.getNewValue(), + details: details.getNewValue(), + aliases: aliases.getNewValue(), + tags: tags.getNewValue(), + image: image.getNewValue(), + remote_site_id: remoteSiteID.getNewValue(), + // Include parent from original scraped data (read-only) + parent: scraped.parent, + }; + } + + function renderScrapeRows() { + return ( + <> + setName(value)} + /> + setURLs(value)} + /> + setDetails(value)} + /> + setAliases(value)} + /> + {scrapedTagsRow} + setImage(value)} + /> + setRemoteSiteID(value)} + /> + + ); + } + + if (linkDialog) { + return linkDialog; + } + + return ( + { + onClose(apply ? makeNewScrapedItem() : undefined); + }} + > + {renderScrapeRows()} + + ); +}; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioStashBoxModal.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioStashBoxModal.tsx new file mode 100644 index 000000000..2c29d99af --- /dev/null +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioStashBoxModal.tsx @@ -0,0 +1,183 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Form, Row } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; + +import * as GQL from "src/core/generated-graphql"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; +import { stashboxDisplayName } from "src/utils/stashbox"; +import { useDebounce } from "src/hooks/debounce"; +import { TruncatedText } from "src/components/Shared/TruncatedText"; + +const CLASSNAME = "StudioScrapeModal"; +const CLASSNAME_LIST = `${CLASSNAME}-list`; +const CLASSNAME_LIST_CONTAINER = `${CLASSNAME_LIST}-container`; + +interface IStudioSearchResultDetailsProps { + studio: GQL.ScrapedStudioDataFragment; +} + +const StudioSearchResultDetails: React.FC = ({ + studio, +}) => { + function renderImage() { + if (studio.image) { + return ( +
+ +
+ ); + } + } + + return ( +
+ + {renderImage()} +
+

+ {studio.name} +

+ {studio.parent?.name && ( +
+ {studio.parent.name} +
+ )} + {studio.urls && studio.urls.length > 0 && ( +
{studio.urls[0]}
+ )} +
+
+ {studio.details && ( + +
+ +
+
+ )} +
+ ); +}; + +export interface IStudioSearchResult { + studio: GQL.ScrapedStudioDataFragment; +} + +export const StudioSearchResult: React.FC = ({ + studio, +}) => { + return ( +
+ +
+ ); +}; + +export interface IStashBox extends GQL.StashBox { + index: number; +} + +interface IProps { + instance: IStashBox; + onHide: () => void; + onSelectStudio: (studio: GQL.ScrapedStudio) => void; + name?: string; +} + +const StudioStashBoxModal: React.FC = ({ + instance, + name, + onHide, + onSelectStudio, +}) => { + const intl = useIntl(); + const inputRef = useRef(null); + const [query, setQuery] = useState(name ?? ""); + const { data, loading } = GQL.useScrapeSingleStudioQuery({ + variables: { + source: { + stash_box_endpoint: instance.endpoint, + }, + input: { + query, + }, + }, + skip: query === "", + }); + + const studios = data?.scrapeSingleStudio ?? []; + + const onInputChange = useDebounce(setQuery, 500); + + useEffect(() => inputRef.current?.focus(), []); + + function renderResults() { + if (!studios) { + return; + } + + return ( +
+
+ +
+
    + {studios.map((s, i) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key +
  • onSelectStudio(s)}> + +
  • + ))} +
+
+ ); + } + + return ( + +
+ onInputChange(e.currentTarget.value)} + defaultValue={name ?? ""} + placeholder={intl.formatMessage({ id: "studio_name" }) + "..."} + className="text-input mb-4" + ref={inputRef} + /> + {loading ? ( +
+ +
+ ) : studios.length > 0 ? ( + renderResults() + ) : ( + query !== "" && ( +
+ +
+ ) + )} +
+
+ ); +}; + +export default StudioStashBoxModal; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index f165c03cd..40b1c14e9 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1491,6 +1491,8 @@ "studio_and_parent": "Studio & Parent", "studio_count": "Studio Count", "studio_depth": "Levels (empty for all)", + "studio_image": "Studio Image", + "studio_name": "Studio name", "studio_tagger": { "add_new_studios": "Add New Studios", "any_names_entered_will_be_queried": "Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.",