Scrape dialog (#644)

* Fix performer page button spacing
* Improve scene URL scrape button styling
This commit is contained in:
WithoutPants
2020-07-02 10:10:29 +10:00
committed by GitHub
parent bfeb7d1824
commit f1c544affb
12 changed files with 1217 additions and 48 deletions

View File

@@ -3,6 +3,7 @@ import ReactMarkdown from "react-markdown";
const markup = ` const markup = `
### ✨ New Features ### ✨ New Features
* Add post-scrape dialog.
* Add various keyboard shortcuts (see manual). * Add various keyboard shortcuts (see manual).
* Support deleting multiple scenes. * Support deleting multiple scenes.
* Add in-app help manual. * Add in-app help manual.

View File

@@ -27,6 +27,7 @@ import {
EditableTextUtils, EditableTextUtils,
} from "src/utils"; } from "src/utils";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
interface IPerformerDetails { interface IPerformerDetails {
performer: Partial<GQL.PerformerDataFragment>; performer: Partial<GQL.PerformerDataFragment>;
@@ -92,6 +93,10 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
const Scrapers = useListPerformerScrapers(); const Scrapers = useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]); const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
const [scrapedPerformer, setScrapedPerformer] = useState<
GQL.ScrapedPerformer | undefined
>();
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
function updatePerformerEditState( function updatePerformerEditState(
@@ -144,15 +149,63 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
function updatePerformerEditStateFromScraper( function updatePerformerEditStateFromScraper(
state: Partial<GQL.ScrapedPerformerDataFragment> state: Partial<GQL.ScrapedPerformerDataFragment>
) { ) {
updatePerformerEditState(state); if (state.name) {
setName(state.name);
}
// gender is a string in the scraper data if (state.aliases) {
setGender(translateScrapedGender(state.gender ?? undefined)); setAliases(state.aliases ?? undefined);
}
if (state.birthdate) {
setBirthdate(state.birthdate ?? undefined);
}
if (state.ethnicity) {
setEthnicity(state.ethnicity ?? undefined);
}
if (state.country) {
setCountry(state.country ?? undefined);
}
if (state.eye_color) {
setEyeColor(state.eye_color ?? undefined);
}
if (state.height) {
setHeight(state.height ?? undefined);
}
if (state.measurements) {
setMeasurements(state.measurements ?? undefined);
}
if (state.fake_tits) {
setFakeTits(state.fake_tits ?? undefined);
}
if (state.career_length) {
setCareerLength(state.career_length ?? undefined);
}
if (state.tattoos) {
setTattoos(state.tattoos ?? undefined);
}
if (state.piercings) {
setPiercings(state.piercings ?? undefined);
}
if (state.url) {
setUrl(state.url ?? undefined);
}
if (state.twitter) {
setTwitter(state.twitter ?? undefined);
}
if (state.instagram) {
setInstagram(state.instagram ?? undefined);
}
if (state.gender) {
// gender is a string in the scraper data
setGender(translateScrapedGender(state.gender ?? undefined));
}
// image is a base64 string // image is a base64 string
// #404: don't overwrite image if it has been modified by the user // #404: don't overwrite image if it has been modified by the user
// overwrite if not new since it came from a dialog
// otherwise follow existing behaviour
if ( if (
image === undefined && (!isNew || image === undefined) &&
(state as GQL.ScrapedPerformerDataFragment).image !== undefined (state as GQL.ScrapedPerformerDataFragment).image !== undefined
) { ) {
const imageStr = (state as GQL.ScrapedPerformerDataFragment).image; const imageStr = (state as GQL.ScrapedPerformerDataFragment).image;
@@ -286,7 +339,13 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
getQueryScraperPerformerInput() getQueryScraperPerformerInput()
); );
if (!result?.data?.scrapePerformer) return; if (!result?.data?.scrapePerformer) return;
updatePerformerEditStateFromScraper(result.data.scrapePerformer);
// if this is a new performer, just dump the data
if (isNew) {
updatePerformerEditStateFromScraper(result.data.scrapePerformer);
} else {
setScrapedPerformer(result.data.scrapePerformer);
}
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} finally { } finally {
@@ -303,11 +362,12 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return; return;
} }
// leave URL as is if not set explicitly // if this is a new performer, just dump the data
if (!result.data.scrapePerformerURL.url) { if (isNew) {
result.data.scrapePerformerURL.url = url; updatePerformerEditStateFromScraper(result.data.scrapePerformerURL);
} else {
setScrapedPerformer(result.data.scrapePerformerURL);
} }
updatePerformerEditStateFromScraper(result.data.scrapePerformerURL);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} finally { } finally {
@@ -362,7 +422,9 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return ( return (
<OverlayTrigger trigger="click" placement="top" overlay={popover}> <OverlayTrigger trigger="click" placement="top" overlay={popover}>
<Button variant="secondary">Scrape with...</Button> <Button variant="secondary" className="mr-2">
Scrape with...
</Button>
</OverlayTrigger> </OverlayTrigger>
); );
} }
@@ -411,6 +473,49 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
); );
} }
function maybeRenderScrapeDialog() {
if (!scrapedPerformer) {
return;
}
const currentPerformer: Partial<GQL.PerformerDataFragment> = {
name,
aliases,
birthdate,
ethnicity,
country,
eye_color: eyeColor,
height,
measurements,
fake_tits: fakeTits,
career_length: careerLength,
tattoos,
piercings,
url,
twitter,
instagram,
gender: stringToGender(gender),
image_path: image ?? performer.image_path,
};
return (
<PerformerScrapeDialog
performer={currentPerformer}
scraped={scrapedPerformer}
onClose={(p) => {
onScrapeDialogClosed(p);
}}
/>
);
}
function onScrapeDialogClosed(p?: GQL.ScrapedPerformerDataFragment) {
if (p) {
updatePerformerEditStateFromScraper(p);
}
setScrapedPerformer(undefined);
}
function renderURLField() { function renderURLField() {
return ( return (
<tr> <tr>
@@ -436,7 +541,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return ( return (
<div className="row"> <div className="row">
<Button <Button
className="edit-button" className="mr-2"
variant="primary" variant="primary"
onClick={() => onSave?.(getPerformerInput())} onClick={() => onSave?.(getPerformerInput())}
> >
@@ -444,7 +549,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
</Button> </Button>
{!isNew ? ( {!isNew ? (
<Button <Button
className="edit-button" className="mr-2"
variant="danger" variant="danger"
onClick={() => setIsDeleteAlertOpen(true)} onClick={() => setIsDeleteAlertOpen(true)}
> >
@@ -528,6 +633,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
<> <>
{renderDeleteAlert()} {renderDeleteAlert()}
{renderScraperDialog()} {renderScraperDialog()}
{maybeRenderScrapeDialog()}
<Table id="performer-details" className="w-100"> <Table id="performer-details" className="w-100">
<tbody> <tbody>

View File

@@ -0,0 +1,303 @@
import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql";
import {
ScrapeDialog,
ScrapeResult,
ScrapedInputGroupRow,
ScrapedImageRow,
ScrapeDialogRow,
} from "src/components/Shared/ScrapeDialog";
import {
getGenderStrings,
genderToString,
stringToGender,
} from "src/core/StashService";
import { Form } from "react-bootstrap";
function renderScrapedGender(
result: ScrapeResult<string>,
isNew?: boolean,
onChange?: (value: string) => void
) {
const selectOptions = [""].concat(getGenderStrings());
return (
<Form.Control
as="select"
className="input-control"
disabled={!isNew}
plaintext={!isNew}
value={isNew ? result.newValue : result.originalValue}
onChange={(e) => {
if (isNew && onChange) {
onChange(e.currentTarget.value);
}
}}
>
{selectOptions.map((opt) => (
<option value={opt} key={opt}>
{opt}
</option>
))}
</Form.Control>
);
}
function renderScrapedGenderRow(
result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void
) {
return (
<ScrapeDialogRow
title="Gender"
result={result}
renderOriginalField={() => renderScrapedGender(result)}
renderNewField={() =>
renderScrapedGender(result, true, (value) =>
onChange(result.cloneWithValue(value))
)
}
onChange={onChange}
/>
);
}
interface IPerformerScrapeDialogProps {
performer: Partial<GQL.PerformerDataFragment>;
scraped: GQL.ScrapedPerformer;
onClose: (scrapedPerformer?: GQL.ScrapedPerformer) => void;
}
export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
props: IPerformerScrapeDialogProps
) => {
function translateScrapedGender(scrapedGender?: string | null) {
if (!scrapedGender) {
return;
}
let retEnum: GQL.GenderEnum | undefined;
// try to translate from enum values first
const upperGender = scrapedGender?.toUpperCase();
const asEnum = genderToString(upperGender as GQL.GenderEnum);
if (asEnum) {
retEnum = stringToGender(asEnum);
} else {
// try to match against gender strings
const caseInsensitive = true;
retEnum = stringToGender(scrapedGender, caseInsensitive);
}
return genderToString(retEnum);
}
const [name, setName] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.name, props.scraped.name)
);
const [aliases, setAliases] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.aliases, props.scraped.aliases)
);
const [birthdate, setBirthdate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.birthdate, props.scraped.birthdate)
);
const [ethnicity, setEthnicity] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.ethnicity, props.scraped.ethnicity)
);
const [country, setCountry] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.country, props.scraped.country)
);
const [eyeColor, setEyeColor] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.eye_color, props.scraped.eye_color)
);
const [height, setHeight] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.height, props.scraped.height)
);
const [measurements, setMeasurements] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
props.performer.measurements,
props.scraped.measurements
)
);
const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.fake_tits, props.scraped.fake_tits)
);
const [careerLength, setCareerLength] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
props.performer.career_length,
props.scraped.career_length
)
);
const [tattoos, setTattoos] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.tattoos, props.scraped.tattoos)
);
const [piercings, setPiercings] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.piercings, props.scraped.piercings)
);
const [url, setURL] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.url, props.scraped.url)
);
const [twitter, setTwitter] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.twitter, props.scraped.twitter)
);
const [instagram, setInstagram] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.instagram, props.scraped.instagram)
);
const [gender, setGender] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
genderToString(props.performer.gender ?? undefined),
translateScrapedGender(props.scraped.gender)
)
);
const [image, setImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.image_path, props.scraped.image)
);
const allFields = [
name,
aliases,
birthdate,
ethnicity,
country,
eyeColor,
height,
measurements,
fakeTits,
careerLength,
tattoos,
piercings,
url,
twitter,
instagram,
gender,
image,
];
// don't show the dialog if nothing was scraped
if (allFields.every((r) => !r.scraped)) {
props.onClose();
return <></>;
}
function makeNewScrapedItem(): GQL.ScrapedPerformer {
return {
name: name.getNewValue(),
aliases: aliases.getNewValue(),
birthdate: birthdate.getNewValue(),
ethnicity: ethnicity.getNewValue(),
country: country.getNewValue(),
eye_color: eyeColor.getNewValue(),
height: height.getNewValue(),
measurements: measurements.getNewValue(),
fake_tits: fakeTits.getNewValue(),
career_length: careerLength.getNewValue(),
tattoos: tattoos.getNewValue(),
piercings: piercings.getNewValue(),
url: url.getNewValue(),
twitter: twitter.getNewValue(),
instagram: instagram.getNewValue(),
gender: gender.getNewValue(),
image: image.getNewValue(),
};
}
function renderScrapeRows() {
return (
<>
<ScrapedInputGroupRow
title="Name"
result={name}
onChange={(value) => setName(value)}
/>
<ScrapedInputGroupRow
title="Aliases"
result={aliases}
onChange={(value) => setAliases(value)}
/>
{renderScrapedGenderRow(gender, (value) => setGender(value))}
<ScrapedInputGroupRow
title="Birthdate"
result={birthdate}
onChange={(value) => setBirthdate(value)}
/>
<ScrapedInputGroupRow
title="Ethnicity"
result={ethnicity}
onChange={(value) => setEthnicity(value)}
/>
<ScrapedInputGroupRow
title="Country"
result={country}
onChange={(value) => setCountry(value)}
/>
<ScrapedInputGroupRow
title="Eye Color"
result={eyeColor}
onChange={(value) => setEyeColor(value)}
/>
<ScrapedInputGroupRow
title="Height"
result={height}
onChange={(value) => setHeight(value)}
/>
<ScrapedInputGroupRow
title="Measurements"
result={measurements}
onChange={(value) => setMeasurements(value)}
/>
<ScrapedInputGroupRow
title="Fake Tits"
result={fakeTits}
onChange={(value) => setFakeTits(value)}
/>
<ScrapedInputGroupRow
title="Career Length"
result={careerLength}
onChange={(value) => setCareerLength(value)}
/>
<ScrapedInputGroupRow
title="Tattoos"
result={tattoos}
onChange={(value) => setTattoos(value)}
/>
<ScrapedInputGroupRow
title="Piercings"
result={piercings}
onChange={(value) => setPiercings(value)}
/>
<ScrapedInputGroupRow
title="URL"
result={url}
onChange={(value) => setURL(value)}
/>
<ScrapedInputGroupRow
title="Twitter"
result={twitter}
onChange={(value) => setTwitter(value)}
/>
<ScrapedInputGroupRow
title="Instagram"
result={instagram}
onChange={(value) => setInstagram(value)}
/>
<ScrapedImageRow
title="Performer Image"
className="performer-image"
result={image}
onChange={(value) => setImage(value)}
/>
</>
);
}
return (
<ScrapeDialog
title="Performer Scrape Results"
renderScrapeRows={renderScrapeRows}
onClose={(apply) => {
props.onClose(apply ? makeNewScrapedItem() : undefined);
}}
/>
);
};

View File

@@ -71,3 +71,10 @@
padding: 0 0 1rem 0; padding: 0 0 1rem 0;
} }
} }
.scrape-dialog .performer-image {
display: block;
margin-bottom: 10px;
margin-top: 10px;
max-width: 100%;
}

View File

@@ -31,6 +31,7 @@ import { ImageUtils, FormUtils, EditableTextUtils } from "src/utils";
import { MovieSelect } from "src/components/Shared/Select"; import { MovieSelect } from "src/components/Shared/Select";
import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable"; import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable";
import { RatingStars } from "./RatingStars"; import { RatingStars } from "./RatingStars";
import { SceneScrapeDialog } from "./SceneScrapeDialog";
interface IProps { interface IProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
@@ -59,6 +60,8 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
const Scrapers = useListSceneScrapers(); const Scrapers = useListSceneScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]); const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
const [scrapedScene, setScrapedScene] = useState<GQL.ScrapedScene | null>();
const [coverImagePreview, setCoverImagePreview] = useState<string>(); const [coverImagePreview, setCoverImagePreview] = useState<string>();
// Network state // Network state
@@ -257,7 +260,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
if (!result.data || !result.data.scrapeScene) { if (!result.data || !result.data.scrapeScene) {
return; return;
} }
updateSceneFromScrapedScene(result.data.scrapeScene); setScrapedScene(result.data.scrapeScene);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} finally { } finally {
@@ -279,6 +282,34 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
} }
} }
function onScrapeDialogClosed(scene?: GQL.ScrapedSceneDataFragment) {
if (scene) {
updateSceneFromScrapedScene(scene);
}
setScrapedScene(undefined);
}
function maybeRenderScrapeDialog() {
if (!scrapedScene) {
return;
}
const currentScene = getSceneInput();
if (!currentScene.cover_image) {
currentScene.cover_image = props.scene.paths.screenshot;
}
return (
<SceneScrapeDialog
scene={currentScene}
scraped={scrapedScene}
onClose={(scene) => {
onScrapeDialogClosed(scene);
}}
/>
);
}
function renderScraperMenu() { function renderScraperMenu() {
return ( return (
<DropdownButton id="scene-scrape" title="Scrape with..."> <DropdownButton id="scene-scrape" title="Scrape with...">
@@ -304,31 +335,27 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
} }
function updateSceneFromScrapedScene(scene: GQL.ScrapedSceneDataFragment) { function updateSceneFromScrapedScene(scene: GQL.ScrapedSceneDataFragment) {
if (!title && scene.title) { if (scene.title) {
setTitle(scene.title); setTitle(scene.title);
} }
if (!details && scene.details) { if (scene.details) {
setDetails(scene.details); setDetails(scene.details);
} }
if (!date && scene.date) { if (scene.date) {
setDate(scene.date); setDate(scene.date);
} }
if (!url && scene.url) { if (scene.url) {
setUrl(scene.url); setUrl(scene.url);
} }
if (!studioId && scene.studio && scene.studio.id) { if (scene.studio && scene.studio.id) {
setStudioId(scene.studio.id); setStudioId(scene.studio.id);
} }
if ( if (scene.performers && scene.performers.length > 0) {
(!performerIds || performerIds.length === 0) &&
scene.performers &&
scene.performers.length > 0
) {
const idPerfs = scene.performers.filter((p) => { const idPerfs = scene.performers.filter((p) => {
return p.id !== undefined && p.id !== null; return p.id !== undefined && p.id !== null;
}); });
@@ -339,11 +366,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
} }
} }
if ( if (scene.movies && scene.movies.length > 0) {
(!movieIds || movieIds.length === 0) &&
scene.movies &&
scene.movies.length > 0
) {
const idMovis = scene.movies.filter((p) => { const idMovis = scene.movies.filter((p) => {
return p.id !== undefined && p.id !== null; return p.id !== undefined && p.id !== null;
}); });
@@ -354,7 +377,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
} }
} }
if (!tagIds?.length && scene?.tags?.length) { if (scene?.tags?.length) {
const idTags = scene.tags.filter((p) => { const idTags = scene.tags.filter((p) => {
return p.id !== undefined && p.id !== null; return p.id !== undefined && p.id !== null;
}); });
@@ -382,7 +405,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
if (!result.data || !result.data.scrapeSceneURL) { if (!result.data || !result.data.scrapeSceneURL) {
return; return;
} }
updateSceneFromScrapedScene(result.data.scrapeSceneURL); setScrapedScene(result.data.scrapeSceneURL);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} finally { } finally {
@@ -395,8 +418,12 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
return undefined; return undefined;
} }
return ( return (
<Button id="scrape-url-button" onClick={onScrapeSceneURL} title="Scrape"> <Button
<Icon icon="file-download" /> className="minimal scrape-url-button"
onClick={onScrapeSceneURL}
title="Scrape"
>
<Icon className="fa-fw" icon="file-download" />
</Button> </Button>
); );
} }
@@ -404,7 +431,8 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
if (isLoading) return <LoadingIndicator />; if (isLoading) return <LoadingIndicator />;
return ( return (
<> <div id="scene-edit-details">
{maybeRenderScrapeDialog()}
<div className="form-container row px-3 pt-3"> <div className="form-container row px-3 pt-3">
<div className="col edit-buttons mb-3 pl-0"> <div className="col edit-buttons mb-3 pl-0">
<Button className="edit-button" variant="primary" onClick={onSave}> <Button className="edit-button" variant="primary" onClick={onSave}>
@@ -429,9 +457,12 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
isEditing: true, isEditing: true,
})} })}
<Form.Group controlId="url" as={Row}> <Form.Group controlId="url" as={Row}>
{FormUtils.renderLabel({ <Col xs={3} className="pr-0 url-label">
title: "URL", <Form.Label className="col-form-label">URL</Form.Label>
})} <div className="float-right scrape-button-container">
{maybeRenderScrapeButton()}
</div>
</Col>
<Col xs={9}> <Col xs={9}>
{EditableTextUtils.renderInputGroup({ {EditableTextUtils.renderInputGroup({
title: "URL", title: "URL",
@@ -439,7 +470,6 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
onChange: setUrl, onChange: setUrl,
isEditing: true, isEditing: true,
})} })}
{maybeRenderScrapeButton()}
</Col> </Col>
</Form.Group> </Form.Group>
{FormUtils.renderInputGroup({ {FormUtils.renderInputGroup({
@@ -573,6 +603,6 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
</div> </div>
</div> </div>
</div> </div>
</> </div>
); );
}; };

View File

@@ -0,0 +1,365 @@
import React, { useState } from "react";
import { StudioSelect, PerformerSelect } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql";
import { MovieSelect, TagSelect } from "src/components/Shared/Select";
import {
ScrapeDialog,
ScrapeDialogRow,
ScrapeResult,
ScrapedInputGroupRow,
ScrapedTextAreaRow,
ScrapedImageRow,
} from "src/components/Shared/ScrapeDialog";
import _ from "lodash";
function renderScrapedStudio(
result: ScrapeResult<string>,
isNew?: boolean,
onChange?: (value: string) => void
) {
const resultValue = isNew ? result.newValue : result.originalValue;
const value = resultValue ? [resultValue] : [];
return (
<StudioSelect
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChange) {
onChange(items[0]?.id);
}
}}
ids={value}
/>
);
}
function renderScrapedStudioRow(
result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void
) {
return (
<ScrapeDialogRow
title="Studio"
result={result}
renderOriginalField={() => renderScrapedStudio(result)}
renderNewField={() =>
renderScrapedStudio(result, true, (value) =>
onChange(result.cloneWithValue(value))
)
}
onChange={onChange}
/>
);
}
function renderScrapedPerformers(
result: ScrapeResult<string[]>,
isNew?: boolean,
onChange?: (value: string[]) => void
) {
const resultValue = isNew ? result.newValue : result.originalValue;
const value = resultValue ?? [];
return (
<PerformerSelect
isMulti
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChange) {
onChange(items.map((i) => i.id));
}
}}
ids={value}
/>
);
}
function renderScrapedPerformersRow(
result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void
) {
return (
<ScrapeDialogRow
title="Performers"
result={result}
renderOriginalField={() => renderScrapedPerformers(result)}
renderNewField={() =>
renderScrapedPerformers(result, true, (value) =>
onChange(result.cloneWithValue(value))
)
}
onChange={onChange}
/>
);
}
function renderScrapedMovies(
result: ScrapeResult<string[]>,
isNew?: boolean,
onChange?: (value: string[]) => void
) {
const resultValue = isNew ? result.newValue : result.originalValue;
const value = resultValue ?? [];
return (
<MovieSelect
isMulti
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChange) {
onChange(items.map((i) => i.id));
}
}}
ids={value}
/>
);
}
function renderScrapedMoviesRow(
result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void
) {
return (
<ScrapeDialogRow
title="Movies"
result={result}
renderOriginalField={() => renderScrapedMovies(result)}
renderNewField={() =>
renderScrapedMovies(result, true, (value) =>
onChange(result.cloneWithValue(value))
)
}
onChange={onChange}
/>
);
}
function renderScrapedTags(
result: ScrapeResult<string[]>,
isNew?: boolean,
onChange?: (value: string[]) => void
) {
const resultValue = isNew ? result.newValue : result.originalValue;
const value = resultValue ?? [];
return (
<TagSelect
isMulti
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChange) {
onChange(items.map((i) => i.id));
}
}}
ids={value}
/>
);
}
function renderScrapedTagsRow(
result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void
) {
return (
<ScrapeDialogRow
title="Tags"
result={result}
renderOriginalField={() => renderScrapedTags(result)}
renderNewField={() =>
renderScrapedTags(result, true, (value) =>
onChange(result.cloneWithValue(value))
)
}
onChange={onChange}
/>
);
}
interface ISceneScrapeDialogProps {
scene: Partial<GQL.SceneUpdateInput>;
scraped: GQL.ScrapedScene;
onClose: (scrapedScene?: GQL.ScrapedScene) => void;
}
interface IHasID {
id?: string | null;
}
export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
props: ISceneScrapeDialogProps
) => {
const [title, setTitle] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.scene.title, props.scraped.title)
);
const [url, setURL] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.scene.url, props.scraped.url)
);
const [date, setDate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.scene.date, props.scraped.date)
);
const [studio, setStudio] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.scene.studio_id, props.scraped.studio?.id)
);
function mapIdObjects(scrapedObjects?: IHasID[]): string[] | undefined {
if (!scrapedObjects) {
return undefined;
}
const ret = scrapedObjects
.map((p) => p.id)
.filter((p) => {
return p !== undefined && p !== null;
}) as string[];
if (ret.length === 0) {
return undefined;
}
// sort by id numerically
ret.sort((a, b) => {
return parseInt(a) - parseInt(b);
});
return ret;
}
function sortIdList(idList?: string[] | null) {
if (!idList) {
return;
}
const ret = _.clone(idList);
// sort by id numerically
ret.sort((a, b) => {
return parseInt(a) - parseInt(b);
});
return ret;
}
const [performers, setPerformers] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(
sortIdList(props.scene.performer_ids),
mapIdObjects(props.scraped.performers ?? undefined)
)
);
const [movies, setMovies] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(
sortIdList(props.scene.movies?.map((p) => p.movie_id)),
mapIdObjects(props.scraped.movies ?? undefined)
)
);
const [tags, setTags] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(
sortIdList(props.scene.tag_ids),
mapIdObjects(props.scraped.tags ?? undefined)
)
);
const [details, setDetails] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.scene.details, props.scraped.details)
);
const [image, setImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.scene.cover_image, props.scraped.image)
);
// don't show the dialog if nothing was scraped
if (
[title, url, date, studio, performers, movies, tags, details, image].every(
(r) => !r.scraped
)
) {
props.onClose();
return <></>;
}
function makeNewScrapedItem() {
const newStudio = studio.getNewValue();
return {
title: title.getNewValue(),
url: url.getNewValue(),
date: date.getNewValue(),
studio: newStudio
? {
id: newStudio,
name: "",
}
: undefined,
performers: performers.getNewValue()?.map((p) => {
return {
id: p,
name: "",
};
}),
movies: movies.getNewValue()?.map((m) => {
return {
id: m,
name: "",
};
}),
tags: tags.getNewValue()?.map((m) => {
return {
id: m,
name: "",
};
}),
details: details.getNewValue(),
image: image.getNewValue(),
};
}
function renderScrapeRows() {
return (
<>
<ScrapedInputGroupRow
title="Title"
result={title}
onChange={(value) => setTitle(value)}
/>
<ScrapedInputGroupRow
title="URL"
result={url}
onChange={(value) => setURL(value)}
/>
<ScrapedInputGroupRow
title="Date"
placeholder="YYYY-MM-DD"
result={date}
onChange={(value) => setDate(value)}
/>
{renderScrapedStudioRow(studio, (value) => setStudio(value))}
{renderScrapedPerformersRow(performers, (value) =>
setPerformers(value)
)}
{renderScrapedMoviesRow(movies, (value) => setMovies(value))}
{renderScrapedTagsRow(tags, (value) => setTags(value))}
<ScrapedTextAreaRow
title="Details"
result={details}
onChange={(value) => setDetails(value)}
/>
<ScrapedImageRow
title="Cover Image"
className="scene-cover"
result={image}
onChange={(value) => setImage(value)}
/>
</>
);
}
return (
<ScrapeDialog
title="Scene Scrape Results"
renderScrapeRows={renderScrapeRows}
onClose={(apply) => {
props.onClose(apply ? makeNewScrapedItem() : undefined);
}}
/>
);
};

View File

@@ -90,7 +90,7 @@
} }
} }
#details { textarea.scene-description {
min-height: 150px; min-height: 150px;
} }
@@ -284,7 +284,18 @@
} }
} }
#scene-edit-details .rating-stars { #scene-edit-details {
font-size: 1.3em; .rating-stars {
height: calc(1.5em + 0.75rem + 2px); font-size: 1.3em;
height: calc(1.5em + 0.75rem + 2px);
}
.scrape-button-container {
margin-right: -15px;
}
.scrape-url-button {
color: $text-color;
margin-right: 0;
}
} }

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Button, Modal, Spinner } from "react-bootstrap"; import { Button, Modal, Spinner, ModalProps } from "react-bootstrap";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
import { IconName } from "@fortawesome/fontawesome-svg-core"; import { IconName } from "@fortawesome/fontawesome-svg-core";
@@ -17,6 +17,7 @@ interface IModal {
cancel?: IButton; cancel?: IButton;
accept?: IButton; accept?: IButton;
isRunning?: boolean; isRunning?: boolean;
modalProps?: ModalProps;
} }
const ModalComponent: React.FC<IModal> = ({ const ModalComponent: React.FC<IModal> = ({
@@ -28,8 +29,9 @@ const ModalComponent: React.FC<IModal> = ({
accept, accept,
onHide, onHide,
isRunning, isRunning,
modalProps,
}) => ( }) => (
<Modal keyboard={false} onHide={onHide} show={show}> <Modal keyboard={false} onHide={onHide} show={show} {...modalProps}>
<Modal.Header> <Modal.Header>
{icon ? <Icon icon={icon} /> : ""} {icon ? <Icon icon={icon} /> : ""}
<span>{header ?? ""}</span> <span>{header ?? ""}</span>

View File

@@ -0,0 +1,325 @@
import React from "react";
import {
Form,
Col,
Row,
InputGroup,
Button,
FormControl,
} from "react-bootstrap";
import { Icon, Modal } from "src/components/Shared";
import _ from "lodash";
export class ScrapeResult<T> {
public newValue?: T;
public originalValue?: T;
public scraped: boolean = false;
public useNewValue: boolean = false;
public constructor(originalValue?: T | null, newValue?: T | null) {
this.originalValue = originalValue ?? undefined;
this.newValue = newValue ?? undefined;
const valuesEqual = _.isEqual(originalValue, newValue);
this.useNewValue = !!this.newValue && !valuesEqual;
this.scraped = this.useNewValue;
}
public setOriginalValue(value?: T) {
this.originalValue = value;
this.newValue = value;
}
public cloneWithValue(value?: T) {
const ret = _.clone(this);
ret.newValue = value;
ret.useNewValue = !_.isEqual(ret.newValue, ret.originalValue);
return ret;
}
public getNewValue() {
if (this.useNewValue) {
return this.newValue;
}
}
}
interface IScrapedFieldProps<T> {
result: ScrapeResult<T>;
}
interface IScrapedRowProps<T> extends IScrapedFieldProps<T> {
title: string;
renderOriginalField: (result: ScrapeResult<T>) => JSX.Element | undefined;
renderNewField: (result: ScrapeResult<T>) => JSX.Element | undefined;
onChange: (value: ScrapeResult<T>) => void;
}
function renderButtonIcon(selected: boolean) {
const className = selected ? "text-success" : "text-muted";
return (
<Icon
className={`fa-fw ${className}`}
icon={selected ? "check" : "times"}
/>
);
}
export const ScrapeDialogRow = <T,>(props: IScrapedRowProps<T>) => {
function handleSelectClick(isNew: boolean) {
const ret = _.clone(props.result);
ret.useNewValue = isNew;
props.onChange(ret);
}
if (!props.result.scraped) {
return <></>;
}
return (
<Row className="px-3 pt-3">
<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>
</Col>
</Row>
</Col>
</Row>
);
};
interface IScrapedInputGroupProps {
isNew?: boolean;
placeholder?: string;
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}
onChange={(e) => {
if (props.isNew && props.onChange) {
props.onChange(e.target.value);
}
}}
className="bg-secondary text-white border-secondary"
/>
);
};
interface IScrapedInputGroupRowProps {
title: string;
placeholder?: string;
result: ScrapeResult<string>;
onChange: (value: ScrapeResult<string>) => void;
}
export const ScrapedInputGroupRow: React.FC<IScrapedInputGroupRowProps> = (
props
) => {
return (
<ScrapeDialogRow
title={props.title}
result={props.result}
renderOriginalField={() => (
<ScrapedInputGroup
placeholder={props.placeholder || props.title}
result={props.result}
/>
)}
renderNewField={() => (
<ScrapedInputGroup
placeholder={props.placeholder || props.title}
result={props.result}
isNew
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}
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}
/>
);
};
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;
className?: string;
result: ScrapeResult<string>;
onChange: (value: ScrapeResult<string>) => void;
}
export const ScrapedImageRow: React.FC<IScrapedImageRowProps> = (props) => {
return (
<ScrapeDialogRow
title={props.title}
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}
/>
);
};
interface IScrapeDialogProps {
title: string;
renderScrapeRows: () => JSX.Element;
onClose: (apply?: boolean) => void;
}
export const ScrapeDialog: React.FC<IScrapeDialogProps> = (
props: IScrapeDialogProps
) => {
return (
<Modal
show
icon="pencil-alt"
header={props.title}
accept={{
onClick: () => {
props.onClose(true);
},
text: "Apply",
}}
cancel={{
onClick: () => props.onClose(),
text: "Cancel",
variant: "secondary",
}}
modalProps={{ size: "lg", dialogClassName: "scrape-dialog" }}
>
<div className="dialog-container">
<Form>
<Row className="px-3 pt-3">
<Col lg={{ span: 9, offset: 3 }}>
<Row>
<Form.Label column xs="6">
Existing
</Form.Label>
<Form.Label column xs="6">
Scraped
</Form.Label>
</Row>
</Col>
</Row>
{props.renderScrapeRows()}
</Form>
</div>
</Modal>
);
};

View File

@@ -113,3 +113,9 @@
} }
} }
} }
.scrape-dialog .modal-content .dialog-container {
max-height: calc(100vh - 14rem);
overflow-y: auto;
padding-right: 15px;
}

View File

@@ -211,6 +211,17 @@ div.dropdown-menu {
} }
} }
/* fix for react-select in input-group */
.input-group .react-select {
border: 0;
height: 100%;
padding: 0;
.react-select__control {
border-radius: 0;
}
}
/* stylelint-enable selector-class-pattern */ /* stylelint-enable selector-class-pattern */
.image-thumbnail { .image-thumbnail {

View File

@@ -51,7 +51,7 @@ const renderInputGroup = (options: {
value: string | undefined; value: string | undefined;
isEditing: boolean; isEditing: boolean;
url?: string; url?: string;
onChange: (value: string) => void; onChange?: (value: string) => void;
}) => { }) => {
if (options.url && !options.isEditing) { if (options.url && !options.isEditing) {
return ( return (
@@ -68,9 +68,11 @@ const renderInputGroup = (options: {
plaintext={!options.isEditing} plaintext={!options.isEditing}
value={options.value ?? ""} value={options.value ?? ""}
placeholder={options.placeholder ?? options.title} placeholder={options.placeholder ?? options.title}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
options.onChange(event.currentTarget.value) if (options.onChange) {
} options.onChange(event.currentTarget.value);
}
}}
/> />
); );
}; };