Manually Search Stash ID - Edit Page (#6284)

This commit is contained in:
Gykes 2025-11-27 14:32:29 -06:00 committed by GitHub
parent 90d1b2df2d
commit e0c1d4c51d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 459 additions and 26 deletions

View file

@ -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<IPerformerDetails> = ({
// Editing state
const [scraper, setScraper] = useState<GQL.Scraper | IStashBox>();
const [isScraperModalOpen, setIsScraperModalOpen] = useState<boolean>(false);
const [isStashIDSearchOpen, setIsStashIDSearchOpen] =
useState<boolean>(false);
// Network state
const [isLoading, setIsLoading] = useState(false);
@ -569,6 +572,27 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
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 (
<div className={cx("details-edit", "col-xl-9", classNames)}>
@ -659,6 +683,18 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<>
{renderScrapeModal()}
{maybeRenderScrapeDialog()}
{isStashIDSearchOpen && (
<StashBoxIDSearchModal
stashBoxes={stashConfig?.general.stashBoxes ?? []}
excludedStashBoxEndpoints={formik.values.stash_ids.map(
(s) => s.endpoint
)}
onSelectItem={(item) => {
onStashIDSelected(item);
setIsStashIDSearchOpen(false);
}}
/>
)}
<Prompt
when={formik.dirty}
@ -701,7 +737,21 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{renderInputField("details", "textarea")}
{renderTagsField()}
{renderStashIDsField("stash_ids", "performers")}
{renderStashIDsField(
"stash_ids",
"performers",
"stash_ids",
undefined,
<Button
variant="success"
className="mr-2 py-0"
onClick={() => setIsStashIDSearchOpen(true)}
disabled={!stashConfig?.general.stashBoxes?.length}
title={intl.formatMessage({ id: "actions.add_stash_id" })}
>
<Icon icon={faPlus} />
</Button>
)}
<hr />

View file

@ -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 (
<div className="scene-image-container">
<img src={imageUrl} alt="" className="align-self-center scene-image" />
</div>
);
};
// Shared component for rendering tags
const SearchResultTags: React.FC<{
tags?: GQL.ScrapedTag[] | null;
}> = ({ tags }) => {
if (!tags || tags.length === 0) return null;
return (
<Row>
<Col>
{tags.map((tag) => (
<Badge className="tag-item" variant="secondary" key={tag.stored_id}>
{tag.name}
</Badge>
))}
</Col>
</Row>
);
};
// Performer Result Component
interface IPerformerResultProps {
performer: GQL.ScrapedPerformerDataFragment;
}
const PerformerSearchResultDetails: React.FC<IPerformerResultProps> = ({
performer,
}) => {
const age = performer?.birthdate
? TextUtils.age(performer.birthdate, performer.death_date)
: undefined;
return (
<div className="performer-result">
<Row>
<SearchResultImage imageUrl={performer.images?.[0]} />
<div className="col flex-column">
<h4 className="performer-name">
<span>{performer.name}</span>
{performer.disambiguation && (
<span className="performer-disambiguation">
{` (${performer.disambiguation})`}
</span>
)}
</h4>
<h5 className="performer-details">
{performer.gender && (
<span>
<GenderIcon
className="gender-icon"
gender={stringToGender(performer.gender, true)}
/>
</span>
)}
{age && (
<span>
{`${age} `}
<FormattedMessage id="years_old" />
</span>
)}
</h5>
{performer.country && (
<span>
<CountryFlag
className="performer-result__country-flag"
country={performer.country}
/>
</span>
)}
</div>
</Row>
<Row>
<Col>
<TruncatedText text={performer.details ?? ""} lineCount={3} />
</Col>
</Row>
<SearchResultTags tags={performer.tags} />
</div>
);
};
export const PerformerSearchResult: React.FC<IPerformerResultProps> = ({
performer,
}) => {
return (
<div className="mt-3 search-item" style={{ cursor: "pointer" }}>
<PerformerSearchResultDetails performer={performer} />
</div>
);
};
// Main Modal Component
export const StashBoxIDSearchModal: React.FC<IProps> = ({
stashBoxes,
excludedStashBoxEndpoints = [],
onSelectItem,
}) => {
const intl = useIntl();
const Toast = useToast();
const inputRef = useRef<HTMLInputElement>(null);
const [selectedStashBox, setSelectedStashBox] = useState<GQL.StashBox | null>(
null
);
const [query, setQuery] = useState<string>("");
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 (
<div className={CLASSNAME_LIST_CONTAINER}>
<div className="mt-1 mb-2">
<FormattedMessage
id="dialogs.performers_found"
values={{ count: results.length }}
/>
</div>
<ul className={CLASSNAME_LIST} style={{ listStyleType: "none" }}>
{results.map((item, i) => (
<li key={i} onClick={() => handleItemClick(item)}>
<PerformerSearchResult performer={item} />
</li>
))}
</ul>
</div>
);
}
return (
<ModalComponent
show
onHide={handleClose}
header={intl.formatMessage(
{ id: "stashbox_search.header" },
{ entityType: "Performer" }
)}
accept={{
text: intl.formatMessage({ id: "actions.cancel" }),
onClick: handleClose,
variant: "secondary",
}}
>
<div className={CLASSNAME}>
<Form.Group className="d-flex align-items-center mb-3">
<Form.Label className="mb-0 mr-2" style={{ flexShrink: 0 }}>
<FormattedMessage id="stashbox.source" />
</Form.Label>
<Form.Control
as="select"
className="input-control"
style={{ flex: "0 1 auto" }}
value={selectedStashBox?.endpoint ?? ""}
onChange={(e) => {
const box = stashBoxes.find(
(b) => b.endpoint === e.currentTarget.value
);
if (box) {
setSelectedStashBox(box);
}
}}
>
{stashBoxes.map((box, index) => (
<option key={box.endpoint} value={box.endpoint}>
{stashboxDisplayName(box.name, index)}
</option>
))}
</Form.Control>
</Form.Group>
{selectedStashBox &&
excludedStashBoxEndpoints.includes(selectedStashBox.endpoint) && (
<span className="saved-filter-overwrite-warning mb-3 d-block">
<FormattedMessage id="dialogs.stashid_exists_warning" />
</span>
)}
<InputGroup>
<Form.Control
onChange={(e) => 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<HTMLInputElement>) =>
e.key === "Enter" && doSearch()
}
/>
<InputGroup.Append>
<Button
onClick={doSearch}
variant="primary"
disabled={!selectedStashBox}
title={intl.formatMessage({ id: "actions.search" })}
>
<Icon icon={faSearch} />
</Button>
</InputGroup.Append>
</InputGroup>
{loading ? (
<div className="m-4 text-center">
<LoadingIndicator inline />
</div>
) : results && results.length > 0 ? (
renderResults()
) : (
results !== undefined &&
results.length === 0 && (
<h5 className="text-center mt-4">
<FormattedMessage id="stashbox_search.no_results" />
</h5>
)
)}
</div>
</ModalComponent>
);
};
export default StashBoxIDSearchModal;

View file

@ -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;
}
}

View file

@ -2313,6 +2313,22 @@ export const stashBoxStudioQuery = (
fetchPolicy: "network-only",
});
export const stashBoxSceneQuery = (query: string, stashBoxEndpoint: string) =>
client.query<GQL.ScrapeSingleSceneQuery, GQL.ScrapeSingleSceneQueryVariables>(
{
query: GQL.ScrapeSingleSceneDocument,
variables: {
source: {
stash_box_endpoint: stashBoxEndpoint,
},
input: {
query: query,
},
},
fetchPolicy: "network-only",
}
);
export const mutateStashBoxBatchPerformerTag = (
input: GQL.StashBoxBatchTagInput
) =>

View file

@ -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",

View file

@ -362,12 +362,10 @@ export function formikUtils<V extends FormikValues>(
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,7 +375,9 @@ export function formikUtils<V extends FormikValues>(
};
const control = (
<ul className="pl-0 mb-0">
<>
{values.length > 0 && (
<ul className="pl-0 mb-2">
{values.map((stashID) => {
return (
<Row as="li" key={stashID.stash_id} noGutters>
@ -397,6 +397,9 @@ export function formikUtils<V extends FormikValues>(
);
})}
</ul>
)}
{addButton}
</>
);
return renderField(field, title, control, props);