diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 597cbad1c..55bd20910 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -34,8 +34,9 @@ import { useConfigurationContext } from "src/hooks/Config"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal"; +import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; import cx from "classnames"; -import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; +import { faSyncAlt, faPlus } from "@fortawesome/free-solid-svg-icons"; import isEqual from "lodash-es/isEqual"; import { formikUtils } from "src/utils/form"; import { @@ -88,6 +89,8 @@ export const PerformerEditPanel: React.FC = ({ // Editing state const [scraper, setScraper] = useState(); const [isScraperModalOpen, setIsScraperModalOpen] = useState(false); + const [isStashIDSearchOpen, setIsStashIDSearchOpen] = + useState(false); // Network state const [isLoading, setIsLoading] = useState(false); @@ -569,6 +572,27 @@ export const PerformerEditPanel: React.FC = ({ setScraper(undefined); } + function onStashIDSelected(item?: GQL.StashIdInput) { + if (!item) return; + + // Check if StashID with this endpoint already exists + const existingIndex = formik.values.stash_ids.findIndex( + (s) => s.endpoint === item.endpoint + ); + + let newStashIDs; + if (existingIndex >= 0) { + // Replace existing StashID + newStashIDs = [...formik.values.stash_ids]; + newStashIDs[existingIndex] = item; + } else { + // Add new StashID + newStashIDs = [...formik.values.stash_ids, item]; + } + + formik.setFieldValue("stash_ids", newStashIDs); + } + function renderButtons(classNames: string) { return (
@@ -659,6 +683,18 @@ export const PerformerEditPanel: React.FC = ({ <> {renderScrapeModal()} {maybeRenderScrapeDialog()} + {isStashIDSearchOpen && ( + s.endpoint + )} + onSelectItem={(item) => { + onStashIDSelected(item); + setIsStashIDSearchOpen(false); + }} + /> + )} = ({ {renderInputField("details", "textarea")} {renderTagsField()} - {renderStashIDsField("stash_ids", "performers")} + {renderStashIDsField( + "stash_ids", + "performers", + "stash_ids", + undefined, + + )}
diff --git a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx new file mode 100644 index 000000000..0b11a6d25 --- /dev/null +++ b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx @@ -0,0 +1,315 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Form, Button, Row, Col, Badge, InputGroup } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { faSearch } from "@fortawesome/free-solid-svg-icons"; +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 { TruncatedText } from "src/components/Shared/TruncatedText"; +import TextUtils from "src/utils/text"; +import GenderIcon from "src/components/Performers/GenderIcon"; +import { CountryFlag } from "src/components/Shared/CountryFlag"; +import { Icon } from "src/components/Shared/Icon"; +import { stashBoxPerformerQuery } from "src/core/StashService"; +import { useToast } from "src/hooks/Toast"; +import { stringToGender } from "src/utils/gender"; + +interface IProps { + stashBoxes: GQL.StashBox[]; + excludedStashBoxEndpoints?: string[]; + onSelectItem: (item?: GQL.StashIdInput) => void; +} + +const CLASSNAME = "StashBoxIDSearchModal"; +const CLASSNAME_LIST = `${CLASSNAME}-list`; +const CLASSNAME_LIST_CONTAINER = `${CLASSNAME_LIST}-container`; + +interface IHasRemoteSiteID { + remote_site_id?: string | null; +} + +// Shared component for rendering images +const SearchResultImage: React.FC<{ imageUrl?: string | null }> = ({ + imageUrl, +}) => { + if (!imageUrl) return null; + + return ( +
+ +
+ ); +}; + +// Shared component for rendering tags +const SearchResultTags: React.FC<{ + tags?: GQL.ScrapedTag[] | null; +}> = ({ tags }) => { + if (!tags || tags.length === 0) return null; + + return ( + + + {tags.map((tag) => ( + + {tag.name} + + ))} + + + ); +}; + +// Performer Result Component +interface IPerformerResultProps { + performer: GQL.ScrapedPerformerDataFragment; +} + +const PerformerSearchResultDetails: React.FC = ({ + performer, +}) => { + const age = performer?.birthdate + ? TextUtils.age(performer.birthdate, performer.death_date) + : undefined; + + return ( +
+ + +
+

+ {performer.name} + {performer.disambiguation && ( + + {` (${performer.disambiguation})`} + + )} +

+
+ {performer.gender && ( + + + + )} + {age && ( + + {`${age} `} + + + )} +
+ {performer.country && ( + + + + )} +
+
+ + + + + + +
+ ); +}; + +export const PerformerSearchResult: React.FC = ({ + performer, +}) => { + return ( +
+ +
+ ); +}; + +// Main Modal Component +export const StashBoxIDSearchModal: React.FC = ({ + stashBoxes, + excludedStashBoxEndpoints = [], + onSelectItem, +}) => { + const intl = useIntl(); + const Toast = useToast(); + const inputRef = useRef(null); + + const [selectedStashBox, setSelectedStashBox] = useState( + null + ); + const [query, setQuery] = useState(""); + const [results, setResults] = useState< + GQL.ScrapedPerformerDataFragment[] | undefined + >(undefined); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (stashBoxes.length > 0) { + setSelectedStashBox(stashBoxes[0]); + } + }, [stashBoxes]); + + useEffect(() => inputRef.current?.focus(), []); + + const doSearch = useCallback(async () => { + if (!selectedStashBox || !query) { + return; + } + + setLoading(true); + setResults([]); + + try { + const queryData = await stashBoxPerformerQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSinglePerformer ?? []); + } catch (error) { + Toast.error(error); + } finally { + setLoading(false); + } + }, [query, selectedStashBox, Toast]); + + function handleItemClick(item: IHasRemoteSiteID) { + if (selectedStashBox && item.remote_site_id) { + onSelectItem({ + endpoint: selectedStashBox.endpoint, + stash_id: item.remote_site_id, + }); + } else { + onSelectItem(undefined); + } + } + + function handleClose() { + onSelectItem(undefined); + } + + function renderResults() { + if (!results || results.length === 0) { + return null; + } + + return ( +
+
+ +
+
    + {results.map((item, i) => ( +
  • handleItemClick(item)}> + +
  • + ))} +
+
+ ); + } + + return ( + +
+ + + + + { + const box = stashBoxes.find( + (b) => b.endpoint === e.currentTarget.value + ); + if (box) { + setSelectedStashBox(box); + } + }} + > + {stashBoxes.map((box, index) => ( + + ))} + + + + {selectedStashBox && + excludedStashBoxEndpoints.includes(selectedStashBox.endpoint) && ( + + + + )} + + + setQuery(e.currentTarget.value)} + value={query} + placeholder={intl.formatMessage( + { id: "stashbox_search.placeholder_name_or_id" }, + { entityType: "Performer" } + )} + className="text-input" + ref={inputRef} + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && doSearch() + } + /> + + + + + + {loading ? ( +
+ +
+ ) : results && results.length > 0 ? ( + renderResults() + ) : ( + results !== undefined && + results.length === 0 && ( +
+ +
+ ) + )} +
+
+ ); +}; + +export default StashBoxIDSearchModal; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 2da24774b..9985138e0 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -1046,3 +1046,44 @@ input[type="range"].double-range-slider-max { background: transparent; } } + +// Label offset for buttons that need to align with form fields +.ml-label { + @include media-breakpoint-up(sm) { + // sm: label is 3 of 12 columns = 25%, plus partial gutter + margin-left: calc(25% + 7.5px); + } + @include media-breakpoint-up(xl) { + // xl: label is 2 of 12 columns = 16.667%, plus partial gutter + margin-left: calc(16.667% + 7.5px); + } +} + +// StashBox Search Modal +.StashBoxSearchModal { + &-list { + list-style: none; + padding: 0; + + li { + border-radius: 0.25rem; + cursor: pointer; + margin-bottom: 0.5rem; + padding: 0.5rem; + transition: background-color 0.2s; + + &:hover { + background-color: rgba(138, 155, 168, 0.1); + } + + &.selected { + background-color: #e7f3ff; + } + } + } + + &-list-container { + max-height: 60vh; + overflow-y: auto; + } +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 100f25199..e69d988bf 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2313,6 +2313,22 @@ export const stashBoxStudioQuery = ( fetchPolicy: "network-only", }); +export const stashBoxSceneQuery = (query: string, stashBoxEndpoint: string) => + client.query( + { + query: GQL.ScrapeSingleSceneDocument, + variables: { + source: { + stash_box_endpoint: stashBoxEndpoint, + }, + input: { + query: query, + }, + }, + fetchPolicy: "network-only", + } + ); + export const mutateStashBoxBatchPerformerTag = ( input: GQL.StashBoxBatchTagInput ) => diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 28d16e486..e0f49aa3c 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -7,6 +7,7 @@ "add_sub_groups": "Add Sub-Groups", "add_o": "Add O", "add_play": "Add play", + "add_stash_id": "Add Stash ID", "add_to_entity": "Add to {entityType}", "allow": "Allow", "allow_temporarily": "Allow temporarily", @@ -967,6 +968,7 @@ "overwrite_filter_warning": "Saved filter \"{entityName}\" will be overwritten.", "performers_found": "{count} performers found", "reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}", + "stashid_exists_warning": "The existing stash id for this stash-box will be replaced.", "reassign_files": { "destination": "Reassign to" }, @@ -1451,6 +1453,12 @@ "stash_id": "Stash ID", "stash_id_endpoint": "Stash ID Endpoint", "stash_ids": "Stash IDs", + "stashbox_search": { + "header": "Search {entityType} from StashBox", + "no_results": "No results found.", + "placeholder_name_or_id": "{entityType} name or StashID...", + "select_stashbox": "Select StashBox..." + }, "stashbox": { "go_review_draft": "Go to {endpoint_name} to review draft.", "selected_stash_box": "Selected Stash-Box endpoint", diff --git a/ui/v2.5/src/utils/form.tsx b/ui/v2.5/src/utils/form.tsx index 1fef74cf2..9798efd19 100644 --- a/ui/v2.5/src/utils/form.tsx +++ b/ui/v2.5/src/utils/form.tsx @@ -362,12 +362,10 @@ export function formikUtils( field: Field, linkType: LinkType, messageID: string = field, - props?: IProps + props?: IProps, + addButton?: React.ReactNode ) { const values = formik.values[field] as GQL.StashIdInput[]; - if (!values.length) { - return; - } const title = intl.formatMessage({ id: messageID }); @@ -377,26 +375,31 @@ export function formikUtils( }; const control = ( -
    - {values.map((stashID) => { - return ( - - - - - ); - })} -
+ <> + {values.length > 0 && ( +
    + {values.map((stashID) => { + return ( + + + + + ); + })} +
+ )} + {addButton} + ); return renderField(field, title, control, props);