mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44: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 { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user