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 { Spinner } from "react-bootstrap";
import cx from "classnames";
import { useIntl } from "react-intl";
interface ILoadingProps {
message?: string;
@@ -17,13 +18,19 @@ export const LoadingIndicator: React.FC<ILoadingProps> = ({
inline = false,
small = false,
card = false,
}) => (
<div className={cx(CLASSNAME, { inline, small, "card-based": card })}>
<Spinner animation="border" role="status" size={small ? "sm" : undefined}>
<span className="sr-only">Loading...</span>
</Spinner>
{message !== "" && (
<h4 className={CLASSNAME_MESSAGE}>{message ?? "Loading..."}</h4>
)}
</div>
);
}) => {
const intl = useIntl();
const text = intl.formatMessage({ id: "loading.generic" });
return (
<div className={cx(CLASSNAME, { inline, small, "card-based": card })}>
<Spinner animation="border" role="status" size={small ? "sm" : undefined}>
<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 { FormattedMessage, useIntl } from "react-intl";
import {
faArrowLeft,
faArrowRight,
faCheck,
faPencilAlt,
faPlus,
@@ -25,6 +23,7 @@ import {
import { getCountryByISO } from "src/utils/country";
import { CountrySelect } from "./CountrySelect";
import { StringListInput } from "./StringListInput";
import { ImageSelector } from "./ImageSelector";
export class ScrapeResult<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 {
title: string;
className?: string;
@@ -581,11 +451,18 @@ interface IScrapedImagesRowProps {
}
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 (
<ScrapeImageDialogRow
<ScrapeDialogRow
title={props.title}
result={props.result}
images={props.images}
renderOriginalField={() => (
<ScrapedImage
result={props.result}
@@ -594,12 +471,14 @@ export const ScrapedImagesRow: React.FC<IScrapedImagesRowProps> = (props) => {
/>
)}
renderNewField={() => (
<ScrapedImage
result={props.result}
className={props.className}
placeholder={props.title}
isNew
/>
<div>
<ImageSelector
imageClassName={props.className}
images={props.images}
imageIndex={imageIndex}
setImageIndex={onSetImageIndex}
/>
</div>
)}
onChange={props.onChange}
/>

View File

@@ -135,10 +135,36 @@
}
}
.scrape-dialog .modal-content .dialog-container {
max-height: calc(100vh - 14rem);
overflow-y: auto;
padding-right: 15px;
.scrape-dialog {
.modal-content .dialog-container {
max-height: calc(100vh - 14rem);
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,