Refactor and restyle scrape dialog on smaller viewports (#6387)

* Improve string-list-input styling
* Rename ScrapedDialog file
* Move ScrapeDialog into separate file
* Refactor scrape dialog row inputs
* Refactor new value handling
* Add context for labels
* Refactor scrape dialog to accept children
* Add existing/scraped labels for smaller viewports
This commit is contained in:
WithoutPants
2025-12-09 07:29:41 +11:00
committed by GitHub
parent 7db394bbea
commit 945d679158
11 changed files with 658 additions and 598 deletions

View File

@@ -2,11 +2,11 @@ import React, { useState } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
ScrapeDialog,
ScrapedInputGroupRow, ScrapedInputGroupRow,
ScrapedStringListRow, ScrapedStringListRow,
ScrapedTextAreaRow, ScrapedTextAreaRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow";
import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import { import {
ObjectListScrapeResult, ObjectListScrapeResult,
ObjectScrapeResult, ObjectScrapeResult,
@@ -225,10 +225,11 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
{ id: "dialogs.scrape_entity_title" }, { id: "dialogs.scrape_entity_title" },
{ entity_type: intl.formatMessage({ id: "gallery" }) } { entity_type: intl.formatMessage({ id: "gallery" }) }
)} )}
renderScrapeRows={renderScrapeRows}
onClose={(apply) => { onClose={(apply) => {
onClose(apply ? makeNewScrapedItem() : undefined); onClose(apply ? makeNewScrapedItem() : undefined);
}} }}
/> >
{renderScrapeRows()}
</ScrapeDialog>
); );
}; };

View File

@@ -2,12 +2,12 @@ import React, { useState } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
ScrapeDialog,
ScrapedInputGroupRow, ScrapedInputGroupRow,
ScrapedImageRow, ScrapedImageRow,
ScrapedTextAreaRow, ScrapedTextAreaRow,
ScrapedStringListRow, ScrapedStringListRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow";
import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { import {
ObjectScrapeResult, ObjectScrapeResult,
@@ -224,10 +224,11 @@ export const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({
{ id: "dialogs.scrape_entity_title" }, { id: "dialogs.scrape_entity_title" },
{ entity_type: intl.formatMessage({ id: "group" }) } { entity_type: intl.formatMessage({ id: "group" }) }
)} )}
renderScrapeRows={renderScrapeRows}
onClose={(apply) => { onClose={(apply) => {
onClose(apply ? makeNewScrapedItem() : undefined); onClose(apply ? makeNewScrapedItem() : undefined);
}} }}
/> >
{renderScrapeRows()}
</ScrapeDialog>
); );
}; };

View File

@@ -2,11 +2,11 @@ import React, { useState } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
ScrapeDialog,
ScrapedInputGroupRow, ScrapedInputGroupRow,
ScrapedStringListRow, ScrapedStringListRow,
ScrapedTextAreaRow, ScrapedTextAreaRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow";
import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import { import {
ObjectListScrapeResult, ObjectListScrapeResult,
ObjectScrapeResult, ObjectScrapeResult,
@@ -226,10 +226,11 @@ export const ImageScrapeDialog: React.FC<IImageScrapeDialogProps> = ({
{ id: "dialogs.scrape_entity_title" }, { id: "dialogs.scrape_entity_title" },
{ entity_type: intl.formatMessage({ id: "image" }) } { entity_type: intl.formatMessage({ id: "image" }) }
)} )}
renderScrapeRows={renderScrapeRows}
onClose={(apply) => { onClose={(apply) => {
onClose(apply ? makeNewScrapedItem() : undefined); onClose(apply ? makeNewScrapedItem() : undefined);
}} }}
/> >
{renderScrapeRows()}
</ScrapeDialog>
); );
}; };

View File

@@ -2,14 +2,14 @@ import React, { useState } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
ScrapeDialog,
ScrapedInputGroupRow, ScrapedInputGroupRow,
ScrapedImagesRow, ScrapedImagesRow,
ScrapeDialogRow, ScrapeDialogRow,
ScrapedTextAreaRow, ScrapedTextAreaRow,
ScrapedCountryRow, ScrapedCountryRow,
ScrapedStringListRow, ScrapedStringListRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow";
import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { import {
genderStrings, genderStrings,
@@ -66,12 +66,10 @@ function renderScrapedGenderRow(
field="gender" field="gender"
title={title} title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedGender(result)} originalField={renderScrapedGender(result)}
renderNewField={() => newField={renderScrapedGender(result, true, (value) =>
renderScrapedGender(result, true, (value) => onChange(result.cloneWithValue(value))
onChange(result.cloneWithValue(value)) )}
)
}
onChange={onChange} onChange={onChange}
/> />
); );
@@ -116,12 +114,10 @@ function renderScrapedCircumcisedRow(
title={title} title={title}
field="circumcised" field="circumcised"
result={result} result={result}
renderOriginalField={() => renderScrapedCircumcised(result)} originalField={renderScrapedCircumcised(result)}
renderNewField={() => newField={renderScrapedCircumcised(result, true, (value) =>
renderScrapedCircumcised(result, true, (value) => onChange(result.cloneWithValue(value))
onChange(result.cloneWithValue(value)) )}
)
}
onChange={onChange} onChange={onChange}
/> />
); );
@@ -552,10 +548,11 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
{ id: "dialogs.scrape_entity_title" }, { id: "dialogs.scrape_entity_title" },
{ entity_type: intl.formatMessage({ id: "performer" }) } { entity_type: intl.formatMessage({ id: "performer" }) }
)} )}
renderScrapeRows={renderScrapeRows}
onClose={(apply) => { onClose={(apply) => {
props.onClose(apply ? makeNewScrapedItem() : undefined); props.onClose(apply ? makeNewScrapedItem() : undefined);
}} }}
/> >
{renderScrapeRows()}
</ScrapeDialog>
); );
}; };

View File

@@ -1,12 +1,12 @@
import React, { useState } from "react"; import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
ScrapeDialog,
ScrapedInputGroupRow, ScrapedInputGroupRow,
ScrapedTextAreaRow, ScrapedTextAreaRow,
ScrapedImageRow, ScrapedImageRow,
ScrapedStringListRow, ScrapedStringListRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow";
import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { uniq } from "lodash-es"; import { uniq } from "lodash-es";
import { Performer } from "src/components/Performers/PerformerSelect"; import { Performer } from "src/components/Performers/PerformerSelect";
@@ -304,11 +304,12 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
{ id: "dialogs.scrape_entity_title" }, { id: "dialogs.scrape_entity_title" },
{ entity_type: intl.formatMessage({ id: "scene" }) } { entity_type: intl.formatMessage({ id: "scene" }) }
)} )}
renderScrapeRows={renderScrapeRows}
onClose={(apply) => { onClose={(apply) => {
onClose(apply ? makeNewScrapedItem() : undefined); onClose(apply ? makeNewScrapedItem() : undefined);
}} }}
/> >
{renderScrapeRows()}
</ScrapeDialog>
); );
}; };

View File

@@ -12,13 +12,13 @@ import { FormattedMessage, useIntl } from "react-intl";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
import { import {
ScrapeDialog,
ScrapeDialogRow, ScrapeDialogRow,
ScrapedImageRow, ScrapedImageRow,
ScrapedInputGroupRow, ScrapedInputGroupRow,
ScrapedStringListRow, ScrapedStringListRow,
ScrapedTextAreaRow, ScrapedTextAreaRow,
} from "../Shared/ScrapeDialog/ScrapeDialog"; } from "../Shared/ScrapeDialog/ScrapeDialogRow";
import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
import { clone, uniq } from "lodash-es"; import { clone, uniq } from "lodash-es";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { ModalComponent } from "../Shared/Modal"; import { ModalComponent } from "../Shared/Modal";
@@ -400,63 +400,59 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
field="rating" field="rating"
title={intl.formatMessage({ id: "rating" })} title={intl.formatMessage({ id: "rating" })}
result={rating} result={rating}
renderOriginalField={() => ( originalField={<RatingSystem value={rating.originalValue} disabled />}
<RatingSystem value={rating.originalValue} disabled /> newField={<RatingSystem value={rating.newValue} disabled />}
)}
renderNewField={() => (
<RatingSystem value={rating.newValue} disabled />
)}
onChange={(value) => setRating(value)} onChange={(value) => setRating(value)}
/> />
<ScrapeDialogRow <ScrapeDialogRow
field="o_count" field="o_count"
title={intl.formatMessage({ id: "o_count" })} title={intl.formatMessage({ id: "o_count" })}
result={oCounter} result={oCounter}
renderOriginalField={() => ( originalField={
<FormControl <FormControl
value={oCounter.originalValue ?? 0} value={oCounter.originalValue ?? 0}
readOnly readOnly
onChange={() => {}} onChange={() => {}}
className="bg-secondary text-white border-secondary" className="bg-secondary text-white border-secondary"
/> />
)} }
renderNewField={() => ( newField={
<FormControl <FormControl
value={oCounter.newValue ?? 0} value={oCounter.newValue ?? 0}
readOnly readOnly
onChange={() => {}} onChange={() => {}}
className="bg-secondary text-white border-secondary" className="bg-secondary text-white border-secondary"
/> />
)} }
onChange={(value) => setOCounter(value)} onChange={(value) => setOCounter(value)}
/> />
<ScrapeDialogRow <ScrapeDialogRow
field="play_count" field="play_count"
title={intl.formatMessage({ id: "play_count" })} title={intl.formatMessage({ id: "play_count" })}
result={playCount} result={playCount}
renderOriginalField={() => ( originalField={
<FormControl <FormControl
value={playCount.originalValue ?? 0} value={playCount.originalValue ?? 0}
readOnly readOnly
onChange={() => {}} onChange={() => {}}
className="bg-secondary text-white border-secondary" className="bg-secondary text-white border-secondary"
/> />
)} }
renderNewField={() => ( newField={
<FormControl <FormControl
value={playCount.newValue ?? 0} value={playCount.newValue ?? 0}
readOnly readOnly
onChange={() => {}} onChange={() => {}}
className="bg-secondary text-white border-secondary" className="bg-secondary text-white border-secondary"
/> />
)} }
onChange={(value) => setPlayCount(value)} onChange={(value) => setPlayCount(value)}
/> />
<ScrapeDialogRow <ScrapeDialogRow
field="play_duration" field="play_duration"
title={intl.formatMessage({ id: "play_duration" })} title={intl.formatMessage({ id: "play_duration" })}
result={playDuration} result={playDuration}
renderOriginalField={() => ( originalField={
<FormControl <FormControl
value={TextUtils.secondsToTimestamp( value={TextUtils.secondsToTimestamp(
playDuration.originalValue ?? 0 playDuration.originalValue ?? 0
@@ -465,22 +461,22 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
onChange={() => {}} onChange={() => {}}
className="bg-secondary text-white border-secondary" className="bg-secondary text-white border-secondary"
/> />
)} }
renderNewField={() => ( newField={
<FormControl <FormControl
value={TextUtils.secondsToTimestamp(playDuration.newValue ?? 0)} value={TextUtils.secondsToTimestamp(playDuration.newValue ?? 0)}
readOnly readOnly
onChange={() => {}} onChange={() => {}}
className="bg-secondary text-white border-secondary" className="bg-secondary text-white border-secondary"
/> />
)} }
onChange={(value) => setPlayDuration(value)} onChange={(value) => setPlayDuration(value)}
/> />
<ScrapeDialogRow <ScrapeDialogRow
field="galleries" field="galleries"
title={intl.formatMessage({ id: "galleries" })} title={intl.formatMessage({ id: "galleries" })}
result={galleries} result={galleries}
renderOriginalField={() => ( originalField={
<GallerySelect <GallerySelect
className="form-control react-select" className="form-control react-select"
ids={galleries.originalValue ?? []} ids={galleries.originalValue ?? []}
@@ -488,8 +484,8 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
isMulti isMulti
isDisabled isDisabled
/> />
)} }
renderNewField={() => ( newField={
<GallerySelect <GallerySelect
className="form-control react-select" className="form-control react-select"
ids={galleries.newValue ?? []} ids={galleries.newValue ?? []}
@@ -497,7 +493,7 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
isMulti isMulti
isDisabled isDisabled
/> />
)} }
onChange={(value) => setGalleries(value)} onChange={(value) => setGalleries(value)}
/> />
<ScrapedStudioRow <ScrapedStudioRow
@@ -535,34 +531,32 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
field="organized" field="organized"
title={intl.formatMessage({ id: "organized" })} title={intl.formatMessage({ id: "organized" })}
result={organized} result={organized}
renderOriginalField={() => ( originalField={
<FormControl <FormControl
value={organized.originalValue ? trueString : falseString} value={organized.originalValue ? trueString : falseString}
readOnly readOnly
onChange={() => {}} onChange={() => {}}
className="bg-secondary text-white border-secondary" className="bg-secondary text-white border-secondary"
/> />
)} }
renderNewField={() => ( newField={
<FormControl <FormControl
value={organized.newValue ? trueString : falseString} value={organized.newValue ? trueString : falseString}
readOnly readOnly
onChange={() => {}} onChange={() => {}}
className="bg-secondary text-white border-secondary" className="bg-secondary text-white border-secondary"
/> />
)} }
onChange={(value) => setOrganized(value)} onChange={(value) => setOrganized(value)}
/> />
<ScrapeDialogRow <ScrapeDialogRow
field="stash_ids" field="stash_ids"
title={intl.formatMessage({ id: "stash_id" })} title={intl.formatMessage({ id: "stash_id" })}
result={stashIDs} result={stashIDs}
renderOriginalField={() => ( originalField={
<StashIDsField values={stashIDs?.originalValue ?? []} /> <StashIDsField values={stashIDs?.originalValue ?? []} />
)} }
renderNewField={() => ( newField={<StashIDsField values={stashIDs?.newValue ?? []} />}
<StashIDsField values={stashIDs?.newValue ?? []} />
)}
onChange={(value) => setStashIDs(value)} onChange={(value) => setStashIDs(value)}
/> />
<ScrapedImageRow <ScrapedImageRow
@@ -634,7 +628,6 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
title={dialogTitle} title={dialogTitle}
existingLabel={destinationLabel} existingLabel={destinationLabel}
scrapedLabel={sourceLabel} scrapedLabel={sourceLabel}
renderScrapeRows={renderScrapeRows}
onClose={(apply) => { onClose={(apply) => {
if (!apply) { if (!apply) {
onClose(); onClose();
@@ -642,7 +635,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
onClose(createValues()); onClose(createValues());
} }
}} }}
/> >
{renderScrapeRows()}
</ScrapeDialog>
); );
}; };

View File

@@ -1,457 +1,55 @@
import React, { useState } from "react"; import React, { useMemo } from "react";
import { import { Form, Col, Row } from "react-bootstrap";
Form,
Col,
Row,
InputGroup,
Button,
FormControl,
Badge,
} from "react-bootstrap";
import { CollapseButton } from "../CollapseButton";
import { Icon } from "../Icon";
import { ModalComponent } from "../Modal"; import { ModalComponent } from "../Modal";
import clone from "lodash-es/clone";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
faCheck,
faPencilAlt,
faPlus,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { getCountryByISO } from "src/utils/country";
import { CountrySelect } from "../CountrySelect";
import { StringListInput } from "../StringListInput";
import { ImageSelector } from "../ImageSelector";
import { ScrapeResult } from "./scrapeResult";
import { useConfigurationContext } from "src/hooks/Config"; import { useConfigurationContext } from "src/hooks/Config";
interface IScrapedFieldProps<T> { export interface IScrapeDialogContextState {
result: ScrapeResult<T>; existingLabel?: React.ReactNode;
scrapedLabel?: React.ReactNode;
} }
interface IScrapedRowProps<T, V> extends IScrapedFieldProps<T> { export const ScrapeDialogContext =
className?: string; React.createContext<IScrapeDialogContextState>({});
field: string;
title: string;
renderOriginalField: (result: ScrapeResult<T>) => JSX.Element | undefined;
renderNewField: (result: ScrapeResult<T>) => JSX.Element | undefined;
onChange: (value: ScrapeResult<T>) => void;
newValues?: V[];
onCreateNew?: (index: number) => void;
getName?: (value: V) => string;
}
function renderButtonIcon(selected: boolean) {
const className = selected ? "text-success" : "text-muted";
return (
<Icon
className={`fa-fw ${className}`}
icon={selected ? faCheck : faTimes}
/>
);
}
export const ScrapeDialogRow = <T, V>(props: IScrapedRowProps<T, V>) => {
const { getName = () => "" } = props;
function handleSelectClick(isNew: boolean) {
const ret = clone(props.result);
ret.useNewValue = isNew;
props.onChange(ret);
}
function hasNewValues() {
return props.newValues && props.newValues.length > 0 && props.onCreateNew;
}
if (!props.result.scraped && !hasNewValues()) {
return <></>;
}
function renderNewValues() {
if (!hasNewValues()) {
return;
}
const ret = (
<>
{props.newValues!.map((t, i) => (
<Badge
className="tag-item"
variant="secondary"
key={getName(t)}
onClick={() => props.onCreateNew!(i)}
>
{getName(t)}
<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 ${props.className ?? ""}`}
data-field={props.field}
>
<Form.Label column lg="3">
{props.title}
</Form.Label>
<Col lg="9">
<Row>
<Col xs="6">
<InputGroup>
<InputGroup.Prepend className="bg-secondary text-white border-secondary">
<Button
variant="secondary"
onClick={() => handleSelectClick(false)}
>
{renderButtonIcon(!props.result.useNewValue)}
</Button>
</InputGroup.Prepend>
{props.renderOriginalField(props.result)}
</InputGroup>
</Col>
<Col xs="6">
<InputGroup>
<InputGroup.Prepend>
<Button
variant="secondary"
onClick={() => handleSelectClick(true)}
>
{renderButtonIcon(props.result.useNewValue)}
</Button>
</InputGroup.Prepend>
{props.renderNewField(props.result)}
</InputGroup>
{renderNewValues()}
</Col>
</Row>
</Col>
</Row>
);
};
interface IScrapedInputGroupProps {
isNew?: boolean;
placeholder?: string;
locked?: boolean;
result: ScrapeResult<string>;
onChange?: (value: string) => void;
}
const ScrapedInputGroup: React.FC<IScrapedInputGroupProps> = (props) => {
return (
<FormControl
placeholder={props.placeholder}
value={props.isNew ? props.result.newValue : props.result.originalValue}
readOnly={!props.isNew || props.locked}
onChange={(e) => {
if (props.isNew && props.onChange) {
props.onChange(e.target.value);
}
}}
className="bg-secondary text-white border-secondary"
/>
);
};
function getNameString(value: string) {
return value;
}
interface IScrapedInputGroupRowProps {
title: string;
field: string;
className?: string;
placeholder?: string;
result: ScrapeResult<string>;
locked?: boolean;
onChange: (value: ScrapeResult<string>) => void;
}
export const ScrapedInputGroupRow: React.FC<IScrapedInputGroupRowProps> = (
props
) => {
return (
<ScrapeDialogRow
title={props.title}
field={props.field}
className={props.className}
result={props.result}
renderOriginalField={() => (
<ScrapedInputGroup
placeholder={props.placeholder || props.title}
result={props.result}
/>
)}
renderNewField={() => (
<ScrapedInputGroup
placeholder={props.placeholder || props.title}
result={props.result}
isNew
locked={props.locked}
onChange={(value) =>
props.onChange(props.result.cloneWithValue(value))
}
/>
)}
onChange={props.onChange}
getName={getNameString}
/>
);
};
interface IScrapedStringListProps {
isNew?: boolean;
placeholder?: string;
locked?: boolean;
result: ScrapeResult<string[]>;
onChange?: (value: string[]) => void;
}
const ScrapedStringList: React.FC<IScrapedStringListProps> = (props) => {
const value = props.isNew
? props.result.newValue
: props.result.originalValue;
return (
<StringListInput
value={value ?? []}
setValue={(v) => {
if (props.isNew && props.onChange) {
props.onChange(v);
}
}}
placeholder={props.placeholder}
readOnly={!props.isNew || props.locked}
/>
);
};
interface IScrapedStringListRowProps {
title: string;
field: string;
placeholder?: string;
result: ScrapeResult<string[]>;
locked?: boolean;
onChange: (value: ScrapeResult<string[]>) => void;
}
export const ScrapedStringListRow: React.FC<IScrapedStringListRowProps> = (
props
) => {
return (
<ScrapeDialogRow
className="string-list-row"
title={props.title}
field={props.field}
result={props.result}
renderOriginalField={() => (
<ScrapedStringList
placeholder={props.placeholder || props.title}
result={props.result}
/>
)}
renderNewField={() => (
<ScrapedStringList
placeholder={props.placeholder || props.title}
result={props.result}
isNew
locked={props.locked}
onChange={(value) =>
props.onChange(props.result.cloneWithValue(value))
}
/>
)}
onChange={props.onChange}
getName={getNameString}
/>
);
};
const ScrapedTextArea: React.FC<IScrapedInputGroupProps> = (props) => {
return (
<FormControl
as="textarea"
placeholder={props.placeholder}
value={props.isNew ? props.result.newValue : props.result.originalValue}
readOnly={!props.isNew}
onChange={(e) => {
if (props.isNew && props.onChange) {
props.onChange(e.target.value);
}
}}
className="bg-secondary text-white border-secondary scene-description"
/>
);
};
export const ScrapedTextAreaRow: React.FC<IScrapedInputGroupRowProps> = (
props
) => {
return (
<ScrapeDialogRow
title={props.title}
field={props.field}
result={props.result}
renderOriginalField={() => (
<ScrapedTextArea
placeholder={props.placeholder || props.title}
result={props.result}
/>
)}
renderNewField={() => (
<ScrapedTextArea
placeholder={props.placeholder || props.title}
result={props.result}
isNew
onChange={(value) =>
props.onChange(props.result.cloneWithValue(value))
}
/>
)}
onChange={props.onChange}
getName={getNameString}
/>
);
};
interface IScrapedImageProps {
isNew?: boolean;
className?: string;
placeholder?: string;
result: ScrapeResult<string>;
}
const ScrapedImage: React.FC<IScrapedImageProps> = (props) => {
const value = props.isNew
? props.result.newValue
: props.result.originalValue;
if (!value) {
return <></>;
}
return (
<img className={props.className} src={value} alt={props.placeholder} />
);
};
interface IScrapedImageRowProps {
title: string;
field: string;
className?: string;
result: ScrapeResult<string>;
onChange: (value: ScrapeResult<string>) => void;
}
export const ScrapedImageRow: React.FC<IScrapedImageRowProps> = (props) => {
return (
<ScrapeDialogRow
title={props.title}
field={props.field}
result={props.result}
renderOriginalField={() => (
<ScrapedImage
result={props.result}
className={props.className}
placeholder={props.title}
/>
)}
renderNewField={() => (
<ScrapedImage
result={props.result}
className={props.className}
placeholder={props.title}
isNew
/>
)}
onChange={props.onChange}
getName={getNameString}
/>
);
};
interface IScrapedImagesRowProps {
title: string;
field: string;
className?: string;
result: ScrapeResult<string>;
images: string[];
onChange: (value: ScrapeResult<string>) => void;
}
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 (
<ScrapeDialogRow
title={props.title}
field={props.field}
result={props.result}
renderOriginalField={() => (
<ScrapedImage
result={props.result}
className={props.className}
placeholder={props.title}
/>
)}
renderNewField={() => (
<div className="image-selection-parent">
<ImageSelector
imageClassName={props.className}
images={props.images}
imageIndex={imageIndex}
setImageIndex={onSetImageIndex}
/>
</div>
)}
onChange={props.onChange}
getName={getNameString}
/>
);
};
interface IScrapeDialogProps { interface IScrapeDialogProps {
title: string; title: string;
existingLabel?: string; existingLabel?: React.ReactNode;
scrapedLabel?: string; scrapedLabel?: React.ReactNode;
renderScrapeRows: () => JSX.Element;
onClose: (apply?: boolean) => void; onClose: (apply?: boolean) => void;
} }
export const ScrapeDialog: React.FC<IScrapeDialogProps> = ( export const ScrapeDialog: React.FC<
props: IScrapeDialogProps React.PropsWithChildren<IScrapeDialogProps>
) => { > = (props: React.PropsWithChildren<IScrapeDialogProps>) => {
const intl = useIntl(); const intl = useIntl();
const { configuration } = useConfigurationContext(); const { configuration } = useConfigurationContext();
const { sfwContentMode } = configuration.interface; const { sfwContentMode } = configuration.interface;
const existingLabel = useMemo(
() =>
props.existingLabel ?? (
<FormattedMessage id="dialogs.scrape_results_existing" />
),
[props.existingLabel]
);
const scrapedLabel = useMemo(
() =>
props.scrapedLabel ?? (
<FormattedMessage id="dialogs.scrape_results_scraped" />
),
[props.scrapedLabel]
);
const contextState = useMemo(
() => ({
existingLabel: existingLabel,
scrapedLabel: scrapedLabel,
}),
[existingLabel, scrapedLabel]
);
return ( return (
<ModalComponent <ModalComponent
show show
@@ -474,76 +72,33 @@ export const ScrapeDialog: React.FC<IScrapeDialogProps> = (
}} }}
> >
<div className="dialog-container"> <div className="dialog-container">
<Form> <ScrapeDialogContext.Provider value={contextState}>
<Row className="px-3 pt-3"> <Form>
<Col lg={{ span: 9, offset: 3 }}> <Row className="px-3 pt-3">
<Row> <Col lg={{ span: 9, offset: 3 }}>
<Form.Label column xs="6"> <Row>
{props.existingLabel ?? ( <Form.Label
<FormattedMessage id="dialogs.scrape_results_existing" /> column
)} lg="6"
</Form.Label> className="d-lg-block d-none column-label"
<Form.Label column xs="6"> >
{props.scrapedLabel ?? ( {existingLabel}
<FormattedMessage id="dialogs.scrape_results_scraped" /> </Form.Label>
)} <Form.Label
</Form.Label> column
</Row> lg="6"
</Col> className="d-lg-block d-none column-label"
</Row> >
{scrapedLabel}
</Form.Label>
</Row>
</Col>
</Row>
{props.renderScrapeRows()} {props.children}
</Form> </Form>
</ScrapeDialogContext.Provider>
</div> </div>
</ModalComponent> </ModalComponent>
); );
}; };
interface IScrapedCountryRowProps {
title: string;
field: string;
result: ScrapeResult<string>;
onChange: (value: ScrapeResult<string>) => void;
locked?: boolean;
locale?: string;
}
export const ScrapedCountryRow: React.FC<IScrapedCountryRowProps> = ({
title,
field,
result,
onChange,
locked,
locale,
}) => (
<ScrapeDialogRow
title={title}
field={field}
result={result}
renderOriginalField={() => (
<FormControl
value={
getCountryByISO(result.originalValue, locale) ?? result.originalValue
}
readOnly
className="bg-secondary text-white border-secondary"
/>
)}
renderNewField={() => (
<CountrySelect
value={result.newValue}
disabled={locked}
onChange={(value) => {
if (onChange) {
onChange(result.cloneWithValue(value));
}
}}
showFlag={false}
isClearable={false}
className="flex-grow-1"
/>
)}
onChange={onChange}
getName={getNameString}
/>
);

View File

@@ -0,0 +1,433 @@
import React, { useContext, useState } from "react";
import {
Form,
Col,
Row,
InputGroup,
Button,
FormControl,
} from "react-bootstrap";
import { Icon } from "../Icon";
import clone from "lodash-es/clone";
import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons";
import { getCountryByISO } from "src/utils/country";
import { CountrySelect } from "../CountrySelect";
import { StringListInput } from "../StringListInput";
import { ImageSelector } from "../ImageSelector";
import { ScrapeResult } from "./scrapeResult";
import { ScrapeDialogContext } from "./ScrapeDialog";
function renderButtonIcon(selected: boolean) {
const className = selected ? "text-success" : "text-muted";
return (
<Icon
className={`fa-fw ${className}`}
icon={selected ? faCheck : faTimes}
/>
);
}
interface IScrapedFieldProps<T> {
result: ScrapeResult<T>;
}
interface IScrapedRowProps<T> extends IScrapedFieldProps<T> {
className?: string;
field: string;
title: string;
originalField: React.ReactNode;
newField: React.ReactNode;
onChange: (value: ScrapeResult<T>) => void;
newValues?: React.ReactNode;
}
export const ScrapeDialogRow = <T,>(props: IScrapedRowProps<T>) => {
const { existingLabel, scrapedLabel } = useContext(ScrapeDialogContext);
function handleSelectClick(isNew: boolean) {
const ret = clone(props.result);
ret.useNewValue = isNew;
props.onChange(ret);
}
if (!props.result.scraped && !props.newValues) {
return <></>;
}
return (
<Row
className={`px-3 pt-3 ${props.className ?? ""}`}
data-field={props.field}
>
<Form.Label column lg="3">
{props.title}
</Form.Label>
<Col lg="9">
<Row>
<Form.Label column className="d-lg-none column-label">
{existingLabel}
</Form.Label>
<Col lg="6">
<InputGroup>
<InputGroup.Prepend className="bg-secondary text-white border-secondary">
<Button
variant="secondary"
onClick={() => handleSelectClick(false)}
>
{renderButtonIcon(!props.result.useNewValue)}
</Button>
</InputGroup.Prepend>
{props.originalField}
</InputGroup>
</Col>
<Form.Label column className="d-lg-none column-label">
{scrapedLabel}
</Form.Label>
<Col lg="6">
<InputGroup>
<InputGroup.Prepend>
<Button
variant="secondary"
onClick={() => handleSelectClick(true)}
>
{renderButtonIcon(props.result.useNewValue)}
</Button>
</InputGroup.Prepend>
{props.newField}
</InputGroup>
{props.newValues}
</Col>
</Row>
</Col>
</Row>
);
};
interface IScrapedInputGroupProps {
isNew?: boolean;
placeholder?: string;
locked?: boolean;
result: ScrapeResult<string>;
onChange?: (value: string) => void;
}
const ScrapedInputGroup: React.FC<IScrapedInputGroupProps> = (props) => {
return (
<FormControl
placeholder={props.placeholder}
value={props.isNew ? props.result.newValue : props.result.originalValue}
readOnly={!props.isNew || props.locked}
onChange={(e) => {
if (props.isNew && props.onChange) {
props.onChange(e.target.value);
}
}}
className="bg-secondary text-white border-secondary"
/>
);
};
interface IScrapedInputGroupRowProps {
title: string;
field: string;
className?: string;
placeholder?: string;
result: ScrapeResult<string>;
locked?: boolean;
onChange: (value: ScrapeResult<string>) => void;
}
export const ScrapedInputGroupRow: React.FC<IScrapedInputGroupRowProps> = (
props
) => {
return (
<ScrapeDialogRow
title={props.title}
field={props.field}
className={props.className}
result={props.result}
originalField={
<ScrapedInputGroup
placeholder={props.placeholder || props.title}
result={props.result}
/>
}
newField={
<ScrapedInputGroup
placeholder={props.placeholder || props.title}
result={props.result}
isNew
locked={props.locked}
onChange={(value) =>
props.onChange(props.result.cloneWithValue(value))
}
/>
}
onChange={props.onChange}
/>
);
};
interface IScrapedStringListProps {
isNew?: boolean;
placeholder?: string;
locked?: boolean;
result: ScrapeResult<string[]>;
onChange?: (value: string[]) => void;
}
const ScrapedStringList: React.FC<IScrapedStringListProps> = (props) => {
const value = props.isNew
? props.result.newValue
: props.result.originalValue;
return (
<StringListInput
value={value ?? []}
setValue={(v) => {
if (props.isNew && props.onChange) {
props.onChange(v);
}
}}
placeholder={props.placeholder}
readOnly={!props.isNew || props.locked}
/>
);
};
interface IScrapedStringListRowProps {
title: string;
field: string;
placeholder?: string;
result: ScrapeResult<string[]>;
locked?: boolean;
onChange: (value: ScrapeResult<string[]>) => void;
}
export const ScrapedStringListRow: React.FC<IScrapedStringListRowProps> = (
props
) => {
return (
<ScrapeDialogRow
className="string-list-row"
title={props.title}
field={props.field}
result={props.result}
originalField={
<ScrapedStringList
placeholder={props.placeholder || props.title}
result={props.result}
/>
}
newField={
<ScrapedStringList
placeholder={props.placeholder || props.title}
result={props.result}
isNew
locked={props.locked}
onChange={(value) =>
props.onChange(props.result.cloneWithValue(value))
}
/>
}
onChange={props.onChange}
/>
);
};
const ScrapedTextArea: React.FC<IScrapedInputGroupProps> = (props) => {
return (
<FormControl
as="textarea"
placeholder={props.placeholder}
value={props.isNew ? props.result.newValue : props.result.originalValue}
readOnly={!props.isNew}
onChange={(e) => {
if (props.isNew && props.onChange) {
props.onChange(e.target.value);
}
}}
className="bg-secondary text-white border-secondary scene-description"
/>
);
};
export const ScrapedTextAreaRow: React.FC<IScrapedInputGroupRowProps> = (
props
) => {
return (
<ScrapeDialogRow
title={props.title}
field={props.field}
result={props.result}
originalField={
<ScrapedTextArea
placeholder={props.placeholder || props.title}
result={props.result}
/>
}
newField={
<ScrapedTextArea
placeholder={props.placeholder || props.title}
result={props.result}
isNew
onChange={(value) =>
props.onChange(props.result.cloneWithValue(value))
}
/>
}
onChange={props.onChange}
/>
);
};
interface IScrapedImageProps {
isNew?: boolean;
className?: string;
placeholder?: string;
result: ScrapeResult<string>;
}
const ScrapedImage: React.FC<IScrapedImageProps> = (props) => {
const value = props.isNew
? props.result.newValue
: props.result.originalValue;
if (!value) {
return <></>;
}
return (
<img className={props.className} src={value} alt={props.placeholder} />
);
};
interface IScrapedImageRowProps {
title: string;
field: string;
className?: string;
result: ScrapeResult<string>;
onChange: (value: ScrapeResult<string>) => void;
}
export const ScrapedImageRow: React.FC<IScrapedImageRowProps> = (props) => {
return (
<ScrapeDialogRow
title={props.title}
field={props.field}
result={props.result}
originalField={
<ScrapedImage
result={props.result}
className={props.className}
placeholder={props.title}
/>
}
newField={
<ScrapedImage
result={props.result}
className={props.className}
placeholder={props.title}
isNew
/>
}
onChange={props.onChange}
/>
);
};
interface IScrapedImagesRowProps {
title: string;
field: string;
className?: string;
result: ScrapeResult<string>;
images: string[];
onChange: (value: ScrapeResult<string>) => void;
}
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 (
<ScrapeDialogRow
title={props.title}
field={props.field}
result={props.result}
originalField={
<ScrapedImage
result={props.result}
className={props.className}
placeholder={props.title}
/>
}
newField={
<div className="image-selection-parent">
<ImageSelector
imageClassName={props.className}
images={props.images}
imageIndex={imageIndex}
setImageIndex={onSetImageIndex}
/>
</div>
}
onChange={props.onChange}
/>
);
};
interface IScrapedCountryRowProps {
title: string;
field: string;
result: ScrapeResult<string>;
onChange: (value: ScrapeResult<string>) => void;
locked?: boolean;
locale?: string;
}
export const ScrapedCountryRow: React.FC<IScrapedCountryRowProps> = ({
title,
field,
result,
onChange,
locked,
locale,
}) => (
<ScrapeDialogRow
title={title}
field={field}
result={result}
originalField={
<FormControl
value={
getCountryByISO(result.originalValue, locale) ?? result.originalValue
}
readOnly
className="bg-secondary text-white border-secondary"
/>
}
newField={
<CountrySelect
value={result.newValue}
disabled={locked}
onChange={(value) => {
if (onChange) {
onChange(result.cloneWithValue(value));
}
}}
showFlag={false}
isClearable={false}
className="flex-grow-1"
/>
}
onChange={onChange}
/>
);

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { ScrapeDialogRow } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { ScrapeDialogRow } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow";
import { PerformerSelect } from "src/components/Performers/PerformerSelect"; import { PerformerSelect } from "src/components/Performers/PerformerSelect";
import { import {
ObjectScrapeResult, ObjectScrapeResult,
@@ -10,6 +10,58 @@ import { TagIDSelect } from "src/components/Tags/TagSelect";
import { StudioSelect } from "src/components/Studios/StudioSelect"; import { StudioSelect } from "src/components/Studios/StudioSelect";
import { GroupSelect } from "src/components/Groups/GroupSelect"; import { GroupSelect } from "src/components/Groups/GroupSelect";
import { uniq } from "lodash-es"; import { uniq } from "lodash-es";
import { CollapseButton } from "../CollapseButton";
import { Badge, Button } from "react-bootstrap";
import { Icon } from "../Icon";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { useIntl } from "react-intl";
interface INewScrapedObjects<T> {
newValues: T[];
onCreateNew: (value: T) => void;
getName: (value: T) => string;
}
export const NewScrapedObjects = <T,>(props: INewScrapedObjects<T>) => {
const intl = useIntl();
if (props.newValues.length === 0) {
return null;
}
const ret = (
<>
{props.newValues.map((t) => (
<Badge
className="tag-item"
variant="secondary"
key={props.getName(t)}
onClick={() => props.onCreateNew(t)}
>
{props.getName(t)}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon={faPlus} />
</Button>
</Badge>
))}
</>
);
const minCollapseLength = 10;
if (props.newValues!.length >= minCollapseLength) {
const missingText = intl.formatMessage({
id: "dialogs.scrape_results_missing",
});
return (
<CollapseButton text={`${missingText} (${props.newValues!.length})`}>
{ret}
</CollapseButton>
);
}
return ret;
};
interface IScrapedStudioRow { interface IScrapedStudioRow {
title: string; title: string;
@@ -77,18 +129,20 @@ export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
title={title} title={title}
field={field} field={field}
result={result} result={result}
renderOriginalField={() => renderScrapedStudio(result)} originalField={renderScrapedStudio(result)}
renderNewField={() => newField={renderScrapedStudio(result, true, (value) =>
renderScrapedStudio(result, true, (value) => onChange(result.cloneWithValue(value))
onChange(result.cloneWithValue(value)) )}
)
}
onChange={onChange} onChange={onChange}
newValues={newStudio ? [newStudio] : undefined} newValues={
onCreateNew={() => { newStudio && onCreateNew ? (
if (onCreateNew && newStudio) onCreateNew(newStudio); <NewScrapedObjects
}} newValues={[newStudio]}
getName={getObjectName} onCreateNew={onCreateNew}
getName={getObjectName}
/>
) : undefined
}
/> />
); );
}; };
@@ -125,18 +179,20 @@ export const ScrapedObjectsRow = <T,>(props: IScrapedObjectsRow<T>) => {
title={title} title={title}
field={field} field={field}
result={result} result={result}
renderOriginalField={() => renderObjects(result)} originalField={renderObjects(result)}
renderNewField={() => newField={renderObjects(result, true, (value) =>
renderObjects(result, true, (value) => onChange(result.cloneWithValue(value))
onChange(result.cloneWithValue(value)) )}
)
}
onChange={onChange} onChange={onChange}
newValues={newObjects} newValues={
onCreateNew={(i) => { onCreateNew ? (
if (onCreateNew) onCreateNew(newObjects![i]); <NewScrapedObjects
}} newValues={newObjects ?? []}
getName={getName} onCreateNew={onCreateNew}
getName={getName}
/>
) : undefined
}
/> />
); );
}; };

View File

@@ -155,6 +155,15 @@
} }
.scrape-dialog { .scrape-dialog {
.column-label {
color: $muted-gray;
font-size: 0.85em;
}
.string-list-input {
width: 100%;
}
.modal-content .dialog-container { .modal-content .dialog-container {
max-height: calc(100vh - 14rem); max-height: calc(100vh - 14rem);
overflow-y: auto; overflow-y: auto;
@@ -391,8 +400,18 @@ button.collapse-button {
opacity: 0.5; opacity: 0.5;
} }
.string-list-input .input-group { .string-list-input {
margin-bottom: 0.35rem; .form-group {
margin-bottom: 0;
}
.input-group {
margin-bottom: 0.35rem;
&:last-child {
margin-bottom: 0;
}
}
} }
.bulk-update-text-input { .bulk-update-text-input {

View File

@@ -1020,6 +1020,7 @@
"scrape_entity_query": "{entity_type} Scrape Query", "scrape_entity_query": "{entity_type} Scrape Query",
"scrape_entity_title": "{entity_type} Scrape Results", "scrape_entity_title": "{entity_type} Scrape Results",
"scrape_results_existing": "Existing", "scrape_results_existing": "Existing",
"scrape_results_missing": "Missing",
"scrape_results_scraped": "Scraped", "scrape_results_scraped": "Scraped",
"set_default_filter_confirm": "Are you sure you want to set this filter as the default?", "set_default_filter_confirm": "Are you sure you want to set this filter as the default?",
"set_image_url_title": "Image URL", "set_image_url_title": "Image URL",