mirror of
https://github.com/stashapp/stash.git
synced 2026-02-09 08:55:19 +01:00
Add studio image scraping feature
Implements comprehensive studio image scraping with: - StashBox integration via StudioStashBoxModal component - URL-based web scraping via StudioScrapeDialog component - Scraper menu integration in studio editor - Basic English localization (en-GB) This feature allows users to import studio images from: 1. StashBox search by studio name 2. Direct URL scraping via configured scrapers UI components include modal dialogs for search results and scraper output with image preview and import functionality.
This commit is contained in:
parent
9b709ef614
commit
3e5ac408fa
4 changed files with 567 additions and 20 deletions
|
|
@ -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<GQL.StudioDataFragment>;
|
||||
|
|
@ -45,7 +49,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
|
||||
const isNew = studio.id === undefined;
|
||||
|
||||
// Editing state
|
||||
// Editing/scraper state
|
||||
const [scraper, setScraper] = useState<IStashBox>();
|
||||
const [isScraperModalOpen, setIsScraperModalOpen] = useState(false);
|
||||
const [scrapedStudio, setScrapedStudio] = useState<GQL.ScrapedStudio>();
|
||||
const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false);
|
||||
|
||||
// Network state
|
||||
|
|
@ -86,8 +93,9 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
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<IStudioEditPanel> = ({
|
|||
);
|
||||
}
|
||||
|
||||
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<GQL.ScrapedStudioDataFragment>
|
||||
) {
|
||||
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 = (
|
||||
<Dropdown.Menu id="studio-scraper-popover">
|
||||
{stashBoxes.map((s, index) => (
|
||||
<Dropdown.Item
|
||||
as={Button}
|
||||
key={s.endpoint}
|
||||
className="minimal"
|
||||
onClick={() => onScraperSelected({ ...s, index })}
|
||||
>
|
||||
{stashboxDisplayName(s.name, index)}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown className="d-inline-block">
|
||||
<Dropdown.Toggle variant="secondary" className="mr-2">
|
||||
<FormattedMessage id="actions.scrape_with" />
|
||||
</Dropdown.Toggle>
|
||||
{popover}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function renderButtons(classNames: string) {
|
||||
return (
|
||||
<div className={cx("details-edit", "col-xl-9", classNames)}>
|
||||
{!isNew && (
|
||||
<Button className="mr-2" variant="primary" onClick={onCancel}>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
)}
|
||||
{renderScraperMenu()}
|
||||
<ImageInput
|
||||
isEditing
|
||||
onImageChange={onImageChange}
|
||||
onImageURL={onImageLoad}
|
||||
acceptSVG
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
className="mr-2"
|
||||
variant="danger"
|
||||
onClick={() => onImageLoad(null)}
|
||||
>
|
||||
<FormattedMessage id="actions.clear_image" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="success"
|
||||
disabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
|
||||
onClick={() => formik.submitForm()}
|
||||
>
|
||||
<FormattedMessage id="actions.save" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderScrapeDialog() {
|
||||
if (!scrapedStudio || !scraper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStudio = {
|
||||
...formik.values,
|
||||
image: formik.values.image ?? studio.image_path,
|
||||
};
|
||||
|
||||
return (
|
||||
<StudioScrapeDialog
|
||||
studio={currentStudio}
|
||||
studioTags={tags}
|
||||
scraped={scrapedStudio}
|
||||
scraper={scraper}
|
||||
onClose={(s) => {
|
||||
onScrapeDialogClosed(s);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function onScrapeDialogClosed(s?: GQL.ScrapedStudioDataFragment) {
|
||||
if (s) {
|
||||
updateStudioEditStateFromScraper(s);
|
||||
}
|
||||
setScrapedStudio(undefined);
|
||||
setScraper(undefined);
|
||||
}
|
||||
|
||||
function renderScrapeModal() {
|
||||
if (!isScraperModalOpen || !scraper) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioStashBoxModal
|
||||
instance={scraper}
|
||||
onHide={() => setScraper(undefined)}
|
||||
onSelectStudio={onScrapeStashBox}
|
||||
name={formik.values.name || ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
renderField,
|
||||
renderInputField,
|
||||
|
|
@ -189,6 +380,8 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{renderScrapeModal()}
|
||||
{maybeRenderScrapeDialog()}
|
||||
{isStashIDSearchOpen && (
|
||||
<StashBoxIDSearchModal
|
||||
entityType="studio"
|
||||
|
|
@ -241,20 +434,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
{renderInputField("ignore_auto_tag", "checkbox")}
|
||||
</Form>
|
||||
|
||||
<DetailsEditNavbar
|
||||
objectName={studio?.name ?? intl.formatMessage({ id: "studio" })}
|
||||
classNames="col-xl-9 mt-3"
|
||||
isNew={isNew}
|
||||
isEditing
|
||||
onToggleEdit={onCancel}
|
||||
onSave={formik.handleSubmit}
|
||||
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
|
||||
onImageChange={onImageChange}
|
||||
onImageChangeURL={onImageLoad}
|
||||
onClearImage={() => onImageLoad(null)}
|
||||
onDelete={onDelete}
|
||||
acceptSVG
|
||||
/>
|
||||
{renderButtons("mt-3")}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<GQL.StudioUpdateInput>;
|
||||
studioTags: Tag[];
|
||||
scraped: GQL.ScrapedStudio;
|
||||
scraper: IStashBox;
|
||||
onClose: (scrapedStudio?: GQL.ScrapedStudio) => void;
|
||||
}
|
||||
|
||||
export const StudioScrapeDialog: React.FC<IStudioScrapeDialogProps> = ({
|
||||
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<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(studio.name, scraped.name)
|
||||
);
|
||||
|
||||
const [urls, setURLs] = useState<ScrapeResult<string[]>>(
|
||||
new ScrapeResult<string[]>(
|
||||
studio.urls,
|
||||
scraped.urls
|
||||
? uniq((studio.urls ?? []).concat(scraped.urls ?? []))
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
|
||||
const [details, setDetails] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(studio.details, scraped.details)
|
||||
);
|
||||
|
||||
const [aliases, setAliases] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(
|
||||
studio.aliases?.join(", "),
|
||||
scraped.aliases
|
||||
)
|
||||
);
|
||||
|
||||
const [remoteSiteID, setRemoteSiteID] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(
|
||||
getCurrentRemoteSiteID(),
|
||||
scraped.remote_site_id
|
||||
)
|
||||
);
|
||||
|
||||
const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(
|
||||
studioTags,
|
||||
scraped.tags,
|
||||
endpoint
|
||||
);
|
||||
|
||||
const [image, setImage] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(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 (
|
||||
<>
|
||||
<ScrapedInputGroupRow
|
||||
field="name"
|
||||
title={intl.formatMessage({ id: "name" })}
|
||||
result={name}
|
||||
onChange={(value) => setName(value)}
|
||||
/>
|
||||
<ScrapedStringListRow
|
||||
field="urls"
|
||||
title={intl.formatMessage({ id: "urls" })}
|
||||
result={urls}
|
||||
onChange={(value) => setURLs(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
field="details"
|
||||
title={intl.formatMessage({ id: "details" })}
|
||||
result={details}
|
||||
onChange={(value) => setDetails(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
field="aliases"
|
||||
title={intl.formatMessage({ id: "aliases" })}
|
||||
result={aliases}
|
||||
onChange={(value) => setAliases(value)}
|
||||
/>
|
||||
{scrapedTagsRow}
|
||||
<ScrapedImageRow
|
||||
field="image"
|
||||
title={intl.formatMessage({ id: "studio_image" })}
|
||||
className="studio-image"
|
||||
result={image}
|
||||
onChange={(value) => setImage(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="remote_site_id"
|
||||
title={intl.formatMessage({ id: "stash_id" })}
|
||||
result={remoteSiteID}
|
||||
locked
|
||||
onChange={(value) => setRemoteSiteID(value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (linkDialog) {
|
||||
return linkDialog;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrapeDialog
|
||||
title={intl.formatMessage(
|
||||
{ id: "dialogs.scrape_entity_title" },
|
||||
{ entity_type: intl.formatMessage({ id: "studio" }) }
|
||||
)}
|
||||
onClose={(apply) => {
|
||||
onClose(apply ? makeNewScrapedItem() : undefined);
|
||||
}}
|
||||
>
|
||||
{renderScrapeRows()}
|
||||
</ScrapeDialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<IStudioSearchResultDetailsProps> = ({
|
||||
studio,
|
||||
}) => {
|
||||
function renderImage() {
|
||||
if (studio.image) {
|
||||
return (
|
||||
<div className="scene-image-container">
|
||||
<img
|
||||
src={studio.image}
|
||||
alt=""
|
||||
className="align-self-center scene-image"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="studio-result">
|
||||
<Row>
|
||||
{renderImage()}
|
||||
<div className="col flex-column">
|
||||
<h4 className="studio-name">
|
||||
<span>{studio.name}</span>
|
||||
</h4>
|
||||
{studio.parent?.name && (
|
||||
<h5 className="studio-parent text-muted">
|
||||
<span>{studio.parent.name}</span>
|
||||
</h5>
|
||||
)}
|
||||
{studio.urls && studio.urls.length > 0 && (
|
||||
<div className="studio-url text-muted small">{studio.urls[0]}</div>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
{studio.details && (
|
||||
<Row>
|
||||
<div className="col">
|
||||
<TruncatedText text={studio.details} lineCount={3} />
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface IStudioSearchResult {
|
||||
studio: GQL.ScrapedStudioDataFragment;
|
||||
}
|
||||
|
||||
export const StudioSearchResult: React.FC<IStudioSearchResult> = ({
|
||||
studio,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mt-3 search-item">
|
||||
<StudioSearchResultDetails studio={studio} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<IProps> = ({
|
||||
instance,
|
||||
name,
|
||||
onHide,
|
||||
onSelectStudio,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState<string>(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 (
|
||||
<div className={CLASSNAME_LIST_CONTAINER}>
|
||||
<div className="mt-1">
|
||||
<FormattedMessage
|
||||
id="dialogs.studios_found"
|
||||
values={{ count: studios.length }}
|
||||
/>
|
||||
</div>
|
||||
<ul className={CLASSNAME_LIST}>
|
||||
{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
|
||||
<li key={i} onClick={() => onSelectStudio(s)}>
|
||||
<StudioSearchResult studio={s} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
onHide={onHide}
|
||||
header={`Scrape studio from ${stashboxDisplayName(
|
||||
instance.name,
|
||||
instance.index
|
||||
)}`}
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
onClick: onHide,
|
||||
variant: "secondary",
|
||||
}}
|
||||
>
|
||||
<div className={CLASSNAME}>
|
||||
<Form.Control
|
||||
onChange={(e) => onInputChange(e.currentTarget.value)}
|
||||
defaultValue={name ?? ""}
|
||||
placeholder={intl.formatMessage({ id: "studio_name" }) + "..."}
|
||||
className="text-input mb-4"
|
||||
ref={inputRef}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="m-4 text-center">
|
||||
<LoadingIndicator inline />
|
||||
</div>
|
||||
) : studios.length > 0 ? (
|
||||
renderResults()
|
||||
) : (
|
||||
query !== "" && (
|
||||
<h5 className="text-center">
|
||||
<FormattedMessage id="stashbox_search.no_results" />
|
||||
</h5>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioStashBoxModal;
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue