mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 21:04:37 +03:00
Refactor scraped image selector (#3989)
* Place image selector above image * Internationalise loading indicator * Separate and refactor image selector
This commit is contained in:
102
ui/v2.5/src/components/Shared/ImageSelector.tsx
Normal file
102
ui/v2.5/src/components/Shared/ImageSelector.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { LoadingIndicator } from "./LoadingIndicator";
|
||||||
|
import { Button } from "react-bootstrap";
|
||||||
|
import { faArrowLeft, faArrowRight } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
|
interface IImageSelectorProps {
|
||||||
|
imageClassName?: string;
|
||||||
|
images: string[];
|
||||||
|
imageIndex: number;
|
||||||
|
setImageIndex: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageSelector: React.FC<IImageSelectorProps> = ({
|
||||||
|
imageClassName,
|
||||||
|
images,
|
||||||
|
imageIndex,
|
||||||
|
setImageIndex,
|
||||||
|
}) => {
|
||||||
|
const [imageState, setImageState] = useState<
|
||||||
|
"loading" | "error" | "loaded" | "empty"
|
||||||
|
>("empty");
|
||||||
|
const [loadDict, setLoadDict] = useState<Record<number, boolean>>({});
|
||||||
|
const [currentImage, setCurrentImage] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (imageState !== "loading") {
|
||||||
|
setCurrentImage(images[imageIndex]);
|
||||||
|
}
|
||||||
|
}, [imageState, imageIndex, images]);
|
||||||
|
|
||||||
|
const changeImage = (index: number) => {
|
||||||
|
setImageIndex(index);
|
||||||
|
if (!loadDict[index]) setImageState("loading");
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPrev = () =>
|
||||||
|
changeImage(imageIndex === 0 ? images.length - 1 : imageIndex - 1);
|
||||||
|
const setNext = () =>
|
||||||
|
changeImage(imageIndex === images.length - 1 ? 0 : imageIndex + 1);
|
||||||
|
|
||||||
|
const handleLoad = (index: number) => {
|
||||||
|
setLoadDict({
|
||||||
|
...loadDict,
|
||||||
|
[index]: true,
|
||||||
|
});
|
||||||
|
setImageState("loaded");
|
||||||
|
};
|
||||||
|
const handleError = () => setImageState("error");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-selection">
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className="select-buttons">
|
||||||
|
<Button onClick={setPrev} disabled={images.length === 1}>
|
||||||
|
<Icon icon={faArrowLeft} />
|
||||||
|
</Button>
|
||||||
|
<h5 className="image-index">
|
||||||
|
<FormattedMessage
|
||||||
|
id="index_of_total"
|
||||||
|
values={{
|
||||||
|
index: imageIndex + 1,
|
||||||
|
total: images.length,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</h5>
|
||||||
|
<Button onClick={setNext} disabled={images.length === 1}>
|
||||||
|
<Icon icon={faArrowRight} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="performer-image">
|
||||||
|
{/* hidden image to handle loading */}
|
||||||
|
<img
|
||||||
|
src={images[imageIndex]}
|
||||||
|
className="d-none"
|
||||||
|
onLoad={() => handleLoad(imageIndex)}
|
||||||
|
onError={handleError}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src={currentImage}
|
||||||
|
className={cx(imageClassName, { loading: imageState === "loading" })}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
{imageState === "loading" && <LoadingIndicator />}
|
||||||
|
{imageState === "error" && (
|
||||||
|
<div className="h-100 d-flex justify-content-center align-items-center">
|
||||||
|
<b>
|
||||||
|
<FormattedMessage
|
||||||
|
id="errors.loading_type"
|
||||||
|
values={{ type: "image" }}
|
||||||
|
/>
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Spinner } from "react-bootstrap";
|
import { Spinner } from "react-bootstrap";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
|
||||||
interface ILoadingProps {
|
interface ILoadingProps {
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -17,13 +18,19 @@ export const LoadingIndicator: React.FC<ILoadingProps> = ({
|
|||||||
inline = false,
|
inline = false,
|
||||||
small = false,
|
small = false,
|
||||||
card = false,
|
card = false,
|
||||||
}) => (
|
}) => {
|
||||||
<div className={cx(CLASSNAME, { inline, small, "card-based": card })}>
|
const intl = useIntl();
|
||||||
<Spinner animation="border" role="status" size={small ? "sm" : undefined}>
|
|
||||||
<span className="sr-only">Loading...</span>
|
const text = intl.formatMessage({ id: "loading.generic" });
|
||||||
</Spinner>
|
|
||||||
{message !== "" && (
|
return (
|
||||||
<h4 className={CLASSNAME_MESSAGE}>{message ?? "Loading..."}</h4>
|
<div className={cx(CLASSNAME, { inline, small, "card-based": card })}>
|
||||||
)}
|
<Spinner animation="border" role="status" size={small ? "sm" : undefined}>
|
||||||
</div>
|
<span className="sr-only">{text}</span>
|
||||||
);
|
</Spinner>
|
||||||
|
{message !== "" && (
|
||||||
|
<h4 className={CLASSNAME_MESSAGE}>{message ?? text}</h4>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import isEqual from "lodash-es/isEqual";
|
|||||||
import clone from "lodash-es/clone";
|
import clone from "lodash-es/clone";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import {
|
import {
|
||||||
faArrowLeft,
|
|
||||||
faArrowRight,
|
|
||||||
faCheck,
|
faCheck,
|
||||||
faPencilAlt,
|
faPencilAlt,
|
||||||
faPlus,
|
faPlus,
|
||||||
@@ -25,6 +23,7 @@ import {
|
|||||||
import { getCountryByISO } from "src/utils/country";
|
import { getCountryByISO } from "src/utils/country";
|
||||||
import { CountrySelect } from "./CountrySelect";
|
import { CountrySelect } from "./CountrySelect";
|
||||||
import { StringListInput } from "./StringListInput";
|
import { StringListInput } from "./StringListInput";
|
||||||
|
import { ImageSelector } from "./ImageSelector";
|
||||||
|
|
||||||
export class ScrapeResult<T> {
|
export class ScrapeResult<T> {
|
||||||
public newValue?: T;
|
public newValue?: T;
|
||||||
@@ -443,135 +442,6 @@ export const ScrapedImageRow: React.FC<IScrapedImageRowProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IScrapedImageDialogRowProps<
|
|
||||||
T extends ScrapeResult<string>,
|
|
||||||
V extends IHasName
|
|
||||||
> extends IScrapedFieldProps<string> {
|
|
||||||
title: string;
|
|
||||||
renderOriginalField: () => JSX.Element | undefined;
|
|
||||||
renderNewField: () => JSX.Element | undefined;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
newValues?: V[];
|
|
||||||
images: string[];
|
|
||||||
onCreateNew?: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ScrapeImageDialogRow = <
|
|
||||||
T extends ScrapeResult<string>,
|
|
||||||
V extends IHasName
|
|
||||||
>(
|
|
||||||
props: IScrapedImageDialogRowProps<T, V>
|
|
||||||
) => {
|
|
||||||
const [imageIndex, setImageIndex] = useState(0);
|
|
||||||
|
|
||||||
function hasNewValues() {
|
|
||||||
return props.newValues && props.newValues.length > 0 && props.onCreateNew;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPrev() {
|
|
||||||
let newIdx = imageIndex - 1;
|
|
||||||
if (newIdx < 0) {
|
|
||||||
newIdx = props.images.length - 1;
|
|
||||||
}
|
|
||||||
const ret = props.result.cloneWithValue(props.images[newIdx]);
|
|
||||||
props.onChange(ret as T);
|
|
||||||
setImageIndex(newIdx);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setNext() {
|
|
||||||
let newIdx = imageIndex + 1;
|
|
||||||
if (newIdx >= props.images.length) {
|
|
||||||
newIdx = 0;
|
|
||||||
}
|
|
||||||
const ret = props.result.cloneWithValue(props.images[newIdx]);
|
|
||||||
props.onChange(ret as T);
|
|
||||||
setImageIndex(newIdx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!props.result.scraped && !hasNewValues()) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSelector() {
|
|
||||||
return (
|
|
||||||
props.images.length > 1 && (
|
|
||||||
<div className="d-flex mt-2 image-selection">
|
|
||||||
<Button onClick={setPrev}>
|
|
||||||
<Icon icon={faArrowLeft} />
|
|
||||||
</Button>
|
|
||||||
<h5 className="flex-grow-1 px-2">
|
|
||||||
Select performer image
|
|
||||||
<br />
|
|
||||||
{imageIndex + 1} of {props.images.length}
|
|
||||||
</h5>
|
|
||||||
<Button onClick={setNext}>
|
|
||||||
<Icon icon={faArrowRight} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNewValues() {
|
|
||||||
if (!hasNewValues()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ret = (
|
|
||||||
<>
|
|
||||||
{props.newValues!.map((t, i) => (
|
|
||||||
<Badge
|
|
||||||
className="tag-item"
|
|
||||||
variant="secondary"
|
|
||||||
key={t.name}
|
|
||||||
onClick={() => props.onCreateNew!(i)}
|
|
||||||
>
|
|
||||||
{t.name}
|
|
||||||
<Button className="minimal ml-2">
|
|
||||||
<Icon className="fa-fw" icon={faPlus} />
|
|
||||||
</Button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const minCollapseLength = 10;
|
|
||||||
|
|
||||||
if (props.newValues!.length >= minCollapseLength) {
|
|
||||||
return (
|
|
||||||
<CollapseButton text={`Missing (${props.newValues!.length})`}>
|
|
||||||
{ret}
|
|
||||||
</CollapseButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row className="px-3 pt-3">
|
|
||||||
<Form.Label column lg="3">
|
|
||||||
{props.title}
|
|
||||||
</Form.Label>
|
|
||||||
|
|
||||||
<Col lg="9">
|
|
||||||
<Row>
|
|
||||||
<Col xs="6">
|
|
||||||
<InputGroup>{props.renderOriginalField()}</InputGroup>
|
|
||||||
</Col>
|
|
||||||
<Col xs="6">
|
|
||||||
<InputGroup>
|
|
||||||
{props.renderNewField()}
|
|
||||||
{renderSelector()}
|
|
||||||
</InputGroup>
|
|
||||||
{renderNewValues()}
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IScrapedImagesRowProps {
|
interface IScrapedImagesRowProps {
|
||||||
title: string;
|
title: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -581,11 +451,18 @@ interface IScrapedImagesRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ScrapedImagesRow: React.FC<IScrapedImagesRowProps> = (props) => {
|
export const ScrapedImagesRow: React.FC<IScrapedImagesRowProps> = (props) => {
|
||||||
|
const [imageIndex, setImageIndex] = useState(0);
|
||||||
|
|
||||||
|
function onSetImageIndex(newIdx: number) {
|
||||||
|
const ret = props.result.cloneWithValue(props.images[newIdx]);
|
||||||
|
props.onChange(ret);
|
||||||
|
setImageIndex(newIdx);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrapeImageDialogRow
|
<ScrapeDialogRow
|
||||||
title={props.title}
|
title={props.title}
|
||||||
result={props.result}
|
result={props.result}
|
||||||
images={props.images}
|
|
||||||
renderOriginalField={() => (
|
renderOriginalField={() => (
|
||||||
<ScrapedImage
|
<ScrapedImage
|
||||||
result={props.result}
|
result={props.result}
|
||||||
@@ -594,12 +471,14 @@ export const ScrapedImagesRow: React.FC<IScrapedImagesRowProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderNewField={() => (
|
renderNewField={() => (
|
||||||
<ScrapedImage
|
<div>
|
||||||
result={props.result}
|
<ImageSelector
|
||||||
className={props.className}
|
imageClassName={props.className}
|
||||||
placeholder={props.title}
|
images={props.images}
|
||||||
isNew
|
imageIndex={imageIndex}
|
||||||
/>
|
setImageIndex={onSetImageIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -135,10 +135,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrape-dialog .modal-content .dialog-container {
|
.scrape-dialog {
|
||||||
max-height: calc(100vh - 14rem);
|
.modal-content .dialog-container {
|
||||||
overflow-y: auto;
|
max-height: calc(100vh - 14rem);
|
||||||
padding-right: 15px;
|
overflow-y: auto;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-selection {
|
||||||
|
.select-buttons {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 1rem;
|
||||||
|
|
||||||
|
.image-index {
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.LoadingIndicator {
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button.collapse-button.btn-primary:not(:disabled):not(.disabled):hover,
|
button.collapse-button.btn-primary:not(:disabled):not(.disabled):hover,
|
||||||
|
|||||||
@@ -949,6 +949,7 @@
|
|||||||
},
|
},
|
||||||
"empty_server": "Add some scenes to your server to view recommendations on this page.",
|
"empty_server": "Add some scenes to your server to view recommendations on this page.",
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"loading_type": "Error loading {type}",
|
||||||
"image_index_greater_than_zero": "Image index must be greater than 0",
|
"image_index_greater_than_zero": "Image index must be greater than 0",
|
||||||
"lazy_component_error_help": "If you recently upgraded Stash, please reload the page or clear your browser cache.",
|
"lazy_component_error_help": "If you recently upgraded Stash, please reload the page or clear your browser cache.",
|
||||||
"something_went_wrong": "Something went wrong."
|
"something_went_wrong": "Something went wrong."
|
||||||
@@ -1013,6 +1014,7 @@
|
|||||||
"include_parent_tags": "Include parent tags",
|
"include_parent_tags": "Include parent tags",
|
||||||
"include_sub_studios": "Include subsidiary studios",
|
"include_sub_studios": "Include subsidiary studios",
|
||||||
"include_sub_tags": "Include sub-tags",
|
"include_sub_tags": "Include sub-tags",
|
||||||
|
"index_of_total": "{index} of {total}",
|
||||||
"instagram": "Instagram",
|
"instagram": "Instagram",
|
||||||
"interactive": "Interactive",
|
"interactive": "Interactive",
|
||||||
"interactive_speed": "Interactive speed",
|
"interactive_speed": "Interactive speed",
|
||||||
|
|||||||
Reference in New Issue
Block a user