Refactor scraped image selector (#3989)

* Place image selector above image
* Internationalise loading indicator
* Separate and refactor image selector
This commit is contained in:
WithoutPants
2023-08-02 16:15:56 +10:00
committed by GitHub
parent 107d1113e5
commit 3ea233dc06
5 changed files with 169 additions and 153 deletions

View 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>
);
};

View File

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

View File

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

View File

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

View File

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