From 113f0b7d77e47ebc7620c915f6d614bc619cd1ff Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 21 Aug 2023 09:51:23 +1000 Subject: [PATCH 01/16] Update changelog for bugfix release --- ui/v2.5/src/docs/en/Changelog/v0220.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0220.md b/ui/v2.5/src/docs/en/Changelog/v0220.md index ea2a7d96739..86d2ecb58d7 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0220.md +++ b/ui/v2.5/src/docs/en/Changelog/v0220.md @@ -20,6 +20,11 @@ * Added support for `-v/--version` command line flag. ([#3883](https://github.com/stashapp/stash/pull/3883)) ### 🐛 Bug fixes +* **[0.22.1]** Fixed Batch Update Performers not working correctly. ([#4024](https://github.com/stashapp/stash/pull/4024)) +* **[0.22.1]** Fixed panic when creating Studios during Identify task. ([#4024](https://github.com/stashapp/stash/pull/4024)) +* **[0.22.1]** Added explicit option to store blobs in database at setup, and fixed default blobs path. ([#4038](https://github.com/stashapp/stash/pull/4038)) +* **[0.22.1]** Fixed dropdown appearing beneath other controls on the Performer and Tag pages. ([#4039](https://github.com/stashapp/stash/pull/4039)) +* **[0.22.1]** Fixed buttons moving around when setting marker time when creating a new marker. ([#4040](https://github.com/stashapp/stash/pull/4040)) * Fixing sorting of performer tags. ([#4018](https://github.com/stashapp/stash/pull/4018)) * Fixed scene URLs being cleared when merging scenes. ([#4005](https://github.com/stashapp/stash/pull/4005)) * Fixed setting the Create Missing flag in the Identify dialog not working. ([#4008](https://github.com/stashapp/stash/pull/4008)) From 07897465e75ad09d83d22a7a7826de9b43df40c3 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 24 Aug 2023 09:08:06 +1000 Subject: [PATCH 02/16] Make x button on badges easier to click (#4029) --- ui/v2.5/src/index.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index bb278866223..54ab41ec991 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -739,8 +739,9 @@ div.dropdown-menu { color: $dark-text; font-size: 12px; line-height: 1rem; + margin-right: -0.5rem; opacity: 0.5; - padding: 0 0 0 0.5rem; + padding: 0 0.5rem; position: relative; &:active, From 411ebb81954b35f3a3db8b89a34c6cec7c6783b2 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Wed, 23 Aug 2023 18:10:33 -0500 Subject: [PATCH 03/16] fix show all details default behavior (#4047) --- .../src/components/Performers/PerformerDetails/Performer.tsx | 2 +- ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx | 2 +- ui/v2.5/src/components/Tags/TagDetails/Tag.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 015789fe173..c628f3ee47d 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -82,7 +82,7 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const enableBackgroundImage = uiConfig?.enablePerformerBackgroundImage ?? false; - const showAllDetails = uiConfig?.showAllDetails ?? false; + const showAllDetails = uiConfig?.showAllDetails ?? true; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const [collapsed, setCollapsed] = useState(!showAllDetails); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index ae7ce9d85ad..d282b929477 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -82,7 +82,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { const uiConfig = configuration?.ui as IUIConfig | undefined; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const enableBackgroundImage = uiConfig?.enableStudioBackgroundImage ?? false; - const showAllDetails = uiConfig?.showAllDetails ?? false; + const showAllDetails = uiConfig?.showAllDetails ?? true; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const [collapsed, setCollapsed] = useState(!showAllDetails); diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index acf03a295c6..53fcfcbeedd 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -77,7 +77,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { const uiConfig = configuration?.ui as IUIConfig | undefined; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const enableBackgroundImage = uiConfig?.enableTagBackgroundImage ?? false; - const showAllDetails = uiConfig?.showAllDetails ?? false; + const showAllDetails = uiConfig?.showAllDetails ?? true; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const [collapsed, setCollapsed] = useState(!showAllDetails); From 332347737a1e6442e8311278db86cd81b929ef03 Mon Sep 17 00:00:00 2001 From: plato178 <137155614+plato178@users.noreply.github.com> Date: Thu, 24 Aug 2023 01:48:35 +0100 Subject: [PATCH 04/16] Tagger config option to mark scene as organized (#4031) --- ui/v2.5/src/components/Tagger/constants.ts | 2 ++ ui/v2.5/src/components/Tagger/context.tsx | 5 ++++- ui/v2.5/src/components/Tagger/scenes/Config.tsx | 17 +++++++++++++++++ ui/v2.5/src/locales/en-GB.json | 2 ++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index e19803b4cf2..a180757b11c 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -36,6 +36,7 @@ export const initialConfig: ITaggerConfig = { tagOperation: "merge", fingerprintQueue: {}, excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS, + markSceneAsOrganizedOnSave: false, excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS, createParentStudios: true, }; @@ -52,6 +53,7 @@ export interface ITaggerConfig { selectedEndpoint?: string; fingerprintQueue: Record; excludedPerformerFields?: string[]; + markSceneAsOrganizedOnSave?: boolean; excludedStudioFields?: string[]; createParentStudios: boolean; } diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 3c7003228dc..6a72209a1f8 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -461,7 +461,10 @@ export const TaggerContext: React.FC = ({ children }) => { try { await updateScene({ variables: { - input: sceneCreateInput, + input: { + ...sceneCreateInput, + organized: config?.markSceneAsOrganizedOnSave, + }, }, }); diff --git a/ui/v2.5/src/components/Tagger/scenes/Config.tsx b/ui/v2.5/src/components/Tagger/scenes/Config.tsx index a04f78fcb96..f05cc5f5255 100644 --- a/ui/v2.5/src/components/Tagger/scenes/Config.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/Config.tsx @@ -176,6 +176,23 @@ const Config: React.FC = ({ show }) => { })} + + + } + checked={config.markSceneAsOrganizedOnSave} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + markSceneAsOrganizedOnSave: e.currentTarget.checked, + }) + } + /> + + + +
diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b0efd41702b..014854c3da0 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -156,6 +156,8 @@ "active_instance": "Active stash-box instance:", "blacklist_desc": "Blacklist items are excluded from queries. Note that they are regular expressions and also case-insensitive. Certain characters must be escaped with a backslash: {chars_require_escape}", "blacklist_label": "Blacklist", + "mark_organized_desc": "Immediately mark the scene as Organized after the Save button is clicked.", + "mark_organized_label": "Mark as Organized on save", "query_mode_auto": "Auto", "query_mode_auto_desc": "Uses metadata if present, or filename", "query_mode_dir": "Dir", From c2b93676ddb5c5a5e03d403a0faf6307a58833e6 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Thu, 24 Aug 2023 03:08:25 +0200 Subject: [PATCH 05/16] Details pages start with populated content tab (#4032) --- .../Performers/PerformerDetails/Performer.tsx | 26 +++++++++++-- .../Studios/StudioDetails/Studio.tsx | 39 +++++++++++++++++-- .../src/components/Tags/TagDetails/Tag.tsx | 30 ++++++++++++-- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index c628f3ee47d..8d42a6c2a22 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -57,6 +57,7 @@ interface IPerformerParams { } const validTabs = [ + "default", "scenes", "galleries", "images", @@ -65,7 +66,7 @@ const validTabs = [ ] as const; type TabKey = (typeof validTabs)[number]; -const defaultTab: TabKey = "scenes"; +const defaultTab: TabKey = "default"; function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); @@ -117,11 +118,30 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { const [updatePerformer] = usePerformerUpdate(); const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy(); + const populatedDefaultTab = useMemo(() => { + let ret: TabKey = "scenes"; + if (performer.scene_count == 0) { + if (performer.gallery_count != 0) { + ret = "galleries"; + } else if (performer.image_count != 0) { + ret = "images"; + } else if (performer.movie_count != 0) { + ret = "movies"; + } + } + + return ret; + }, [performer]); + + if (tabKey === defaultTab) { + tabKey = populatedDefaultTab; + } + function setTabKey(newTabKey: string | null) { - if (!newTabKey) newTabKey = defaultTab; + if (!newTabKey || newTabKey === defaultTab) newTabKey = populatedDefaultTab; if (newTabKey === tabKey) return; - if (newTabKey === defaultTab) { + if (newTabKey === populatedDefaultTab) { history.replace(`/performers/${performer.id}`); } else if (isTabKey(newTabKey)) { history.replace(`/performers/${performer.id}/${newTabKey}`); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index d282b929477..aa404aad697 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -1,5 +1,5 @@ import { Button, Tabs, Tab } from "react-bootstrap"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; @@ -57,6 +57,7 @@ interface IStudioParams { } const validTabs = [ + "default", "scenes", "galleries", "images", @@ -66,7 +67,7 @@ const validTabs = [ ] as const; type TabKey = (typeof validTabs)[number]; -const defaultTab: TabKey = "scenes"; +const defaultTab: TabKey = "default"; function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); @@ -112,6 +113,36 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { const movieCount = (showAllCounts ? studio.movie_count_all : studio.movie_count) ?? 0; + const populatedDefaultTab = useMemo(() => { + let ret: TabKey = "scenes"; + if (sceneCount == 0) { + if (galleryCount != 0) { + ret = "galleries"; + } else if (imageCount != 0) { + ret = "images"; + } else if (performerCount != 0) { + ret = "performers"; + } else if (movieCount != 0) { + ret = "movies"; + } else if (studio.child_studios.length != 0) { + ret = "childstudios"; + } + } + + return ret; + }, [ + sceneCount, + galleryCount, + imageCount, + performerCount, + movieCount, + studio, + ]); + + if (tabKey === defaultTab) { + tabKey = populatedDefaultTab; + } + // set up hotkeys useEffect(() => { Mousetrap.bind("e", () => toggleEditing()); @@ -243,10 +274,10 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { } function setTabKey(newTabKey: string | null) { - if (!newTabKey) newTabKey = defaultTab; + if (!newTabKey || newTabKey === defaultTab) newTabKey = populatedDefaultTab; if (newTabKey === tabKey) return; - if (newTabKey === defaultTab) { + if (newTabKey === populatedDefaultTab) { history.replace(`/studios/${studio.id}`); } else if (isTabKey(newTabKey)) { history.replace(`/studios/${studio.id}/${newTabKey}`); diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 53fcfcbeedd..5ba59bc87f6 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -1,5 +1,5 @@ import { Tabs, Tab, Dropdown, Button } from "react-bootstrap"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; @@ -53,6 +53,7 @@ interface ITagParams { } const validTabs = [ + "default", "scenes", "images", "galleries", @@ -61,7 +62,7 @@ const validTabs = [ ] as const; type TabKey = (typeof validTabs)[number]; -const defaultTab: TabKey = "scenes"; +const defaultTab: TabKey = "default"; function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); @@ -107,11 +108,32 @@ const TagPage: React.FC = ({ tag, tabKey }) => { const performerCount = (showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0; + const populatedDefaultTab = useMemo(() => { + let ret: TabKey = "scenes"; + if (sceneCount == 0) { + if (imageCount != 0) { + ret = "images"; + } else if (galleryCount != 0) { + ret = "galleries"; + } else if (sceneMarkerCount != 0) { + ret = "markers"; + } else if (performerCount != 0) { + ret = "performers"; + } + } + + return ret; + }, [sceneCount, imageCount, galleryCount, sceneMarkerCount, performerCount]); + + if (tabKey === defaultTab) { + tabKey = populatedDefaultTab; + } + function setTabKey(newTabKey: string | null) { - if (!newTabKey) newTabKey = defaultTab; + if (!newTabKey || newTabKey === defaultTab) newTabKey = populatedDefaultTab; if (newTabKey === tabKey) return; - if (newTabKey === defaultTab) { + if (newTabKey === populatedDefaultTab) { history.replace(`/tags/${tag.id}`); } else if (isTabKey(newTabKey)) { history.replace(`/tags/${tag.id}/${newTabKey}`); From 3dc01a9362853bef579ede891c2f7f8f5fc5e25b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 24 Aug 2023 11:14:20 +1000 Subject: [PATCH 06/16] Preview scrubber (#4022) * Add sprite info hook * Remove axios dependency * Add preview scrubber * Add scrubber timestamp * On click go to timestamp --- ui/v2.5/package.json | 1 - .../ScenePlayer/ScenePlayerScrubber.tsx | 85 +++------ .../src/components/Scenes/PreviewScrubber.tsx | 173 ++++++++++++++++++ ui/v2.5/src/components/Scenes/SceneCard.tsx | 23 ++- ui/v2.5/src/components/Scenes/styles.scss | 63 +++++++ ui/v2.5/src/hooks/sprite.ts | 62 +++++++ ui/v2.5/src/models/sceneQueue.ts | 4 + 7 files changed, 347 insertions(+), 64 deletions(-) create mode 100644 ui/v2.5/src/components/Scenes/PreviewScrubber.tsx create mode 100644 ui/v2.5/src/hooks/sprite.ts diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 60b2d35f477..98ec62dea30 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -32,7 +32,6 @@ "@silvermine/videojs-airplay": "^1.2.0", "@silvermine/videojs-chromecast": "^1.4.1", "apollo-upload-client": "^17.0.0", - "axios": "^1.3.3", "base64-blob": "^1.4.1", "bootstrap": "^4.6.2", "classnames": "^2.3.2", diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx index 68dfbb406da..c6cf120fba1 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx @@ -6,15 +6,14 @@ import React, { useCallback, } from "react"; import { Button } from "react-bootstrap"; -import axios from "axios"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; -import { WebVTT } from "videojs-vtt.js"; import { Icon } from "src/components/Shared/Icon"; import { faChevronRight, faChevronLeft, } from "@fortawesome/free-solid-svg-icons"; +import { useSpriteInfo } from "src/hooks/sprite"; interface IScenePlayerScrubberProps { file: GQL.VideoFileDataFragment; @@ -29,42 +28,6 @@ interface ISceneSpriteItem { time: string; } -interface ISceneSpriteInfo { - url: string; - start: number; - end: number; - x: number; - y: number; - w: number; - h: number; -} - -async function fetchSpriteInfo(vttPath: string) { - const response = await axios.get(vttPath, { responseType: "text" }); - - const sprites: ISceneSpriteInfo[] = []; - - const parser = new WebVTT.Parser(window, WebVTT.StringDecoder()); - parser.oncue = (cue: VTTCue) => { - const match = cue.text.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i); - if (!match) return; - - sprites.push({ - url: new URL(match[1], vttPath).href, - start: cue.startTime, - end: cue.endTime, - x: Number(match[2]), - y: Number(match[3]), - w: Number(match[4]), - h: Number(match[5]), - }); - }; - parser.parse(response.data); - parser.flush(); - - return sprites; -} - export const ScenePlayerScrubber: React.FC = ({ file, scene, @@ -119,34 +82,32 @@ export const ScenePlayerScrubber: React.FC = ({ [onSeek, file.duration, scrubWidth] ); + const spriteInfo = useSpriteInfo(scene.paths.vtt ?? undefined); const [spriteItems, setSpriteItems] = useState(); useEffect(() => { - if (!scene.paths.vtt) return; - fetchSpriteInfo(scene.paths.vtt).then((sprites) => { - if (!sprites) return; - let totalWidth = 0; - const newSprites = sprites?.map((sprite, index) => { - totalWidth += sprite.w; - const left = sprite.w * index; - const style = { - width: `${sprite.w}px`, - height: `${sprite.h}px`, - backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, - backgroundImage: `url(${sprite.url})`, - left: `${left}px`, - }; - const start = TextUtils.secondsToTimestamp(sprite.start); - const end = TextUtils.secondsToTimestamp(sprite.end); - return { - style, - time: `${start} - ${end}`, - }; - }); - setScrubWidth(totalWidth); - setSpriteItems(newSprites); + if (!spriteInfo) return; + let totalWidth = 0; + const newSprites = spriteInfo?.map((sprite, index) => { + totalWidth += sprite.w; + const left = sprite.w * index; + const style = { + width: `${sprite.w}px`, + height: `${sprite.h}px`, + backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + backgroundImage: `url(${sprite.url})`, + left: `${left}px`, + }; + const start = TextUtils.secondsToTimestamp(sprite.start); + const end = TextUtils.secondsToTimestamp(sprite.end); + return { + style, + time: `${start} - ${end}`, + }; }); - }, [scene]); + setScrubWidth(totalWidth); + setSpriteItems(newSprites); + }, [spriteInfo]); useEffect(() => { const onResize = (entries: ResizeObserverEntry[]) => { diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx new file mode 100644 index 00000000000..52be78c7b67 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -0,0 +1,173 @@ +import React, { useMemo } from "react"; +import { useDebounce } from "src/hooks/debounce"; +import { useSpriteInfo } from "src/hooks/sprite"; +import TextUtils from "src/utils/text"; + +interface IHoverScrubber { + totalSprites: number; + activeIndex: number | undefined; + setActiveIndex: (index: number | undefined) => void; + onClick?: (index: number) => void; +} + +const HoverScrubber: React.FC = ({ + totalSprites, + activeIndex, + setActiveIndex, + onClick, +}) => { + function getActiveIndex(e: React.MouseEvent) { + const { width } = e.currentTarget.getBoundingClientRect(); + const x = e.nativeEvent.offsetX; + + return Math.floor((x / width) * totalSprites); + } + + function onMouseMove(e: React.MouseEvent) { + const relatedTarget = e.currentTarget; + + if (relatedTarget !== e.target) return; + + setActiveIndex(getActiveIndex(e)); + } + + function onMouseLeave() { + setActiveIndex(undefined); + } + + function onScrubberClick(e: React.MouseEvent) { + if (!onClick) return; + + const relatedTarget = e.currentTarget; + + if (relatedTarget !== e.target) return; + + e.preventDefault(); + onClick(getActiveIndex(e)); + } + + const indicatorStyle = useMemo(() => { + if (activeIndex === undefined) return {}; + + const width = (activeIndex / totalSprites) * 100; + + return { + width: `${width}%`, + }; + }, [activeIndex, totalSprites]); + + return ( +
+
+
+ {activeIndex !== undefined && ( +
+ )} +
+
+ ); +}; + +interface IScenePreviewProps { + vttPath: string | undefined; + onClick?: (timestamp: number) => void; +} + +function scaleToFit(dimensions: { w: number; h: number }, bounds: DOMRect) { + const rw = bounds.width / dimensions.w; + const rh = bounds.height / dimensions.h; + + // for consistency, use max by default and min for portrait + if (dimensions.w > dimensions.h) { + return Math.max(rw, rh); + } + + return Math.min(rw, rh); +} + +export const PreviewScrubber: React.FC = ({ + vttPath, + onClick, +}) => { + const imageParentRef = React.useRef(null); + + const [activeIndex, setActiveIndex] = React.useState(); + + const debounceSetActiveIndex = useDebounce( + setActiveIndex, + [setActiveIndex], + 10 + ); + + const spriteInfo = useSpriteInfo(vttPath); + + const style = useMemo(() => { + if (!spriteInfo || activeIndex === undefined || !imageParentRef.current) { + return {}; + } + + const sprite = spriteInfo[activeIndex]; + + const clientRect = imageParentRef.current?.getBoundingClientRect(); + const scale = clientRect ? scaleToFit(sprite, clientRect) : 1; + + return { + backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + backgroundImage: `url(${sprite.url})`, + width: `${sprite.w}px`, + height: `${sprite.h}px`, + transform: `scale(${scale})`, + }; + }, [spriteInfo, activeIndex, imageParentRef]); + + const currentTime = useMemo(() => { + if (!spriteInfo || activeIndex === undefined) { + return undefined; + } + + const sprite = spriteInfo[activeIndex]; + + const start = TextUtils.secondsToTimestamp(sprite.start); + + return start; + }, [activeIndex, spriteInfo]); + + function onScrubberClick(index: number) { + if (!spriteInfo || !onClick) { + return; + } + + const sprite = spriteInfo[index]; + + onClick(sprite.start); + } + + if (!spriteInfo) return null; + + return ( +
+ {activeIndex !== undefined && spriteInfo && ( +
+
+ {currentTime !== undefined && ( +
{currentTime}
+ )} +
+ )} + debounceSetActiveIndex(i)} + onClick={onScrubberClick} + /> +
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 190c4b4697f..b01cf698faf 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; -import { Link } from "react-router-dom"; +import { Link, useHistory } from "react-router-dom"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; @@ -25,12 +25,15 @@ import { faTag, } from "@fortawesome/free-solid-svg-icons"; import { objectPath, objectTitle } from "src/core/files"; +import { PreviewScrubber } from "./PreviewScrubber"; interface IScenePreviewProps { isPortrait: boolean; image?: string; video?: string; soundActive: boolean; + vttPath?: string; + onScrubberClick?: (timestamp: number) => void; } export const ScenePreview: React.FC = ({ @@ -38,6 +41,8 @@ export const ScenePreview: React.FC = ({ video, isPortrait, soundActive, + vttPath, + onScrubberClick, }) => { const videoEl = useRef(null); @@ -72,6 +77,7 @@ export const ScenePreview: React.FC = ({ ref={videoEl} src={video} /> +
); }; @@ -90,6 +96,7 @@ interface ISceneCardProps { export const SceneCard: React.FC = ( props: ISceneCardProps ) => { + const history = useHistory(); const { configuration } = React.useContext(ConfigurationContext); const file = useMemo( @@ -383,6 +390,18 @@ export const SceneCard: React.FC = ( }) : `/scenes/${props.scene.id}`; + function onScrubberClick(timestamp: number) { + const link = props.queue + ? props.queue.makeLink(props.scene.id, { + sceneIndex: props.index, + continue: cont, + start: timestamp, + }) + : `/scenes/${props.scene.id}?t=${timestamp}`; + + history.push(link); + } + return ( = ( video={props.scene.paths.preview ?? undefined} isPortrait={isPortrait()} soundActive={configuration?.interface?.soundOnPreview ?? false} + vttPath={props.scene.paths.vtt ?? undefined} + onScrubberClick={onScrubberClick} /> {maybeRenderSceneSpecsOverlay()} diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 31e5de8d1fc..a2c74cb8895 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -643,3 +643,66 @@ input[type="range"].blue-slider { .scrape-dialog .rating-number.disabled { padding-left: 0.5em; } + +.preview-scrubber { + height: 100%; + position: absolute; + width: 100%; + + .scene-card-preview-image { + align-items: center; + display: flex; + justify-content: center; + overflow: hidden; + } + + .scrubber-image { + height: 100%; + width: 100%; + } + + .scrubber-timestamp { + bottom: calc(20px + 0.25rem); + font-weight: 400; + opacity: 0.75; + position: absolute; + right: 0.7rem; + text-shadow: 0 0 3px #000; + } +} + +.hover-scrubber { + bottom: 0; + height: 20px; + overflow: hidden; + position: absolute; + width: 100%; + + .hover-scrubber-area { + cursor: col-resize; + height: 100%; + position: absolute; + width: 100%; + z-index: 1; + } + + .hover-scrubber-indicator { + background-color: rgba(255, 255, 255, 0.1); + bottom: -100%; + height: 100%; + position: absolute; + transition: bottom 0.2s ease-in-out; + width: 100%; + + .hover-scrubber-indicator-marker { + background-color: rgba(255, 0, 0, 0.5); + bottom: 0; + height: 5px; + position: absolute; + } + } + + &:hover .hover-scrubber-indicator { + bottom: 0; + } +} diff --git a/ui/v2.5/src/hooks/sprite.ts b/ui/v2.5/src/hooks/sprite.ts new file mode 100644 index 00000000000..8d66c2fa568 --- /dev/null +++ b/ui/v2.5/src/hooks/sprite.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { WebVTT } from "videojs-vtt.js"; + +export interface ISceneSpriteInfo { + url: string; + start: number; + end: number; + x: number; + y: number; + w: number; + h: number; +} + +function getSpriteInfo(vttPath: string, response: string) { + const sprites: ISceneSpriteInfo[] = []; + + const parser = new WebVTT.Parser(window, WebVTT.StringDecoder()); + parser.oncue = (cue: VTTCue) => { + const match = cue.text.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i); + if (!match) return; + + sprites.push({ + url: new URL(match[1], vttPath).href, + start: cue.startTime, + end: cue.endTime, + x: Number(match[2]), + y: Number(match[3]), + w: Number(match[4]), + h: Number(match[5]), + }); + }; + parser.parse(response); + parser.flush(); + + return sprites; +} + +export function useSpriteInfo(vttPath: string | undefined) { + const [spriteInfo, setSpriteInfo] = useState< + ISceneSpriteInfo[] | undefined + >(); + + useEffect(() => { + if (!vttPath) { + setSpriteInfo(undefined); + return; + } + + fetch(vttPath).then((response) => { + if (!response.ok) { + setSpriteInfo(undefined); + return; + } + + response.text().then((text) => { + setSpriteInfo(getSpriteInfo(vttPath, text)); + }); + }); + }, [vttPath]); + + return spriteInfo; +} diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts index de9cf2bbee2..14f81df5925 100644 --- a/ui/v2.5/src/models/sceneQueue.ts +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -9,6 +9,7 @@ export interface IPlaySceneOptions { newPage?: number; autoPlay?: boolean; continue?: boolean; + start?: number; } export class SceneQueue { @@ -117,6 +118,9 @@ export class SceneQueue { if (options.continue !== undefined) { params.push("continue=" + options.continue); } + if (options.start !== undefined) { + params.push("t=" + options.start); + } return `/scenes/${sceneID}${params.length ? "?" + params.join("&") : ""}`; } } From e40b3d78b2932c7bc8733e0c088584915760a026 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 24 Aug 2023 11:15:49 +1000 Subject: [PATCH 07/16] Performer select refactor (#4013) * Overhaul performer select * Add interface to load performers by id * Add Performer ID select and replace existing --- graphql/documents/data/performer-slim.graphql | 7 + graphql/documents/queries/misc.graphql | 9 - graphql/documents/queries/performer.graphql | 24 +- graphql/schema/schema.graphql | 5 +- internal/api/resolver_query_find_performer.go | 15 +- .../GalleryDetails/GalleryEditPanel.tsx | 39 ++- .../Images/ImageDetails/ImageEditPanel.tsx | 33 ++- .../components/Performers/PerformerSelect.tsx | 241 ++++++++++++++++ ui/v2.5/src/components/Performers/styles.scss | 4 + .../Scenes/SceneDetails/SceneEditPanel.tsx | 38 ++- .../src/components/Shared/FilterSelect.tsx | 257 ++++++++++++++++++ ui/v2.5/src/components/Shared/Select.tsx | 150 +--------- ui/v2.5/src/components/Tagger/queries.ts | 25 -- .../Tagger/scenes/PerformerResult.tsx | 26 +- ui/v2.5/src/core/StashService.ts | 20 +- 15 files changed, 667 insertions(+), 226 deletions(-) create mode 100644 ui/v2.5/src/components/Performers/PerformerSelect.tsx create mode 100644 ui/v2.5/src/components/Shared/FilterSelect.tsx diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 65019b98b52..5fbd1a2eb6d 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -34,3 +34,10 @@ fragment SlimPerformerData on Performer { death_date weight } + +fragment SelectPerformerData on Performer { + id + name + disambiguation + alias_list +} diff --git a/graphql/documents/queries/misc.graphql b/graphql/documents/queries/misc.graphql index 791392fb00d..61354be534d 100644 --- a/graphql/documents/queries/misc.graphql +++ b/graphql/documents/queries/misc.graphql @@ -6,15 +6,6 @@ query MarkerStrings($q: String, $sort: String) { } } -query AllPerformersForFilter { - allPerformers { - id - name - disambiguation - alias_list - } -} - query AllStudiosForFilter { allStudios { id diff --git a/graphql/documents/queries/performer.graphql b/graphql/documents/queries/performer.graphql index cc25752ac4a..3c3f689c326 100644 --- a/graphql/documents/queries/performer.graphql +++ b/graphql/documents/queries/performer.graphql @@ -1,8 +1,13 @@ query FindPerformers( $filter: FindFilterType $performer_filter: PerformerFilterType + $performer_ids: [Int!] ) { - findPerformers(filter: $filter, performer_filter: $performer_filter) { + findPerformers( + filter: $filter + performer_filter: $performer_filter + performer_ids: $performer_ids + ) { count performers { ...PerformerData @@ -15,3 +20,20 @@ query FindPerformer($id: ID!) { ...PerformerData } } + +query FindPerformersForSelect( + $filter: FindFilterType + $performer_filter: PerformerFilterType + $performer_ids: [Int!] +) { + findPerformers( + filter: $filter + performer_filter: $performer_filter + performer_ids: $performer_ids + ) { + count + performers { + ...SelectPerformerData + } + } +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 52f97adab31..4c011ad0db2 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -60,6 +60,7 @@ type Query { findPerformers( performer_filter: PerformerFilterType filter: FindFilterType + performer_ids: [Int!] ): FindPerformersResultType! "Find a studio by ID" @@ -223,11 +224,13 @@ type Query { allSceneMarkers: [SceneMarker!]! allImages: [Image!]! allGalleries: [Gallery!]! - allPerformers: [Performer!]! allStudios: [Studio!]! allMovies: [Movie!]! allTags: [Tag!]! + # @deprecated + allPerformers: [Performer!]! + # Get everything with minimal metadata # Version diff --git a/internal/api/resolver_query_find_performer.go b/internal/api/resolver_query_find_performer.go index 437ac8fcf04..a47b7a18dc5 100644 --- a/internal/api/resolver_query_find_performer.go +++ b/internal/api/resolver_query_find_performer.go @@ -23,9 +23,19 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode return ret, nil } -func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType) (ret *FindPerformersResultType, err error) { +func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int) (ret *FindPerformersResultType, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - performers, total, err := r.repository.Performer.Query(ctx, performerFilter, filter) + var performers []*models.Performer + var err error + var total int + + if len(performerIDs) > 0 { + performers, err = r.repository.Performer.FindMany(ctx, performerIDs) + total = len(performers) + } else { + performers, total, err = r.repository.Performer.Query(ctx, performerFilter, filter) + } + if err != nil { return err } @@ -34,6 +44,7 @@ func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *mod Count: total, Performers: performers, } + return nil }); err != nil { return nil, err diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index c0d037661f3..2ae7f44e5ff 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -19,7 +19,6 @@ import { mutateReloadScrapers, } from "src/core/StashService"; import { - PerformerSelect, TagSelect, SceneSelect, StudioSelect, @@ -39,6 +38,10 @@ import { ConfigurationContext } from "src/hooks/Config"; import isEqual from "lodash-es/isEqual"; import { DateInput } from "src/components/Shared/DateInput"; import { handleUnsavedChanges } from "src/utils/navigation"; +import { + Performer, + PerformerSelect, +} from "src/components/Performers/PerformerSelect"; interface IProps { gallery: Partial; @@ -62,6 +65,8 @@ export const GalleryEditPanel: React.FC = ({ })) ); + const [performers, setPerformers] = useState([]); + const isNew = gallery.id === undefined; const { configuration: stashConfig } = React.useContext(ConfigurationContext); @@ -139,12 +144,24 @@ export const GalleryEditPanel: React.FC = ({ ); } + function onSetPerformers(items: Performer[]) { + setPerformers(items); + formik.setFieldValue( + "performer_ids", + items.map((item) => item.id) + ); + } + useRatingKeybinds( isVisible, stashConfig?.ui?.ratingSystemOptions?.type, setRating ); + useEffect(() => { + setPerformers(gallery.performers ?? []); + }, [gallery.performers]); + useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { @@ -309,8 +326,15 @@ export const GalleryEditPanel: React.FC = ({ }); if (idPerfs.length > 0) { - const newIds = idPerfs.map((p) => p.stored_id); - formik.setFieldValue("performer_ids", newIds as string[]); + onSetPerformers( + idPerfs.map((p) => { + return { + id: p.stored_id!, + name: p.name ?? "", + alias_list: [], + }; + }) + ); } } @@ -472,13 +496,8 @@ export const GalleryEditPanel: React.FC = ({ - formik.setFieldValue( - "performer_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.performer_ids} + onSelect={onSetPerformers} + values={performers} /> diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 23e1a899679..a684e29dabf 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -4,11 +4,7 @@ import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; -import { - PerformerSelect, - TagSelect, - StudioSelect, -} from "src/components/Shared/Select"; +import { TagSelect, StudioSelect } from "src/components/Shared/Select"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { URLField } from "src/components/Shared/URLField"; import { useToast } from "src/hooks/Toast"; @@ -20,6 +16,10 @@ import { useRatingKeybinds } from "src/hooks/keybinds"; import { ConfigurationContext } from "src/hooks/Config"; import isEqual from "lodash-es/isEqual"; import { DateInput } from "src/components/Shared/DateInput"; +import { + Performer, + PerformerSelect, +} from "src/components/Performers/PerformerSelect"; interface IProps { image: GQL.ImageDataFragment; @@ -42,6 +42,8 @@ export const ImageEditPanel: React.FC = ({ const { configuration } = React.useContext(ConfigurationContext); + const [performers, setPerformers] = useState([]); + const schema = yup.object({ title: yup.string().ensure(), url: yup.string().ensure(), @@ -87,12 +89,24 @@ export const ImageEditPanel: React.FC = ({ formik.setFieldValue("rating100", v); } + function onSetPerformers(items: Performer[]) { + setPerformers(items); + formik.setFieldValue( + "performer_ids", + items.map((item) => item.id) + ); + } + useRatingKeybinds( true, configuration?.ui?.ratingSystemOptions?.type, setRating ); + useEffect(() => { + setPerformers(image.performers ?? []); + }, [image.performers]); + useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { @@ -249,13 +263,8 @@ export const ImageEditPanel: React.FC = ({ - formik.setFieldValue( - "performer_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.performer_ids} + onSelect={onSetPerformers} + values={performers} /> diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx new file mode 100644 index 00000000000..c721d652deb --- /dev/null +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -0,0 +1,241 @@ +import React, { useEffect, useState } from "react"; +import { + OptionProps, + components as reactSelectComponents, + MultiValueGenericProps, + SingleValueProps, +} from "react-select"; + +import * as GQL from "src/core/generated-graphql"; +import { + usePerformerCreate, + queryFindPerformersByIDForSelect, + queryFindPerformersForSelect, +} from "src/core/StashService"; +import { ConfigurationContext } from "src/hooks/Config"; +import { useIntl } from "react-intl"; +import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { + FilterSelectComponent, + IFilterIDProps, + IFilterProps, + IFilterValueProps, + Option as SelectOption, +} from "../Shared/FilterSelect"; +import { useCompare } from "src/hooks/state"; + +export type SelectObject = { + id: string; + name?: string | null; + title?: string | null; +}; + +export type Performer = Pick< + GQL.Performer, + "id" | "name" | "alias_list" | "disambiguation" +>; +type Option = SelectOption; + +export const PerformerSelect: React.FC< + IFilterProps & IFilterValueProps +> = (props) => { + const [createPerformer] = usePerformerCreate(); + + const { configuration } = React.useContext(ConfigurationContext); + const intl = useIntl(); + const maxOptionsShown = + (configuration?.ui as IUIConfig).maxOptionsShown ?? defaultMaxOptionsShown; + const defaultCreatable = + !configuration?.interface.disableDropdownCreate.performer ?? true; + + async function loadPerformers(input: string): Promise { + const filter = new ListFilterModel(GQL.FilterMode.Performers); + filter.searchTerm = input; + filter.currentPage = 1; + filter.itemsPerPage = maxOptionsShown; + filter.sortBy = "name"; + filter.sortDirection = GQL.SortDirectionEnum.Asc; + const query = await queryFindPerformersForSelect(filter); + return query.data.findPerformers.performers.map((performer) => ({ + value: performer.id, + object: performer, + })); + } + + const PerformerOption: React.FC> = ( + optionProps + ) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + let { name } = object; + + // if name does not match the input value but an alias does, show the alias + const { inputValue } = optionProps.selectProps; + let alias: string | undefined = ""; + if (!name.toLowerCase().includes(inputValue.toLowerCase())) { + alias = object.alias_list?.find((a) => + a.toLowerCase().includes(inputValue.toLowerCase()) + ); + } + + thisOptionProps = { + ...optionProps, + children: ( + + {name} + {object.disambiguation && ( + {` (${object.disambiguation})`} + )} + {alias && {` (${alias})`}} + + ), + }; + + return ; + }; + + const PerformerMultiValueLabel: React.FC< + MultiValueGenericProps + > = (optionProps) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + thisOptionProps = { + ...optionProps, + children: object.name, + }; + + return ; + }; + + const PerformerValueLabel: React.FC> = ( + optionProps + ) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + thisOptionProps = { + ...optionProps, + children: object.name, + }; + + return ; + }; + + const onCreate = async (name: string) => { + const result = await createPerformer({ + variables: { input: { name } }, + }); + return { + value: result.data!.performerCreate!.id, + item: result.data!.performerCreate!, + message: "Created performer", + }; + }; + + const getNamedObject = (id: string, name: string) => { + return { + id, + name, + alias_list: [], + }; + }; + + const isValidNewOption = (inputValue: string, options: Performer[]) => { + if (!inputValue) { + return false; + } + + if ( + options.some((o) => { + return ( + o.name.toLowerCase() === inputValue.toLowerCase() || + o.alias_list?.some( + (a) => a.toLowerCase() === inputValue.toLowerCase() + ) + ); + }) + ) { + return false; + } + + return true; + }; + + return ( + + {...props} + loadOptions={loadPerformers} + getNamedObject={getNamedObject} + isValidNewOption={isValidNewOption} + components={{ + Option: PerformerOption, + MultiValueLabel: PerformerMultiValueLabel, + SingleValue: PerformerValueLabel, + }} + isMulti={props.isMulti ?? false} + creatable={props.creatable ?? defaultCreatable} + onCreate={onCreate} + placeholder={ + props.noSelectionString ?? + intl.formatMessage( + { id: "actions.select_entity" }, + { entityType: intl.formatMessage({ id: "performer" }) } + ) + } + /> + ); +}; + +export const PerformerIDSelect: React.FC< + IFilterProps & IFilterIDProps +> = (props) => { + const { ids, onSelect: onSelectValues } = props; + + const [values, setValues] = useState([]); + const idsChanged = useCompare(ids); + + function onSelect(items: Performer[]) { + setValues(items); + onSelectValues?.(items); + } + + async function loadObjectsByID(idsToLoad: string[]): Promise { + const performerIDs = idsToLoad.map((id) => parseInt(id)); + const query = await queryFindPerformersByIDForSelect(performerIDs); + const { performers: loadedPerformers } = query.data.findPerformers; + + return loadedPerformers; + } + + useEffect(() => { + if (!idsChanged) { + return; + } + + if (!ids || ids?.length === 0) { + setValues([]); + return; + } + + // load the values if we have ids and they haven't been loaded yet + const filteredValues = values.filter((v) => ids.includes(v.id.toString())); + if (filteredValues.length === ids.length) { + return; + } + + const load = async () => { + const items = await loadObjectsByID(ids); + setValues(items); + }; + + load(); + }, [ids, idsChanged, values]); + + return ; +}; diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 4a0ec524ac2..4451728043b 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -223,3 +223,7 @@ content: ""; } } + +.react-select .alias { + font-weight: bold; +} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 15989fa3cb6..741f221454d 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -20,7 +20,6 @@ import { queryScrapeSceneQueryFragment, } from "src/core/StashService"; import { - PerformerSelect, TagSelect, StudioSelect, GallerySelect, @@ -51,6 +50,10 @@ import { useRatingKeybinds } from "src/hooks/keybinds"; import { lazyComponent } from "src/utils/lazyComponent"; import isEqual from "lodash-es/isEqual"; import { DateInput } from "src/components/Shared/DateInput"; +import { + Performer, + PerformerSelect, +} from "src/components/Performers/PerformerSelect"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -78,6 +81,7 @@ export const SceneEditPanel: React.FC = ({ const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( [] ); + const [performers, setPerformers] = useState([]); const Scrapers = useListSceneScrapers(); const [fragmentScrapers, setFragmentScrapers] = useState([]); @@ -98,6 +102,10 @@ export const SceneEditPanel: React.FC = ({ ); }, [scene.galleries]); + useEffect(() => { + setPerformers(scene.performers ?? []); + }, [scene.performers]); + const { configuration: stashConfig } = React.useContext(ConfigurationContext); // Network state @@ -218,6 +226,14 @@ export const SceneEditPanel: React.FC = ({ ); } + function onSetPerformers(items: Performer[]) { + setPerformers(items); + formik.setFieldValue( + "performer_ids", + items.map((item) => item.id) + ); + } + useRatingKeybinds( isVisible, stashConfig?.ui?.ratingSystemOptions?.type, @@ -581,8 +597,15 @@ export const SceneEditPanel: React.FC = ({ }); if (idPerfs.length > 0) { - const newIds = idPerfs.map((p) => p.stored_id); - formik.setFieldValue("performer_ids", newIds as string[]); + onSetPerformers( + idPerfs.map((p) => { + return { + id: p.stored_id!, + name: p.name ?? "", + alias_list: [], + }; + }) + ); } } @@ -852,13 +875,8 @@ export const SceneEditPanel: React.FC = ({ - formik.setFieldValue( - "performer_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.performer_ids} + onSelect={onSetPerformers} + values={performers} /> diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx new file mode 100644 index 00000000000..faf14a7fc28 --- /dev/null +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -0,0 +1,257 @@ +import React, { useMemo, useState } from "react"; +import { + OnChangeValue, + StylesConfig, + GroupBase, + OptionsOrGroups, + Options, +} from "react-select"; +import AsyncSelect from "react-select/async"; +import AsyncCreatableSelect, { + AsyncCreatableProps, +} from "react-select/async-creatable"; + +import { useToast } from "src/hooks/Toast"; +import { useDebounce } from "src/hooks/debounce"; + +interface IHasID { + id: string; +} + +export type Option = { value: string; object: T }; + +interface ISelectProps + extends AsyncCreatableProps, IsMulti, GroupBase>> { + selectedOptions?: OnChangeValue, IsMulti>; + creatable?: boolean; + isLoading?: boolean; + isDisabled?: boolean; + placeholder?: string; + showDropdown?: boolean; + groupHeader?: string; + noOptionsMessageText?: string | null; +} + +interface IFilterSelectProps + extends Pick< + ISelectProps, + | "selectedOptions" + | "isLoading" + | "isMulti" + | "components" + | "placeholder" + | "closeMenuOnSelect" + > {} + +const getSelectedItems = ( + selectedItems: OnChangeValue, boolean> +) => { + if (Array.isArray(selectedItems)) { + return selectedItems; + } else if (selectedItems) { + return [selectedItems]; + } else { + return []; + } +}; + +const SelectComponent = ( + props: ISelectProps +) => { + const { + selectedOptions, + isLoading, + isDisabled = false, + creatable = false, + components, + placeholder, + showDropdown = true, + noOptionsMessageText: noOptionsMessage = "None", + } = props; + + const styles: StylesConfig, IsMulti> = { + option: (base) => ({ + ...base, + color: "#000", + }), + container: (base, state) => ({ + ...base, + zIndex: state.isFocused ? 10 : base.zIndex, + }), + multiValueRemove: (base, state) => ({ + ...base, + color: state.isFocused ? base.color : "#333333", + }), + }; + + const componentProps = { + ...props, + styles, + defaultOptions: true, + value: selectedOptions, + className: "react-select", + classNamePrefix: "react-select", + noOptionsMessage: () => noOptionsMessage, + placeholder: isDisabled ? "" : placeholder, + components: { + ...components, + IndicatorSeparator: () => null, + ...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }), + ...(isDisabled && { MultiValueRemove: () => null }), + }, + }; + + return creatable ? ( + + ) : ( + + ); +}; + +export interface IFilterValueProps { + values?: T[]; + onSelect?: (item: T[]) => void; +} + +export interface IFilterProps { + noSelectionString?: string; + className?: string; + isMulti?: boolean; + isClearable?: boolean; + isDisabled?: boolean; + creatable?: boolean; + menuPortalTarget?: HTMLElement | null; +} + +export interface IFilterComponentProps extends IFilterProps { + loadOptions: (inputValue: string) => Promise[]>; + onCreate?: ( + name: string + ) => Promise<{ value: string; item: T; message: string }>; + getNamedObject: (id: string, name: string) => T; + isValidNewOption: (inputValue: string, options: T[]) => boolean; +} + +export const FilterSelectComponent = < + T extends IHasID, + IsMulti extends boolean +>( + props: IFilterValueProps & + IFilterComponentProps & + IFilterSelectProps +) => { + const { + values, + isMulti, + onSelect, + isValidNewOption, + getNamedObject, + loadOptions, + } = props; + const [loading, setLoading] = useState(false); + const Toast = useToast(); + + const selectedOptions = useMemo(() => { + if (isMulti && values) { + return values.map( + (value) => + ({ + object: value, + value: value.id, + } as Option) + ) as unknown as OnChangeValue, IsMulti>; + } + + if (values?.length) { + return { + object: values[0], + value: values[0].id, + } as OnChangeValue, IsMulti>; + } + }, [values, isMulti]); + + const onChange = (selectedItems: OnChangeValue, boolean>) => { + const selected = getSelectedItems(selectedItems); + + onSelect?.(selected.map((item) => item.object)); + }; + + const onCreate = async (name: string) => { + try { + setLoading(true); + const { value, item: newItem, message } = await props.onCreate!(name); + const newItemOption = { + object: newItem, + value, + } as Option; + if (!isMulti) { + onChange(newItemOption); + } else { + const o = (selectedOptions ?? []) as Option[]; + onChange([...o, newItemOption]); + } + + setLoading(false); + Toast.success({ + content: ( + + {message}: {name} + + ), + }); + } catch (e) { + Toast.error(e); + } + }; + + const getNewOptionData = ( + inputValue: string, + optionLabel: React.ReactNode + ) => { + return { + value: "", + object: getNamedObject("", optionLabel as string), + }; + }; + + const validNewOption = ( + inputValue: string, + value: Options>, + options: OptionsOrGroups, GroupBase>> + ) => { + return isValidNewOption( + inputValue, + (options as Options>).map((o) => o.object) + ); + }; + + const debounceDelay = 100; + const debounceLoadOptions = useDebounce( + (inputValue, callback) => { + loadOptions(inputValue).then(callback); + }, + [loadOptions], + debounceDelay + ); + + return ( + + {...props} + loadOptions={debounceLoadOptions} + isLoading={props.isLoading || loading} + onChange={onChange} + selectedOptions={selectedOptions} + onCreateOption={props.creatable ? onCreate : undefined} + getNewOptionData={getNewOptionData} + isValidNewOption={validNewOption} + /> + ); +}; + +export interface IFilterIDProps { + ids?: string[]; + onSelect?: (item: T[]) => void; +} diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 6ae86b05cbe..f7c26460993 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -16,11 +16,9 @@ import { useAllTagsForFilter, useAllMoviesForFilter, useAllStudiosForFilter, - useAllPerformersForFilter, useMarkerStrings, useTagCreate, useStudioCreate, - usePerformerCreate, useMovieCreate, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; @@ -33,6 +31,7 @@ import { TagPopover } from "../Tags/TagPopover"; import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; import { useDebouncedSetState } from "src/hooks/debounce"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { PerformerIDSelect } from "../Performers/PerformerSelect"; export type SelectObject = { id: string; @@ -533,152 +532,7 @@ export const MarkerTitleSuggest: React.FC = (props) => { }; export const PerformerSelect: React.FC = (props) => { - const [performerAliases, setPerformerAliases] = useState< - Record - >({}); - const [performerDisambiguations, setPerformerDisambiguations] = useState< - Record - >({}); - const [allAliases, setAllAliases] = useState([]); - const { data, loading } = useAllPerformersForFilter(); - const [createPerformer] = usePerformerCreate(); - - const { configuration } = React.useContext(ConfigurationContext); - const intl = useIntl(); - const defaultCreatable = - !configuration?.interface.disableDropdownCreate.performer ?? true; - - const performers = useMemo( - () => data?.allPerformers ?? [], - [data?.allPerformers] - ); - - useEffect(() => { - // build the tag aliases map - const newAliases: Record = {}; - const newDisambiguations: Record = {}; - const newAll: string[] = []; - performers.forEach((t) => { - if (t.alias_list.length) { - newAliases[t.id] = t.alias_list; - } - newAll.push(...t.alias_list); - if (t.disambiguation) { - newDisambiguations[t.id] = t.disambiguation; - } - }); - setPerformerAliases(newAliases); - setAllAliases(newAll); - setPerformerDisambiguations(newDisambiguations); - }, [performers]); - - const PerformerOption: React.FC> = ( - optionProps - ) => { - const { inputValue } = optionProps.selectProps; - - let thisOptionProps = optionProps; - - let { label } = optionProps.data; - const id = Number(optionProps.data.value); - - if (id && performerDisambiguations[id]) { - label += ` (${performerDisambiguations[id]})`; - } - - if ( - inputValue && - !optionProps.label.toLowerCase().includes(inputValue.toLowerCase()) - ) { - // must be alias - label += " (alias)"; - } - - if (label != optionProps.data.label) { - thisOptionProps = { - ...optionProps, - children: label, - }; - } - - return ; - }; - - const filterOption = (option: Option, rawInput: string): boolean => { - if (!rawInput) { - return true; - } - - const input = rawInput.toLowerCase(); - const optionVal = option.label.toLowerCase(); - - if (optionVal.includes(input)) { - return true; - } - - // search for performer aliases - const aliases = performerAliases[option.value]; - return aliases && aliases.some((a) => a.toLowerCase().includes(input)); - }; - - const isValidNewOption = ( - inputValue: string, - value: Options