Scraper menu filter (#5041)

* Move scene scraper menu into reusable component
* Reuse ScraperMenu for scene query menu
* Reuse scraper menu in GalleryEditPanel
* Add filter to scraper menu
* Add divider between stashboxes and scrapers
This commit is contained in:
WithoutPants
2024-07-04 09:01:35 +10:00
committed by GitHub
parent a3e72b61ee
commit 12917f51d0
4 changed files with 148 additions and 150 deletions

View File

@@ -1,14 +1,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Prompt } from "react-router-dom"; import { Prompt } from "react-router-dom";
import { import { Button, Form, Col, Row } from "react-bootstrap";
Button,
Dropdown,
DropdownButton,
Form,
Col,
Row,
} from "react-bootstrap";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import * as yup from "yup"; import * as yup from "yup";
@@ -18,12 +11,10 @@ import {
useListGalleryScrapers, useListGalleryScrapers,
mutateReloadScrapers, mutateReloadScrapers,
} from "src/core/StashService"; } from "src/core/StashService";
import { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { GalleryScrapeDialog } from "./GalleryScrapeDialog"; import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import isEqual from "lodash-es/isEqual"; import isEqual from "lodash-es/isEqual";
import { handleUnsavedChanges } from "src/utils/navigation"; import { handleUnsavedChanges } from "src/utils/navigation";
import { import {
@@ -39,6 +30,7 @@ import { formikUtils } from "src/utils/form";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect";
import { useTagsEdit } from "src/hooks/tagsEdit"; import { useTagsEdit } from "src/hooks/tagsEdit";
import { ScraperMenu } from "src/components/Shared/ScraperMenu";
interface IProps { interface IProps {
gallery: Partial<GQL.GalleryDataFragment>; gallery: Partial<GQL.GalleryDataFragment>;
@@ -62,8 +54,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
const isNew = gallery.id === undefined; const isNew = gallery.id === undefined;
const Scrapers = useListGalleryScrapers(); const scrapers = useListGalleryScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
const [scrapedGallery, setScrapedGallery] = const [scrapedGallery, setScrapedGallery] =
useState<GQL.ScrapedGallery | null>(); useState<GQL.ScrapedGallery | null>();
@@ -165,13 +156,11 @@ export const GalleryEditPanel: React.FC<IProps> = ({
} }
}); });
useEffect(() => { const fragmentScrapers = useMemo(() => {
const newQueryableScrapers = (Scrapers?.data?.listScrapers ?? []).filter( return (scrapers?.data?.listScrapers ?? []).filter((s) =>
(s) => s.gallery?.supported_scrapes.includes(GQL.ScrapeType.Fragment) s.gallery?.supported_scrapes.includes(GQL.ScrapeType.Fragment)
); );
}, [scrapers]);
setQueryableScrapers(newQueryableScrapers);
}, [Scrapers]);
async function onSave(input: InputValues) { async function onSave(input: InputValues) {
setIsLoading(true); setIsLoading(true);
@@ -184,12 +173,12 @@ export const GalleryEditPanel: React.FC<IProps> = ({
setIsLoading(false); setIsLoading(false);
} }
async function onScrapeClicked(scraper: GQL.Scraper) { async function onScrapeClicked(s: GQL.ScraperSourceInput) {
if (!gallery || !gallery.id) return; if (!gallery || !gallery.id) return;
setIsLoading(true); setIsLoading(true);
try { try {
const result = await queryScrapeGallery(scraper.id, gallery.id); const result = await queryScrapeGallery(s.scraper_id!, gallery.id);
if (!result.data || !result.data.scrapeSingleGallery?.length) { if (!result.data || !result.data.scrapeSingleGallery?.length) {
Toast.success("No galleries found"); Toast.success("No galleries found");
return; return;
@@ -244,36 +233,8 @@ export const GalleryEditPanel: React.FC<IProps> = ({
); );
} }
function renderScraperMenu() {
if (isNew) {
return;
}
return (
<DropdownButton
className="d-inline-block"
id="gallery-scrape"
title={intl.formatMessage({ id: "actions.scrape_with" })}
>
{queryableScrapers.map((s) => (
<Dropdown.Item key={s.name} onClick={() => onScrapeClicked(s)}>
{s.name}
</Dropdown.Item>
))}
<Dropdown.Item onClick={() => onReloadScrapers()}>
<span className="fa-icon">
<Icon icon={faSyncAlt} />
</span>
<span>
<FormattedMessage id="actions.reload_scrapers" />
</span>
</Dropdown.Item>
</DropdownButton>
);
}
function urlScrapable(scrapedUrl: string): boolean { function urlScrapable(scrapedUrl: string): boolean {
return (Scrapers?.data?.listScrapers ?? []).some((s) => return (scrapers?.data?.listScrapers ?? []).some((s) =>
(s?.gallery?.urls ?? []).some((u) => scrapedUrl.includes(u)) (s?.gallery?.urls ?? []).some((u) => scrapedUrl.includes(u))
); );
} }
@@ -461,7 +422,16 @@ export const GalleryEditPanel: React.FC<IProps> = ({
<FormattedMessage id="actions.delete" /> <FormattedMessage id="actions.delete" />
</Button> </Button>
</div> </div>
<div className="ml-auto text-right d-flex">{renderScraperMenu()}</div> <div className="ml-auto text-right d-flex">
{!isNew && (
<ScraperMenu
toggle={intl.formatMessage({ id: "actions.scrape_with" })}
scrapers={fragmentScrapers}
onScraperClicked={onScrapeClicked}
onReloadScrapers={onReloadScrapers}
/>
)}
</div>
</Row> </Row>
<Row className="form-container px-3"> <Row className="form-container px-3">
<Col lg={7} xl={12}> <Col lg={7} xl={12}>

View File

@@ -1,14 +1,6 @@
import React, { useEffect, useState, useMemo } from "react"; import React, { useEffect, useState, useMemo } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { import { Button, Form, Col, Row, ButtonGroup } from "react-bootstrap";
Button,
Dropdown,
DropdownButton,
Form,
Col,
Row,
ButtonGroup,
} from "react-bootstrap";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import * as yup from "yup"; import * as yup from "yup";
@@ -28,9 +20,8 @@ import { getStashIDs } from "src/utils/stashIds";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { Prompt } from "react-router-dom"; import { Prompt } from "react-router-dom";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { stashboxDisplayName } from "src/utils/stashbox";
import { IGroupEntry, SceneGroupTable } from "./SceneGroupTable"; import { IGroupEntry, SceneGroupTable } from "./SceneGroupTable";
import { faSearch, faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { objectTitle } from "src/core/files"; import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import { lazyComponent } from "src/utils/lazyComponent"; import { lazyComponent } from "src/utils/lazyComponent";
@@ -49,6 +40,7 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect";
import { Group } from "src/components/Groups/GroupSelect"; import { Group } from "src/components/Groups/GroupSelect";
import { useTagsEdit } from "src/hooks/tagsEdit"; import { useTagsEdit } from "src/hooks/tagsEdit";
import { ScraperMenu } from "src/components/Shared/ScraperMenu";
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
@@ -394,51 +386,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
); );
} }
function renderScrapeQueryMenu() {
const stashBoxes = stashConfig?.general.stashBoxes ?? [];
if (stashBoxes.length === 0 && queryableScrapers.length === 0) return;
return (
<Dropdown title={intl.formatMessage({ id: "actions.scrape_query" })}>
<Dropdown.Toggle variant="secondary">
<Icon icon={faSearch} />
</Dropdown.Toggle>
<Dropdown.Menu>
{stashBoxes.map((s, index) => (
<Dropdown.Item
key={s.endpoint}
onClick={() =>
onScrapeQueryClicked({
stash_box_endpoint: s.endpoint,
})
}
>
{stashboxDisplayName(s.name, index)}
</Dropdown.Item>
))}
{queryableScrapers.map((s) => (
<Dropdown.Item
key={s.name}
onClick={() => onScrapeQueryClicked({ scraper_id: s.id })}
>
{s.name}
</Dropdown.Item>
))}
<Dropdown.Item onClick={() => onReloadScrapers()}>
<span className="fa-icon">
<Icon icon={faSyncAlt} />
</span>
<span>
<FormattedMessage id="actions.reload_scrapers" />
</span>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
}
function onSceneSelected(s: GQL.ScrapedSceneDataFragment) { function onSceneSelected(s: GQL.ScrapedSceneDataFragment) {
if (!scraper) return; if (!scraper) return;
@@ -468,47 +415,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
); );
}; };
function renderScraperMenu() {
const stashBoxes = stashConfig?.general.stashBoxes ?? [];
return (
<DropdownButton
className="d-inline-block"
id="scene-scrape"
title={intl.formatMessage({ id: "actions.scrape_with" })}
>
{stashBoxes.map((s, index) => (
<Dropdown.Item
key={s.endpoint}
onClick={() =>
onScrapeClicked({
stash_box_endpoint: s.endpoint,
})
}
>
{stashboxDisplayName(s.name, index)}
</Dropdown.Item>
))}
{fragmentScrapers.map((s) => (
<Dropdown.Item
key={s.name}
onClick={() => onScrapeClicked({ scraper_id: s.id })}
>
{s.name}
</Dropdown.Item>
))}
<Dropdown.Item onClick={() => onReloadScrapers()}>
<span className="fa-icon">
<Icon icon={faSyncAlt} />
</span>
<span>
<FormattedMessage id="actions.reload_scrapers" />
</span>
</Dropdown.Item>
</DropdownButton>
);
}
function urlScrapable(scrapedUrl: string): boolean { function urlScrapable(scrapedUrl: string): boolean {
return (Scrapers?.data?.listScrapers ?? []).some((s) => return (Scrapers?.data?.listScrapers ?? []).some((s) =>
(s?.scene?.urls ?? []).some((u) => scrapedUrl.includes(u)) (s?.scene?.urls ?? []).some((u) => scrapedUrl.includes(u))
@@ -801,8 +707,21 @@ export const SceneEditPanel: React.FC<IProps> = ({
{!isNew && ( {!isNew && (
<div className="ml-auto text-right d-flex"> <div className="ml-auto text-right d-flex">
<ButtonGroup className="scraper-group"> <ButtonGroup className="scraper-group">
{renderScraperMenu()} <ScraperMenu
{renderScrapeQueryMenu()} toggle={intl.formatMessage({ id: "actions.scrape_with" })}
stashBoxes={stashConfig?.general.stashBoxes ?? []}
scrapers={fragmentScrapers}
onScraperClicked={onScrapeClicked}
onReloadScrapers={onReloadScrapers}
/>
<ScraperMenu
variant="secondary"
toggle={<Icon icon={faSearch} />}
stashBoxes={stashConfig?.general.stashBoxes ?? []}
scrapers={queryableScrapers}
onScraperClicked={onScrapeQueryClicked}
onReloadScrapers={onReloadScrapers}
/>
</ButtonGroup> </ButtonGroup>
</div> </div>
)} )}

View File

@@ -0,0 +1,100 @@
import React, { useMemo, useState } from "react";
import { Dropdown } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "./Icon";
import { stashboxDisplayName } from "src/utils/stashbox";
import { ScraperSourceInput, StashBox } from "src/core/generated-graphql";
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import { ClearableInput } from "./ClearableInput";
const minFilteredScrapers = 5;
export const ScraperMenu: React.FC<{
toggle: React.ReactNode;
variant?: string;
stashBoxes?: StashBox[];
scrapers: { id: string; name: string }[];
onScraperClicked: (s: ScraperSourceInput) => void;
onReloadScrapers: () => void;
}> = ({
toggle,
variant,
stashBoxes,
scrapers,
onScraperClicked,
onReloadScrapers,
}) => {
const intl = useIntl();
const [filter, setFilter] = useState("");
const filteredStashboxes = useMemo(() => {
if (!stashBoxes) return [];
if (!filter) return stashBoxes;
return stashBoxes.filter((s) =>
s.name.toLowerCase().includes(filter.toLowerCase())
);
}, [stashBoxes, filter]);
const filteredScrapers = useMemo(() => {
if (!filter) return scrapers;
return scrapers.filter(
(s) =>
s.name.toLowerCase().includes(filter.toLowerCase()) ||
s.id.toLowerCase().includes(filter.toLowerCase())
);
}, [scrapers, filter]);
return (
<Dropdown
className="scraper-menu"
title={intl.formatMessage({ id: "actions.scrape_query" })}
>
<Dropdown.Toggle variant={variant}>{toggle}</Dropdown.Toggle>
<Dropdown.Menu>
{(stashBoxes?.length ?? 0) + scrapers.length > minFilteredScrapers && (
<ClearableInput
placeholder={`${intl.formatMessage({ id: "filter" })}...`}
value={filter}
setValue={setFilter}
/>
)}
{filteredStashboxes.map((s, index) => (
<Dropdown.Item
key={s.endpoint}
onClick={() =>
onScraperClicked({
stash_box_endpoint: s.endpoint,
})
}
>
{stashboxDisplayName(s.name, index)}
</Dropdown.Item>
))}
{filteredStashboxes.length > 0 && filteredScrapers.length > 0 && (
<Dropdown.Divider />
)}
{filteredScrapers.map((s) => (
<Dropdown.Item
key={s.name}
onClick={() => onScraperClicked({ scraper_id: s.id })}
>
{s.name}
</Dropdown.Item>
))}
<Dropdown.Item onClick={() => onReloadScrapers()}>
<span className="fa-icon">
<Icon icon={faSyncAlt} />
</span>
<span>
<FormattedMessage id="actions.reload_scrapers" />
</span>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
};

View File

@@ -596,3 +596,12 @@ button.btn.favorite-button {
.external-links-button { .external-links-button {
display: inline-block; display: inline-block;
} }
.scraper-menu .dropdown-menu {
min-width: 250px;
.dropdown-divider {
border-top-color: $textfield-bg;
margin: 0;
}
}