mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Scrape dialog (#644)
* Fix performer page button spacing * Improve scene URL scrape button styling
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
365
ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx
Normal file
365
ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx
Normal 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
325
ui/v2.5/src/components/Shared/ScrapeDialog.tsx
Normal file
325
ui/v2.5/src/components/Shared/ScrapeDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -113,3 +113,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scrape-dialog .modal-content .dialog-container {
|
||||||
|
max-height: calc(100vh - 14rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user