From 2afb467bb10111a35fe238f66f8c341bcc22b1ab Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 23 Mar 2022 08:18:12 +1100 Subject: [PATCH] Persist lightbox settings (#2406) * Persist lightbox settings in local forage * Add lightbox settings to backend * Add lightbox settings to interface settings page --- graphql/documents/data/config.graphql | 8 +- graphql/schema/types/config.graphql | 37 +++- internal/api/resolver_mutation_configure.go | 22 ++- internal/api/resolver_query_configuration.go | 5 +- internal/manager/config/config.go | 68 +++++++- .../manager/config/config_concurrency_test.go | 3 +- .../components/Changelog/versions/v0140.md | 1 + .../SettingsInterfacePanel.tsx | 93 +++++++++- ui/v2.5/src/core/enums.ts | 27 +++ ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 163 +++++++++++------- ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx | 38 ++-- ui/v2.5/src/hooks/ListHook.tsx | 20 ++- ui/v2.5/src/hooks/LocalForage.ts | 8 +- ui/v2.5/src/locales/en-GB.json | 3 + 14 files changed, 382 insertions(+), 114 deletions(-) create mode 100644 ui/v2.5/src/core/enums.ts diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 2421bdb2f..a4b9ff9cb 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -62,7 +62,13 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { css cssEnabled language - slideshowDelay + imageLightbox { + slideshowDelay + displayMode + scaleUp + resetZoomOnNav + scrollMode + } disableDropdownCreate { performer tag diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 2b6fdbd5d..f2f98f393 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -196,6 +196,33 @@ input ConfigDisableDropdownCreateInput { studio: Boolean } +enum ImageLightboxDisplayMode { + ORIGINAL + FIT_XY + FIT_X +} + +enum ImageLightboxScrollMode { + ZOOM + PAN_Y +} + +input ConfigImageLightboxInput { + slideshowDelay: Int + displayMode: ImageLightboxDisplayMode + scaleUp: Boolean + resetZoomOnNav: Boolean + scrollMode: ImageLightboxScrollMode +} + +type ConfigImageLightboxResult { + slideshowDelay: Int + displayMode: ImageLightboxDisplayMode + scaleUp: Boolean + resetZoomOnNav: Boolean + scrollMode: ImageLightboxScrollMode +} + input ConfigInterfaceInput { """Ordered list of items that should be shown in the menu""" menuItems: [String!] @@ -229,9 +256,11 @@ input ConfigInterfaceInput { """Interface language""" language: String - + """Slideshow Delay""" - slideshowDelay: Int + slideshowDelay: Int @deprecated(reason: "Use imageLightbox.slideshowDelay") + + imageLightbox: ConfigImageLightboxInput """Set to true to disable creating new objects via the dropdown menus""" disableDropdownCreate: ConfigDisableDropdownCreateInput @@ -291,7 +320,9 @@ type ConfigInterfaceResult { language: String """Slideshow Delay""" - slideshowDelay: Int + slideshowDelay: Int @deprecated(reason: "Use imageLightbox.slideshowDelay") + + imageLightbox: ConfigImageLightboxResult! """Fields are true if creating via dropdown menus are disabled""" disableDropdownCreate: ConfigDisableDropdownCreate! diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index db4482e12..7ac05941f 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -286,6 +286,12 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. } } + setString := func(key string, v *string) { + if v != nil { + c.Set(key, *v) + } + } + if input.MenuItems != nil { c.Set(config.MenuItems, input.MenuItems) } @@ -316,8 +322,22 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. c.Set(config.Language, *input.Language) } + // deprecated field if input.SlideshowDelay != nil { - c.Set(config.SlideshowDelay, *input.SlideshowDelay) + c.Set(config.ImageLightboxSlideshowDelay, *input.SlideshowDelay) + } + + if input.ImageLightbox != nil { + options := input.ImageLightbox + + if options.SlideshowDelay != nil { + c.Set(config.ImageLightboxSlideshowDelay, *options.SlideshowDelay) + } + + setString(config.ImageLightboxDisplayMode, (*string)(options.DisplayMode)) + setBool(config.ImageLightboxScaleUp, options.ScaleUp) + setBool(config.ImageLightboxResetZoomOnNav, options.ResetZoomOnNav) + setString(config.ImageLightboxScrollMode, (*string)(options.ScrollMode)) } if input.CSS != nil { diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 4c8dadeae..1b91a5f0a 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -140,9 +140,9 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult { css := config.GetCSS() cssEnabled := config.GetCSSEnabled() language := config.GetLanguage() - slideshowDelay := config.GetSlideshowDelay() handyKey := config.GetHandyKey() scriptOffset := config.GetFunscriptOffset() + imageLightboxOptions := config.GetImageLightboxOptions() // FIXME - misnamed output field means we have redundant fields disableDropdownCreate := config.GetDisableDropdownCreate() @@ -163,7 +163,8 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult { CSS: &css, CSSEnabled: &cssEnabled, Language: &language, - SlideshowDelay: &slideshowDelay, + + ImageLightbox: &imageLightboxOptions, // FIXME - see above DisabledDropdownCreate: disableDropdownCreate, diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 7870041b4..818b53192 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -142,8 +142,15 @@ const ( WallPlayback = "wall_playback" defaultWallPlayback = "video" - SlideshowDelay = "slideshow_delay" - defaultSlideshowDelay = 5000 + // Image lightbox options + legacyImageLightboxSlideshowDelay = "slideshow_delay" + ImageLightboxSlideshowDelay = "image_lightbox.slideshow_delay" + ImageLightboxDisplayMode = "image_lightbox.display_mode" + ImageLightboxScaleUp = "image_lightbox.scale_up" + ImageLightboxResetZoomOnNav = "image_lightbox.reset_zoom_on_nav" + ImageLightboxScrollMode = "image_lightbox.scroll_mode" + + defaultImageLightboxSlideshowDelay = 5000 DisableDropdownCreatePerformer = "disable_dropdown_create.performer" DisableDropdownCreateStudio = "disable_dropdown_create.studio" @@ -364,6 +371,18 @@ func (i *Instance) viper(key string) *viper.Viper { return v } +// viper returns the viper instance that has the key set. Returns nil +// if no instance has the key. Assumes read lock held. +func (i *Instance) viperWith(key string) *viper.Viper { + v := i.viper(key) + + if v.IsSet(key) { + return v + } + + return nil +} + func (i *Instance) HasOverride(key string) bool { i.RLock() defer i.RUnlock() @@ -886,14 +905,49 @@ func (i *Instance) GetShowStudioAsText() bool { return i.getBool(ShowStudioAsText) } -func (i *Instance) GetSlideshowDelay() int { +func (i *Instance) getSlideshowDelay() int { + // assume have lock + + ret := defaultImageLightboxSlideshowDelay + v := i.viper(ImageLightboxSlideshowDelay) + if v.IsSet(ImageLightboxSlideshowDelay) { + ret = v.GetInt(ImageLightboxSlideshowDelay) + } else { + // fallback to old location + v := i.viper(legacyImageLightboxSlideshowDelay) + if v.IsSet(legacyImageLightboxSlideshowDelay) { + ret = v.GetInt(legacyImageLightboxSlideshowDelay) + } + } + + return ret +} + +func (i *Instance) GetImageLightboxOptions() models.ConfigImageLightboxResult { i.RLock() defer i.RUnlock() - ret := defaultSlideshowDelay - v := i.viper(SlideshowDelay) - if v.IsSet(SlideshowDelay) { - ret = v.GetInt(SlideshowDelay) + delay := i.getSlideshowDelay() + + ret := models.ConfigImageLightboxResult{ + SlideshowDelay: &delay, + } + + if v := i.viperWith(ImageLightboxDisplayMode); v != nil { + mode := models.ImageLightboxDisplayMode(v.GetString(ImageLightboxDisplayMode)) + ret.DisplayMode = &mode + } + if v := i.viperWith(ImageLightboxScaleUp); v != nil { + value := v.GetBool(ImageLightboxScaleUp) + ret.ScaleUp = &value + } + if v := i.viperWith(ImageLightboxResetZoomOnNav); v != nil { + value := v.GetBool(ImageLightboxResetZoomOnNav) + ret.ResetZoomOnNav = &value + } + if v := i.viperWith(ImageLightboxScrollMode); v != nil { + mode := models.ImageLightboxScrollMode(v.GetString(ImageLightboxScrollMode)) + ret.ScrollMode = &mode } return ret diff --git a/internal/manager/config/config_concurrency_test.go b/internal/manager/config/config_concurrency_test.go index 6db550c73..d3e0c1545 100644 --- a/internal/manager/config/config_concurrency_test.go +++ b/internal/manager/config/config_concurrency_test.go @@ -81,7 +81,8 @@ func TestConcurrentConfigAccess(t *testing.T) { i.Set(MaximumLoopDuration, i.GetMaximumLoopDuration()) i.Set(AutostartVideo, i.GetAutostartVideo()) i.Set(ShowStudioAsText, i.GetShowStudioAsText()) - i.Set(SlideshowDelay, i.GetSlideshowDelay()) + i.Set(legacyImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay) + i.Set(ImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay) i.GetCSSPath() i.GetCSS() i.Set(CSSEnabled, i.GetCSSEnabled()) diff --git a/ui/v2.5/src/components/Changelog/versions/v0140.md b/ui/v2.5/src/components/Changelog/versions/v0140.md index e9b64276c..94fdf6227 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0140.md +++ b/ui/v2.5/src/components/Changelog/versions/v0140.md @@ -1,4 +1,5 @@ ### 🎨 Improvements +* Maintain lightbox settings and add lightbox settings to Interface settings page. ([#2406](https://github.com/stashapp/stash/pull/2406)) * Image lightbox now transitions to next/previous image when scrolling in pan-Y mode. ([#2403](https://github.com/stashapp/stash/pull/2403)) * Allow customisation of UI theme color using `theme_color` property in `config.yml` ([#2365](https://github.com/stashapp/stash/pull/2365)) * Improved autotag performance. ([#2368](https://github.com/stashapp/stash/pull/2368)) diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index c4cbe38da..29b4f8fc7 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -13,6 +13,12 @@ import { } from "../Inputs"; import { SettingStateContext } from "../context"; import { DurationUtils } from "src/utils"; +import * as GQL from "src/core/generated-graphql"; +import { + imageLightboxDisplayModeIntlMap, + imageLightboxScrollModeIntlMap, +} from "src/core/enums"; +import { useInterfaceLocalForage } from "src/hooks"; const allMenuItems = [ { id: "scenes", headingID: "scenes" }, @@ -32,6 +38,28 @@ export const SettingsInterfacePanel: React.FC = () => { SettingStateContext ); + const [, setInterfaceLocalForage] = useInterfaceLocalForage(); + + function saveLightboxSettings(v: Partial) { + // save in local forage as well for consistency + setInterfaceLocalForage((prev) => { + return { + ...prev, + imageLightbox: { + ...prev.imageLightbox, + ...v, + }, + }; + }); + + saveInterface({ + imageLightbox: { + ...iface.imageLightbox, + ...v, + }, + }); + } + if (error) return

{error.message}

; if (loading) return ; @@ -195,13 +223,72 @@ export const SettingsInterfacePanel: React.FC = () => { /> - + saveInterface({ slideshowDelay: v })} + value={iface.imageLightbox?.slideshowDelay ?? undefined} + onChange={(v) => saveLightboxSettings({ slideshowDelay: v })} /> + + + saveLightboxSettings({ + displayMode: v as GQL.ImageLightboxDisplayMode, + }) + } + > + {Array.from(imageLightboxDisplayModeIntlMap.entries()).map((v) => ( + + ))} + + + saveLightboxSettings({ scaleUp: v })} + /> + + saveLightboxSettings({ resetZoomOnNav: v })} + /> + + + saveLightboxSettings({ + scrollMode: v as GQL.ImageLightboxScrollMode, + }) + } + > + {Array.from(imageLightboxScrollModeIntlMap.entries()).map((v) => ( + + ))} + diff --git a/ui/v2.5/src/core/enums.ts b/ui/v2.5/src/core/enums.ts new file mode 100644 index 000000000..c1e198dbb --- /dev/null +++ b/ui/v2.5/src/core/enums.ts @@ -0,0 +1,27 @@ +import { + ImageLightboxDisplayMode, + ImageLightboxScrollMode, +} from "../core/generated-graphql"; + +export const imageLightboxDisplayModeIntlMap = new Map< + ImageLightboxDisplayMode, + string +>([ + [ImageLightboxDisplayMode.Original, "dialogs.lightbox.display_mode.original"], + [ + ImageLightboxDisplayMode.FitXy, + "dialogs.lightbox.display_mode.fit_to_screen", + ], + [ + ImageLightboxDisplayMode.FitX, + "dialogs.lightbox.display_mode.fit_horizontally", + ], +]); + +export const imageLightboxScrollModeIntlMap = new Map< + ImageLightboxScrollMode, + string +>([ + [ImageLightboxScrollMode.Zoom, "dialogs.lightbox.scroll_mode.zoom"], + [ImageLightboxScrollMode.PanY, "dialogs.lightbox.scroll_mode.pan_y"], +]); diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 078b80b23..1a42772bf 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -15,7 +15,7 @@ import debounce from "lodash/debounce"; import { Icon, LoadingIndicator } from "src/components/Shared"; import { useInterval, usePageVisibility, useToast } from "src/hooks"; import { FormattedMessage, useIntl } from "react-intl"; -import { DisplayMode, LightboxImage, ScrollMode } from "./LightboxImage"; +import { LightboxImage } from "./LightboxImage"; import { ConfigurationContext } from "../Config"; import { Link } from "react-router-dom"; import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; @@ -27,6 +27,8 @@ import { mutateImageResetO, } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; +import { useInterfaceLocalForage } from "../LocalForage"; +import { imageLightboxDisplayModeIntlMap } from "src/core/enums"; const CLASSNAME = "Lightbox"; const CLASSNAME_HEADER = `${CLASSNAME}-header`; @@ -98,12 +100,6 @@ export const LightboxComponent: React.FC = ({ const oldImages = useRef([]); - const [displayMode, setDisplayMode] = useState(DisplayMode.FIT_XY); - const oldDisplayMode = useRef(displayMode); - - const [scaleUp, setScaleUp] = useState(false); - const [scrollMode, setScrollMode] = useState(ScrollMode.ZOOM); - const [resetZoomOnNav, setResetZoomOnNav] = useState(true); const [zoom, setZoom] = useState(1); const [resetPosition, setResetPosition] = useState(false); @@ -120,9 +116,53 @@ export const LightboxComponent: React.FC = ({ const Toast = useToast(); const intl = useIntl(); const { configuration: config } = React.useContext(ConfigurationContext); + const [ + interfaceLocalForage, + setInterfaceLocalForage, + ] = useInterfaceLocalForage(); - const userSelectedSlideshowDelayOrDefault = - config?.interface.slideshowDelay ?? DEFAULT_SLIDESHOW_DELAY; + const lightboxSettings = interfaceLocalForage.data?.imageLightbox; + + function setLightboxSettings(v: Partial) { + setInterfaceLocalForage((prev) => { + return { + ...prev, + imageLightbox: { + ...prev.imageLightbox, + ...v, + }, + }; + }); + } + + function setScaleUp(value: boolean) { + setLightboxSettings({ scaleUp: value }); + } + + function setResetZoomOnNav(v: boolean) { + setLightboxSettings({ resetZoomOnNav: v }); + } + + function setScrollMode(v: GQL.ImageLightboxScrollMode) { + setLightboxSettings({ scrollMode: v }); + } + + const slideshowDelay = + lightboxSettings?.slideshowDelay ?? + config?.interface.imageLightbox.slideshowDelay ?? + DEFAULT_SLIDESHOW_DELAY; + + function setSlideshowDelay(v: number) { + setLightboxSettings({ slideshowDelay: v }); + } + + const displayMode = + lightboxSettings?.displayMode ?? GQL.ImageLightboxDisplayMode.FitXy; + const oldDisplayMode = useRef(displayMode); + + function setDisplayMode(v: GQL.ImageLightboxDisplayMode) { + setLightboxSettings({ displayMode: v }); + } // slideshowInterval is used for controlling the logic // displaySlideshowInterval is for display purposes only @@ -131,12 +171,11 @@ export const LightboxComponent: React.FC = ({ const [slideshowInterval, setSlideshowInterval] = useState( null ); + const [ displayedSlideshowInterval, setDisplayedSlideshowInterval, - ] = useState( - (userSelectedSlideshowDelayOrDefault / SECONDS_TO_MS).toString() - ); + ] = useState(slideshowDelay.toString()); useEffect(() => { if (images !== oldImages.current && isSwitchingPage) { @@ -164,7 +203,7 @@ export const LightboxComponent: React.FC = ({ // reset zoom status // setResetZoom((r) => !r); // setZoomed(false); - if (resetZoomOnNav) { + if (lightboxSettings?.resetZoomOnNav) { setZoom(1); } setResetPosition((r) => !r); @@ -192,20 +231,20 @@ export const LightboxComponent: React.FC = ({ } oldIndex.current = index; - }, [index, images.length, resetZoomOnNav]); + }, [index, images.length, lightboxSettings?.resetZoomOnNav]); useEffect(() => { if (displayMode !== oldDisplayMode.current) { // reset zoom status // setResetZoom((r) => !r); // setZoomed(false); - if (resetZoomOnNav) { + if (lightboxSettings?.resetZoomOnNav) { setZoom(1); } setResetPosition((r) => !r); } oldDisplayMode.current = displayMode; - }, [displayMode, resetZoomOnNav]); + }, [displayMode, lightboxSettings?.resetZoomOnNav]); const selectIndex = (e: React.MouseEvent, i: number) => { setIndex(i); @@ -224,20 +263,10 @@ export const LightboxComponent: React.FC = ({ const toggleSlideshow = useCallback(() => { if (slideshowInterval) { setSlideshowInterval(null); - } else if ( - displayedSlideshowInterval !== null && - typeof displayedSlideshowInterval !== "undefined" - ) { - const intervalNumber = Number.parseInt(displayedSlideshowInterval, 10); - setSlideshowInterval(intervalNumber * SECONDS_TO_MS); } else { - setSlideshowInterval(userSelectedSlideshowDelayOrDefault); + setSlideshowInterval(slideshowDelay * SECONDS_TO_MS); } - }, [ - slideshowInterval, - userSelectedSlideshowDelayOrDefault, - displayedSlideshowInterval, - ]); + }, [slideshowInterval, slideshowDelay]); usePageVisibility(() => { toggleSlideshow(); @@ -352,10 +381,6 @@ export const LightboxComponent: React.FC = ({ else document.exitFullscreen(); }, [isFullscreen]); - const handleSlideshowIntervalChange = (newSlideshowInterval: number) => { - setSlideshowInterval(newSlideshowInterval); - }; - const navItems = images.map((image, i) => ( = ({ const onDelayChange = (e: React.ChangeEvent) => { let numberValue = Number.parseInt(e.currentTarget.value, 10); + setDisplayedSlideshowInterval(e.currentTarget.value); + // Without this exception, the blocking of updates for invalid values is even weirder if (e.currentTarget.value === "-" || e.currentTarget.value === "") { - setDisplayedSlideshowInterval(e.currentTarget.value); return; } - setDisplayedSlideshowInterval(e.currentTarget.value); + numberValue = + numberValue >= MIN_VALID_INTERVAL_SECONDS + ? numberValue + : MIN_VALID_INTERVAL_SECONDS; + + setSlideshowDelay(numberValue * SECONDS_TO_MS); + if (slideshowInterval !== null) { - numberValue = - numberValue >= MIN_VALID_INTERVAL_SECONDS - ? numberValue - : MIN_VALID_INTERVAL_SECONDS; - handleSlideshowIntervalChange(numberValue * SECONDS_TO_MS); + setSlideshowInterval(numberValue * SECONDS_TO_MS); } }; @@ -421,25 +449,19 @@ export const LightboxComponent: React.FC = ({ setDisplayMode(e.target.value as DisplayMode)} + onChange={(e) => + setDisplayMode(e.target.value as GQL.ImageLightboxDisplayMode) + } value={displayMode} className="btn-secondary mx-1 mb-1" > - - - + {Array.from(imageLightboxDisplayModeIntlMap.entries()).map((v) => ( + + ))} @@ -451,8 +473,8 @@ export const LightboxComponent: React.FC = ({ label={intl.formatMessage({ id: "dialogs.lightbox.scale_up.label", })} - checked={scaleUp} - disabled={displayMode === DisplayMode.ORIGINAL} + checked={lightboxSettings?.scaleUp ?? false} + disabled={displayMode === GQL.ImageLightboxDisplayMode.Original} onChange={(v) => setScaleUp(v.currentTarget.checked)} /> @@ -471,7 +493,7 @@ export const LightboxComponent: React.FC = ({ label={intl.formatMessage({ id: "dialogs.lightbox.reset_zoom_on_nav", })} - checked={resetZoomOnNav} + checked={lightboxSettings?.resetZoomOnNav ?? false} onChange={(v) => setResetZoomOnNav(v.currentTarget.checked)} /> @@ -487,16 +509,26 @@ export const LightboxComponent: React.FC = ({ setScrollMode(e.target.value as ScrollMode)} - value={scrollMode} + onChange={(e) => + setScrollMode(e.target.value as GQL.ImageLightboxScrollMode) + } + value={ + lightboxSettings?.scrollMode ?? GQL.ImageLightboxScrollMode.Zoom + } className="btn-secondary mx-1 mb-1" > - -