diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index d18aadbd3..952f27808 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -153,7 +153,15 @@ export const GalleryList: React.FC = PatchComponent(
{result.data.findGalleries.galleries.map((gallery) => ( - + + onSelectChange(gallery.id, selected, shiftKey) + } + selecting={selectedIds.size > 0} + /> ))}
diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index c57bf45ad..c1501bd9d 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; @@ -8,6 +9,7 @@ import { useGalleryLightbox } from "src/hooks/Lightbox/hooks"; import { galleryTitle } from "src/core/galleries"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber"; +import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; const CLASSNAME = "GalleryWallCard"; @@ -18,6 +20,9 @@ const CLASSNAME_IMG_CONTAIN = `${CLASSNAME}-img-contain`; interface IProps { gallery: GQL.SlimGalleryDataFragment; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } type Orientation = "landscape" | "portrait"; @@ -26,7 +31,12 @@ function getOrientation(width: number, height: number): Orientation { return width > height ? "landscape" : "portrait"; } -const GalleryWallCard: React.FC = ({ gallery }) => { +const GalleryWallCard: React.FC = ({ + gallery, + selected, + onSelectedChanged, + selecting, +}) => { const intl = useIntl(); const [coverOrientation, setCoverOrientation] = React.useState("landscape"); @@ -34,6 +44,12 @@ const GalleryWallCard: React.FC = ({ gallery }) => { React.useState("landscape"); const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters); + const { dragProps } = useDragMoveSelect({ + selecting: selecting || false, + selected: selected || false, + onSelectedChanged: onSelectedChanged, + }); + const cover = gallery?.paths.cover; function onCoverLoad(e: React.SyntheticEvent) { @@ -58,6 +74,14 @@ const GalleryWallCard: React.FC = ({ gallery }) => { ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; + function handleCardClick(event: React.MouseEvent) { + if (selecting && onSelectedChanged) { + onSelectedChanged(!selected, event.shiftKey); + return; + } + showLightboxStart(); + } + async function showLightboxStart() { if (gallery.image_count === 0) { return; @@ -69,15 +93,32 @@ const GalleryWallCard: React.FC = ({ gallery }) => { const imgClassname = imageOrientation !== coverOrientation ? CLASSNAME_IMG_CONTAIN : ""; + let shiftKey = false; + return ( <>
showLightboxStart()} role="button" tabIndex={0} + {...dragProps} > + {onSelectedChanged && ( + onSelectedChanged(!selected, shiftKey)} + onClick={( + event: React.MouseEvent + ) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} void; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } const zoomWidths = [280, 340, 480, 640]; @@ -49,6 +52,9 @@ const ImageWall: React.FC = ({ images, zoomIndex, handleImageOpen, + selectedIds, + onSelectChange, + selecting, }) => { const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; @@ -121,9 +127,26 @@ const ImageWall: React.FC = ({ ? props.photo.height : targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor; - return ; + const imageId = props.photo.key; + if (!imageId) { + return null; + } + return ( + + onSelectChange(imageId, selected, shiftKey) + : undefined + } + selecting={selecting} + /> + ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -258,6 +281,9 @@ const ImageListImages: React.FC = ({ pageCount={pageCount} handleImageOpen={handleImageOpen} zoomIndex={filter.zoomIndex} + selectedIds={selectedIds} + onSelectChange={onSelectChange} + selecting={!!selectedIds && selectedIds.size > 0} /> ); } diff --git a/ui/v2.5/src/components/Images/ImageWallItem.tsx b/ui/v2.5/src/components/Images/ImageWallItem.tsx index 901295192..a9f681474 100644 --- a/ui/v2.5/src/components/Images/ImageWallItem.tsx +++ b/ui/v2.5/src/components/Images/ImageWallItem.tsx @@ -1,32 +1,50 @@ import React from "react"; +import { Form } from "react-bootstrap"; import type { RenderImageProps } from "react-photo-gallery"; +import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const ImageWallItem: React.FC = ( props: RenderImageProps & IExtraProps ) => { + const { dragProps } = useDragMoveSelect({ + selecting: props.selecting || false, + selected: props.selected || false, + onSelectedChanged: props.onSelectedChanged, + }); + const height = Math.min(props.maxHeight, props.photo.height); const zoomFactor = height / props.photo.height; const width = props.photo.width * zoomFactor; type style = Record; - var imgStyle: style = { + var divStyle: style = { margin: props.margin, display: "block", + position: "relative", }; if (props.direction === "column") { - imgStyle.position = "absolute"; - imgStyle.left = props.left; - imgStyle.top = props.top; + divStyle.position = "absolute"; + divStyle.left = props.left; + divStyle.top = props.top; } var handleClick = function handleClick( event: React.MouseEvent ) { + if (props.selecting && props.onSelectedChanged) { + props.onSelectedChanged(!props.selected, event.shiftKey); + event.preventDefault(); + event.stopPropagation(); + return; + } if (props.onClick) { props.onClick(event, { index: props.index }); } @@ -35,19 +53,39 @@ export const ImageWallItem: React.FC = ( const video = props.photo.src.includes("preview"); const ImagePreview = video ? "video" : "img"; + let shiftKey = false; + return ( - + {...dragProps} + > + {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} + + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 8258f9b57..d139c6a72 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -235,11 +235,20 @@ const SceneList: React.FC<{ scenes={scenes} sceneQueue={queue} zoomIndex={filter.zoomIndex} + selectedIds={selectedIds} + onSelectChange={onSelectChange} /> ); } if (filter.displayMode === DisplayMode.Tagger) { - return ; + return ( + + ); } return null; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 08d4a4046..074ee2b83 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -101,6 +101,8 @@ export const SceneMarkerList: React.FC = PatchComponent( ); } diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx index 0349fae0f..863078c4e 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import Gallery, { GalleryI, @@ -10,6 +11,7 @@ import { objectTitle } from "src/core/files"; import { Link, useHistory } from "react-router-dom"; import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; +import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; import NavUtils from "src/utils/navigation"; import { markerTitle } from "src/core/markers"; @@ -35,11 +37,20 @@ interface IMarkerPhoto { interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const MarkerWallItem: React.FC< RenderImageProps & IExtraProps > = (props: RenderImageProps & IExtraProps) => { + const { dragProps } = useDragMoveSelect({ + selecting: props.selecting || false, + selected: props.selected || false, + onSelectedChanged: props.onSelectedChanged, + }); + const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; @@ -63,6 +74,12 @@ export const MarkerWallItem: React.FC< } var handleClick = function handleClick(event: React.MouseEvent) { + if (props.selecting && props.onSelectedChanged) { + props.onSelectedChanged(!props.selected, event.shiftKey); + event.preventDefault(); + event.stopPropagation(); + return; + } if (props.onClick) { props.onClick(event, { index: props.index }); } @@ -75,16 +92,32 @@ export const MarkerWallItem: React.FC< const title = wallItemTitle(marker); const tagNames = marker.tags.map((p) => p.name); + let shiftKey = false; + return (
+ {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} ; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason @@ -163,7 +199,13 @@ const breakpointZoomHeights = [ { minWidth: 1400, heights: [160, 240, 300, 480] }, ]; -const MarkerWall: React.FC = ({ markers, zoomIndex }) => { +const MarkerWall: React.FC = ({ + markers, + zoomIndex, + selectedIds, + onSelectChange, + selecting, +}) => { const history = useHistory(); const containerRef = React.useRef(null); @@ -233,6 +275,7 @@ const MarkerWall: React.FC = ({ markers, zoomIndex }) => { const renderImage = useCallback( (props: RenderImageProps) => { + const markerId = props.photo.marker.id; return ( = ({ markers, zoomIndex }) => { targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor } + selected={selectedIds?.has(markerId)} + onSelectedChanged={ + onSelectChange + ? (selected, shiftKey) => + onSelectChange(markerId, selected, shiftKey) + : undefined + } + selecting={selecting} /> ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -266,11 +317,24 @@ const MarkerWall: React.FC = ({ markers, zoomIndex }) => { interface IMarkerWallPanelProps { markers: GQL.SceneMarkerDataFragment[]; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; } export const MarkerWallPanel: React.FC = ({ markers, zoomIndex, + selectedIds, + onSelectChange, }) => { - return ; + const selecting = !!selectedIds && selectedIds.size > 0; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx index 92aa21f59..bf4a97b49 100644 --- a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; import Gallery, { @@ -12,6 +13,7 @@ import { Link, useHistory } from "react-router-dom"; import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; import { useIntl } from "react-intl"; +import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; interface IScenePhoto { @@ -22,6 +24,9 @@ interface IScenePhoto { interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const SceneWallItem: React.FC< @@ -29,6 +34,12 @@ export const SceneWallItem: React.FC< > = (props: RenderImageProps & IExtraProps) => { const intl = useIntl(); + const { dragProps } = useDragMoveSelect({ + selecting: props.selecting || false, + selected: props.selected || false, + onSelectedChanged: props.onSelectedChanged, + }); + const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; @@ -52,6 +63,12 @@ export const SceneWallItem: React.FC< } var handleClick = function handleClick(event: React.MouseEvent) { + if (props.selecting && props.onSelectedChanged) { + props.onSelectedChanged(!props.selected, event.shiftKey); + event.preventDefault(); + event.stopPropagation(); + return; + } if (props.onClick) { props.onClick(event, { index: props.index }); } @@ -68,16 +85,32 @@ export const SceneWallItem: React.FC< ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; + let shiftKey = false; + return (
+ {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} ; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason @@ -148,6 +184,9 @@ const SceneWall: React.FC = ({ scenes, sceneQueue, zoomIndex, + selectedIds, + onSelectChange, + selecting, }) => { const history = useHistory(); @@ -223,6 +262,7 @@ const SceneWall: React.FC = ({ const renderImage = useCallback( (props: RenderImageProps) => { + const sceneId = props.photo.scene.id; return ( = ({ targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor } + selected={selectedIds?.has(sceneId)} + onSelectedChanged={ + onSelectChange + ? (selected, shiftKey) => + onSelectChange(sceneId, selected, shiftKey) + : undefined + } + selecting={selecting} /> ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -257,14 +305,26 @@ interface ISceneWallPanelProps { scenes: GQL.SlimSceneDataFragment[]; sceneQueue?: SceneQueue; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; } export const SceneWallPanel: React.FC = ({ scenes, sceneQueue, zoomIndex, + selectedIds, + onSelectChange, }) => { + const selecting = !!selectedIds && selectedIds.size > 0; return ( - + ); }; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index aed03cef9..100b4e643 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -284,6 +284,10 @@ button.collapse-button { opacity: 0; width: 1.2rem; + &:checked { + opacity: 0.75; + } + @media (hover: none), (pointer: coarse) { // always show card controls when hovering not supported opacity: 0.25; @@ -297,10 +301,6 @@ button.collapse-button { .card-check { padding-left: 15px; - &:checked { - opacity: 0.75; - } - @media (hover: none), (pointer: coarse) { // and make it bigger when hovering not supported width: 1.5rem; @@ -314,6 +314,34 @@ button.collapse-button { } } +.search-item-check, +.wall-item-check { + height: 1.2rem; + width: 1.2rem; +} + +// Wall item checkbox styles +.wall-item-check { + left: 0.5rem; + opacity: 0; + position: absolute; + top: 0.5rem; + z-index: 10; + + &:checked { + opacity: 0.75; + } + + @media (hover: none) { + opacity: 0.25; + } +} + +.wall-item:hover .wall-item-check { + opacity: 0.75; + transition: opacity 0.5s; +} + .TruncatedText { -webkit-box-orient: vertical; display: -webkit-box; diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index 34c86e57c..76a67e306 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -22,7 +22,17 @@ const Scene: React.FC<{ queue?: SceneQueue; index: number; showLightboxImage: (imagePath: string) => void; -}> = ({ scene, searchResult, queue, index, showLightboxImage }) => { + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; +}> = ({ + scene, + searchResult, + queue, + index, + showLightboxImage, + selected, + onSelectedChanged, +}) => { const intl = useIntl(); const { currentSource, doSceneQuery, doSceneFragmentScrape, loading } = useContext(TaggerStateContext); @@ -71,6 +81,8 @@ const Scene: React.FC<{ showLightboxImage={showLightboxImage} queue={queue} index={index} + selected={selected} + onSelectedChanged={onSelectedChanged} > {searchResult && searchResult.results?.length ? ( @@ -82,9 +94,16 @@ const Scene: React.FC<{ interface ITaggerProps { scenes: GQL.SlimSceneDataFragment[]; queue?: SceneQueue; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } -export const Tagger: React.FC = ({ scenes, queue }) => { +export const Tagger: React.FC = ({ + scenes, + queue, + selectedIds, + onSelectChange, +}) => { const { sources, setCurrentSource, @@ -103,6 +122,8 @@ export const Tagger: React.FC = ({ scenes, queue }) => { const intl = useIntl(); + const hasSelection = selectedIds.size > 0; + function handleSourceSelect(e: React.ChangeEvent) { setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value)); } @@ -211,7 +232,12 @@ export const Tagger: React.FC = ({ scenes, queue }) => { return; } - if (scenes.length === 0) { + // Use selected scenes if any, otherwise all scenes + const scenesToScrape = hasSelection + ? scenes.filter((s) => selectedIds.has(s.id)) + : scenes; + + if (scenesToScrape.length === 0) { return; } @@ -232,15 +258,20 @@ export const Tagger: React.FC = ({ scenes, queue }) => { ); } + // Change button text based on selection state + const buttonTextId = hasSelection + ? "component_tagger.verb_scrape_selected" + : "component_tagger.verb_scrape_all"; + return (
{ - await doMultiSceneFragmentScrape(scenes.map((s) => s.id)); + await doMultiSceneFragmentScrape(scenesToScrape.map((s) => s.id)); }} > - {intl.formatMessage({ id: "component_tagger.verb_scrape_all" })} + {intl.formatMessage({ id: buttonTextId })} {multiError && ( <> @@ -276,6 +307,10 @@ export const Tagger: React.FC = ({ scenes, queue }) => { index={i} showLightboxImage={showLightboxImage} queue={queue} + selected={selectedIds.has(s.id)} + onSelectedChanged={(selected, shiftKey) => + onSelectChange(s.id, selected, shiftKey) + } /> ))}
diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx index 4825ebcfd..5446257e5 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx @@ -116,6 +116,8 @@ interface ITaggerScene { showLightboxImage: (imagePath: string) => void; queue?: SceneQueue; index?: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } export const TaggerScene: React.FC> = ({ @@ -129,6 +131,8 @@ export const TaggerScene: React.FC> = ({ showLightboxImage, queue, index, + selected, + onSelectedChanged, }) => { const { config } = useContext(TaggerStateContext); const [queryString, setQueryString] = useState(""); @@ -235,10 +239,28 @@ export const TaggerScene: React.FC> = ({ history.push(link); } + let shiftKey = false; + return (
-
+ {onSelectedChanged && ( +
+ onSelectedChanged(!selected, shiftKey)} + onClick={( + event: React.MouseEvent + ) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> +
+ )} +
> = ({
-
+
{renderQueryForm()} {scrapeSceneFragment ? ( diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 889d6b1b4..8861d0043 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -56,6 +56,10 @@ } } +.search-item-check { + cursor: pointer; +} + .search-result { background-color: rgba(61, 80, 92, 0.3); padding: 1rem 0; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index f165c03cd..9c040bb1a 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -230,6 +230,7 @@ "verb_match_tag": "Match Tag", "verb_matched": "Matched", "verb_scrape_all": "Scrape All", + "verb_scrape_selected": "Scrape Selected", "verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}", "verb_toggle_config": "{toggle} {configuration}", "verb_toggle_unmatched": "{toggle} unmatched scenes"