import React, { useEffect, useState, useMemo, lazy } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button, Dropdown, DropdownButton, Form, Col, Row, ButtonGroup, } from "react-bootstrap"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import { queryScrapeScene, queryScrapeSceneURL, useListSceneScrapers, useSceneUpdate, mutateReloadScrapers, queryScrapeSceneQueryFragment, } from "src/core/StashService"; import { PerformerSelect, TagSelect, StudioSelect, GallerySelect, Icon, LoadingIndicator, ImageInput, URLField, } from "src/components/Shared"; import useToast from "src/hooks/Toast"; import { ImageUtils, FormUtils, getStashIDs } from "src/utils"; import { MovieSelect } from "src/components/Shared/Select"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import { ConfigurationContext } from "src/hooks/Config"; import { stashboxDisplayName } from "src/utils/stashbox"; import { SceneMovieTable } from "./SceneMovieTable"; import { RatingStars } from "./RatingStars"; import { faSearch, faSyncAlt, faTrashAlt, } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; const SceneScrapeDialog = lazy(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazy(() => import("./SceneQueryModal")); interface IProps { scene: GQL.SceneDataFragment; isVisible: boolean; onDelete: () => void; onUpdate?: () => void; } export const SceneEditPanel: React.FC = ({ scene, isVisible, onDelete, }) => { const intl = useIntl(); const Toast = useToast(); const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( [] ); const Scrapers = useListSceneScrapers(); const [fragmentScrapers, setFragmentScrapers] = useState([]); const [queryableScrapers, setQueryableScrapers] = useState([]); const [scraper, setScraper] = useState(); const [ isScraperQueryModalOpen, setIsScraperQueryModalOpen, ] = useState(false); const [scrapedScene, setScrapedScene] = useState(); const [endpoint, setEndpoint] = useState(); const [coverImagePreview, setCoverImagePreview] = useState< string | undefined >(); useEffect(() => { setCoverImagePreview(scene.paths.screenshot ?? undefined); }, [scene.paths.screenshot]); useEffect(() => { setGalleries( scene.galleries.map((g) => ({ id: g.id, title: objectTitle(g), })) ); }, [scene.galleries]); const { configuration: stashConfig } = React.useContext(ConfigurationContext); // Network state const [isLoading, setIsLoading] = useState(false); const [updateScene] = useSceneUpdate(); const schema = yup.object({ title: yup.string().optional().nullable(), code: yup.string().optional().nullable(), details: yup.string().optional().nullable(), director: yup.string().optional().nullable(), url: yup.string().optional().nullable(), date: yup.string().optional().nullable(), rating: yup.number().optional().nullable(), gallery_ids: yup.array(yup.string().required()).optional().nullable(), studio_id: yup.string().optional().nullable(), performer_ids: yup.array(yup.string().required()).optional().nullable(), movies: yup .object({ movie_id: yup.string().required(), scene_index: yup.string().optional().nullable(), }) .optional() .nullable(), tag_ids: yup.array(yup.string().required()).optional().nullable(), cover_image: yup.string().optional().nullable(), stash_ids: yup.mixed().optional().nullable(), }); const initialValues = useMemo( () => ({ title: scene.title ?? "", code: scene.code ?? "", details: scene.details ?? "", director: scene.director ?? "", url: scene.url ?? "", date: scene.date ?? "", rating: scene.rating ?? null, gallery_ids: (scene.galleries ?? []).map((g) => g.id), studio_id: scene.studio?.id, performer_ids: (scene.performers ?? []).map((p) => p.id), movies: (scene.movies ?? []).map((m) => { return { movie_id: m.movie.id, scene_index: m.scene_index }; }), tag_ids: (scene.tags ?? []).map((t) => t.id), cover_image: undefined, stash_ids: getStashIDs(scene.stash_ids), }), [scene] ); type InputValues = typeof initialValues; const formik = useFormik({ initialValues, enableReinitialize: true, validationSchema: schema, onSubmit: (values) => onSave(getSceneInput(values)), }); function setRating(v: number) { formik.setFieldValue("rating", v); } interface IGallerySelectValue { id: string; title: string; } function onSetGalleries(items: IGallerySelectValue[]) { setGalleries(items); formik.setFieldValue( "gallery_ids", items.map((i) => i.id) ); } useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { formik.handleSubmit(); }); Mousetrap.bind("d d", () => { onDelete(); }); // numeric keypresses get caught by jwplayer, so blur the element // if the rating sequence is started Mousetrap.bind("r", () => { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } Mousetrap.bind("0", () => setRating(NaN)); Mousetrap.bind("1", () => setRating(1)); Mousetrap.bind("2", () => setRating(2)); Mousetrap.bind("3", () => setRating(3)); Mousetrap.bind("4", () => setRating(4)); Mousetrap.bind("5", () => setRating(5)); setTimeout(() => { Mousetrap.unbind("0"); Mousetrap.unbind("1"); Mousetrap.unbind("2"); Mousetrap.unbind("3"); Mousetrap.unbind("4"); Mousetrap.unbind("5"); }, 1000); }); return () => { Mousetrap.unbind("s s"); Mousetrap.unbind("d d"); Mousetrap.unbind("r"); }; } }); useEffect(() => { const toFilter = Scrapers?.data?.listSceneScrapers ?? []; const newFragmentScrapers = toFilter.filter((s) => s.scene?.supported_scrapes.includes(GQL.ScrapeType.Fragment) ); const newQueryableScrapers = toFilter.filter((s) => s.scene?.supported_scrapes.includes(GQL.ScrapeType.Name) ); setFragmentScrapers(newFragmentScrapers); setQueryableScrapers(newQueryableScrapers); }, [Scrapers, stashConfig]); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true); function getSceneInput(input: InputValues): GQL.SceneUpdateInput { return { id: scene.id, ...input, }; } function setMovieIds(movieIds: string[]) { const existingMovies = formik.values.movies; const newMovies = movieIds.map((m) => { const existing = existingMovies.find((mm) => mm.movie_id === m); if (existing) { return existing; } return { movie_id: m, }; }); formik.setFieldValue("movies", newMovies); } async function onSave(input: GQL.SceneUpdateInput) { setIsLoading(true); try { const result = await updateScene({ variables: { input: { ...input, rating: input.rating ?? null, }, }, }); if (result.data?.sceneUpdate) { Toast.success({ content: intl.formatMessage( { id: "toast.updated_entity" }, { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() } ), }); // clear the cover image so that it doesn't appear dirty formik.resetForm({ values: formik.values }); } } catch (e) { Toast.error(e); } setIsLoading(false); } const removeStashID = (stashID: GQL.StashIdInput) => { formik.setFieldValue( "stash_ids", formik.values.stash_ids.filter( (s) => !(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id) ) ); }; function renderTableMovies() { return ( { formik.setFieldValue("movies", items); }} /> ); } function onImageLoad(imageData: string) { setCoverImagePreview(imageData); formik.setFieldValue("cover_image", imageData); } function onCoverImageChange(event: React.FormEvent) { ImageUtils.onImageChange(event, onImageLoad); } async function onScrapeClicked(s: GQL.ScraperSourceInput) { setIsLoading(true); try { const result = await queryScrapeScene(s, scene.id); if (!result.data || !result.data.scrapeSingleScene?.length) { Toast.success({ content: "No scenes found", }); return; } // assume one returned scene setScrapedScene(result.data.scrapeSingleScene[0]); setEndpoint(s.stash_box_endpoint ?? undefined); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } async function scrapeFromQuery( s: GQL.ScraperSourceInput, fragment: GQL.ScrapedSceneDataFragment ) { setIsLoading(true); try { const input: GQL.ScrapedSceneInput = { date: fragment.date, code: fragment.code, details: fragment.details, director: fragment.director, remote_site_id: fragment.remote_site_id, title: fragment.title, url: fragment.url, }; const result = await queryScrapeSceneQueryFragment(s, input); if (!result.data || !result.data.scrapeSingleScene?.length) { Toast.success({ content: "No scenes found", }); return; } // assume one returned scene setScrapedScene(result.data.scrapeSingleScene[0]); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } function onScrapeQueryClicked(s: GQL.ScraperSourceInput) { setScraper(s); setEndpoint(s.stash_box_endpoint ?? undefined); setIsScraperQueryModalOpen(true); } async function onReloadScrapers() { setIsLoading(true); try { await mutateReloadScrapers(); // reload the performer scrapers await Scrapers.refetch(); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } function onScrapeDialogClosed(sceneData?: GQL.ScrapedSceneDataFragment) { if (sceneData) { updateSceneFromScrapedScene(sceneData); } setScrapedScene(undefined); } function maybeRenderScrapeDialog() { if (!scrapedScene) { return; } const currentScene = getSceneInput(formik.values); if (!currentScene.cover_image) { currentScene.cover_image = scene.paths.screenshot; } return ( onScrapeDialogClosed(s)} /> ); } function renderScrapeQueryMenu() { const stashBoxes = stashConfig?.general.stashBoxes ?? []; if (stashBoxes.length === 0 && queryableScrapers.length === 0) return; return ( {stashBoxes.map((s, index) => ( onScrapeQueryClicked({ stash_box_index: index, stash_box_endpoint: s.endpoint, }) } > {stashboxDisplayName(s.name, index)} ))} {queryableScrapers.map((s) => ( onScrapeQueryClicked({ scraper_id: s.id })} > {s.name} ))} onReloadScrapers()}> ); } function onSceneSelected(s: GQL.ScrapedSceneDataFragment) { if (!scraper) return; if (scraper?.stash_box_index !== undefined) { // must be stash-box - assume full scene setScrapedScene(s); } else { // must be scraper scrapeFromQuery(scraper, s); } } const renderScrapeQueryModal = () => { if (!isScraperQueryModalOpen || !scraper) return; return ( setScraper(undefined)} onSelectScene={(s) => { setIsScraperQueryModalOpen(false); setScraper(undefined); onSceneSelected(s); }} name={formik.values.title || objectTitle(scene) || ""} /> ); }; function renderScraperMenu() { const stashBoxes = stashConfig?.general.stashBoxes ?? []; return ( {stashBoxes.map((s, index) => ( onScrapeClicked({ stash_box_index: index, stash_box_endpoint: s.endpoint, }) } > {stashboxDisplayName(s.name, index)} ))} {fragmentScrapers.map((s) => ( onScrapeClicked({ scraper_id: s.id })} > {s.name} ))} onReloadScrapers()}> ); } function urlScrapable(scrapedUrl: string): boolean { return (Scrapers?.data?.listSceneScrapers ?? []).some((s) => (s?.scene?.urls ?? []).some((u) => scrapedUrl.includes(u)) ); } function updateSceneFromScrapedScene( updatedScene: GQL.ScrapedSceneDataFragment ) { if (updatedScene.title) { formik.setFieldValue("title", updatedScene.title); } if (updatedScene.code) { formik.setFieldValue("code", updatedScene.code); } if (updatedScene.details) { formik.setFieldValue("details", updatedScene.details); } if (updatedScene.director) { formik.setFieldValue("director", updatedScene.director); } if (updatedScene.date) { formik.setFieldValue("date", updatedScene.date); } if (updatedScene.url) { formik.setFieldValue("url", updatedScene.url); } if (updatedScene.studio && updatedScene.studio.stored_id) { formik.setFieldValue("studio_id", updatedScene.studio.stored_id); } if (updatedScene.performers && updatedScene.performers.length > 0) { const idPerfs = updatedScene.performers.filter((p) => { return p.stored_id !== undefined && p.stored_id !== null; }); if (idPerfs.length > 0) { const newIds = idPerfs.map((p) => p.stored_id); formik.setFieldValue("performer_ids", newIds as string[]); } } if (updatedScene.movies && updatedScene.movies.length > 0) { const idMovis = updatedScene.movies.filter((p) => { return p.stored_id !== undefined && p.stored_id !== null; }); if (idMovis.length > 0) { const newIds = idMovis.map((p) => p.stored_id); setMovieIds(newIds as string[]); } } if (updatedScene?.tags?.length) { const idTags = updatedScene.tags.filter((p) => { return p.stored_id !== undefined && p.stored_id !== null; }); if (idTags.length > 0) { const newIds = idTags.map((p) => p.stored_id); formik.setFieldValue("tag_ids", newIds as string[]); } } if (updatedScene.image) { // image is a base64 string formik.setFieldValue("cover_image", updatedScene.image); setCoverImagePreview(updatedScene.image); } if (updatedScene.remote_site_id && endpoint) { let found = false; formik.setFieldValue( "stash_ids", formik.values.stash_ids.map((s) => { if (s.endpoint === endpoint) { found = true; return { endpoint, stash_id: updatedScene.remote_site_id, }; } return s; }) ); if (!found) { formik.setFieldValue( "stash_ids", formik.values.stash_ids.concat({ endpoint, stash_id: updatedScene.remote_site_id, }) ); } } } async function onScrapeSceneURL() { if (!formik.values.url) { return; } setIsLoading(true); try { const result = await queryScrapeSceneURL(formik.values.url); if (!result.data || !result.data.scrapeSceneURL) { return; } setScrapedScene(result.data.scrapeSceneURL); } catch (e) { Toast.error(e); } finally { setIsLoading(false); } } function renderTextField(field: string, title: string, placeholder?: string) { return ( {FormUtils.renderLabel({ title, })} ); } if (isLoading) return ; return (
{renderScrapeQueryModal()} {maybeRenderScrapeDialog()}
{renderScraperMenu()} {renderScrapeQueryMenu()}
{renderTextField("title", intl.formatMessage({ id: "title" }))} {renderTextField("code", intl.formatMessage({ id: "scene_code" }))} {renderTextField( "date", intl.formatMessage({ id: "date" }), "YYYY-MM-DD" )} {renderTextField( "director", intl.formatMessage({ id: "director" }) )} {FormUtils.renderLabel({ title: intl.formatMessage({ id: "rating" }), })} formik.setFieldValue("rating", value ?? null) } /> {FormUtils.renderLabel({ title: intl.formatMessage({ id: "galleries" }), labelProps: { column: true, sm: 3, }, })} onSetGalleries(items)} /> {FormUtils.renderLabel({ title: intl.formatMessage({ id: "studio" }), labelProps: { column: true, sm: 3, }, })} formik.setFieldValue( "studio_id", items.length > 0 ? items[0]?.id : null ) } ids={formik.values.studio_id ? [formik.values.studio_id] : []} /> {FormUtils.renderLabel({ title: intl.formatMessage({ id: "performers" }), labelProps: { column: true, sm: 3, xl: 12, }, })} formik.setFieldValue( "performer_ids", items.map((item) => item.id) ) } ids={formik.values.performer_ids} /> {FormUtils.renderLabel({ title: `${intl.formatMessage({ id: "movies", })}/${intl.formatMessage({ id: "scenes" })}`, labelProps: { column: true, sm: 3, xl: 12, }, })} setMovieIds(items.map((item) => item.id)) } ids={formik.values.movies.map((m) => m.movie_id)} /> {renderTableMovies()} {FormUtils.renderLabel({ title: intl.formatMessage({ id: "tags" }), labelProps: { column: true, sm: 3, xl: 12, }, })} formik.setFieldValue( "tag_ids", items.map((item) => item.id) ) } ids={formik.values.tag_ids} /> {formik.values.stash_ids.length ? (
    {formik.values.stash_ids.map((stashID) => { const base = stashID.endpoint.match( /https?:\/\/.*?\// )?.[0]; const link = base ? ( {stashID.stash_id} ) : ( stashID.stash_id ); return (
  • {link}
  • ); })}
) : undefined}
) => formik.setFieldValue("details", newValue.currentTarget.value) } value={formik.values.details} />
{imageEncoding ? ( ) : ( {intl.formatMessage({ )}
); }; export default SceneEditPanel;