mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
[Feature] Added slideshow to gallery in wall display mode (#1224)
This commit is contained in:
@@ -2,7 +2,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
|||||||
stashes {
|
stashes {
|
||||||
path
|
path
|
||||||
excludeVideo
|
excludeVideo
|
||||||
excludeImage
|
excludeImage
|
||||||
}
|
}
|
||||||
databasePath
|
databasePath
|
||||||
generatedPath
|
generatedPath
|
||||||
@@ -52,6 +52,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
|
|||||||
css
|
css
|
||||||
cssEnabled
|
cssEnabled
|
||||||
language
|
language
|
||||||
|
slideshowDelay
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment ConfigData on ConfigResult {
|
fragment ConfigData on ConfigResult {
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ input ConfigInterfaceInput {
|
|||||||
cssEnabled: Boolean
|
cssEnabled: Boolean
|
||||||
"""Interface language"""
|
"""Interface language"""
|
||||||
language: String
|
language: String
|
||||||
|
"""Slideshow Delay"""
|
||||||
|
slideshowDelay: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigInterfaceResult {
|
type ConfigInterfaceResult {
|
||||||
@@ -210,6 +212,8 @@ type ConfigInterfaceResult {
|
|||||||
cssEnabled: Boolean
|
cssEnabled: Boolean
|
||||||
"""Interface language"""
|
"""Interface language"""
|
||||||
language: String
|
language: String
|
||||||
|
"""Slideshow Delay"""
|
||||||
|
slideshowDelay: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
"""All configuration settings"""
|
"""All configuration settings"""
|
||||||
|
|||||||
@@ -219,6 +219,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
|
|||||||
c.Set(config.Language, *input.Language)
|
c.Set(config.Language, *input.Language)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.SlideshowDelay != nil {
|
||||||
|
c.Set(config.SlideshowDelay, *input.SlideshowDelay)
|
||||||
|
}
|
||||||
|
|
||||||
css := ""
|
css := ""
|
||||||
|
|
||||||
if input.CSS != nil {
|
if input.CSS != nil {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
css := config.GetCSS()
|
css := config.GetCSS()
|
||||||
cssEnabled := config.GetCSSEnabled()
|
cssEnabled := config.GetCSSEnabled()
|
||||||
language := config.GetLanguage()
|
language := config.GetLanguage()
|
||||||
|
slideshowDelay := config.GetSlideshowDelay()
|
||||||
|
|
||||||
return &models.ConfigInterfaceResult{
|
return &models.ConfigInterfaceResult{
|
||||||
MenuItems: menuItems,
|
MenuItems: menuItems,
|
||||||
@@ -105,5 +106,6 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
CSS: &css,
|
CSS: &css,
|
||||||
CSSEnabled: &cssEnabled,
|
CSSEnabled: &cssEnabled,
|
||||||
Language: &language,
|
Language: &language,
|
||||||
|
SlideshowDelay: &slideshowDelay,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ const AutostartVideo = "autostart_video"
|
|||||||
const ShowStudioAsText = "show_studio_as_text"
|
const ShowStudioAsText = "show_studio_as_text"
|
||||||
const CSSEnabled = "cssEnabled"
|
const CSSEnabled = "cssEnabled"
|
||||||
const WallPlayback = "wall_playback"
|
const WallPlayback = "wall_playback"
|
||||||
|
const SlideshowDelay = "slideshow_delay"
|
||||||
|
|
||||||
// Logging options
|
// Logging options
|
||||||
const LogFile = "logFile"
|
const LogFile = "logFile"
|
||||||
@@ -560,6 +561,11 @@ func (i *Instance) GetShowStudioAsText() bool {
|
|||||||
return viper.GetBool(ShowStudioAsText)
|
return viper.GetBool(ShowStudioAsText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *Instance) GetSlideshowDelay() int {
|
||||||
|
viper.SetDefault(SlideshowDelay, 5000)
|
||||||
|
return viper.GetInt(SlideshowDelay)
|
||||||
|
}
|
||||||
|
|
||||||
func (i *Instance) GetCSSPath() string {
|
func (i *Instance) GetCSSPath() string {
|
||||||
// use custom.css in the same directory as the config file
|
// use custom.css in the same directory as the config file
|
||||||
configFileUsed := viper.ConfigFileUsed()
|
configFileUsed := viper.ConfigFileUsed()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Added scene queue.
|
* Added scene queue.
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Add slideshow to image wall view.
|
||||||
* Support API key via URL query parameter, and added API key to stream link in Scene File Info.
|
* Support API key via URL query parameter, and added API key to stream link in Scene File Info.
|
||||||
* Revamped setup wizard and migration UI.
|
* Revamped setup wizard and migration UI.
|
||||||
* Add various `count` filter criteria and sort options.
|
* Add various `count` filter criteria and sort options.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
showWhenSelected,
|
showWhenSelected,
|
||||||
PersistanceLevel,
|
PersistanceLevel,
|
||||||
} from "src/hooks/ListHook";
|
} from "src/hooks/ListHook";
|
||||||
|
|
||||||
import { ImageCard } from "./ImageCard";
|
import { ImageCard } from "./ImageCard";
|
||||||
import { EditImagesDialog } from "./EditImagesDialog";
|
import { EditImagesDialog } from "./EditImagesDialog";
|
||||||
import { DeleteImagesDialog } from "./DeleteImagesDialog";
|
import { DeleteImagesDialog } from "./DeleteImagesDialog";
|
||||||
@@ -36,34 +37,57 @@ const ImageWall: React.FC<IImageWallProps> = ({
|
|||||||
currentPage,
|
currentPage,
|
||||||
pageCount,
|
pageCount,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
|
||||||
const handleLightBoxPage = useCallback(
|
const handleLightBoxPage = useCallback(
|
||||||
(direction: number) => {
|
(direction: number) => {
|
||||||
if (direction === -1) {
|
if (direction === -1) {
|
||||||
if (currentPage === 1) return false;
|
if (currentPage === 1) return false;
|
||||||
onChangePage(currentPage - 1);
|
onChangePage(currentPage - 1);
|
||||||
} else {
|
} else {
|
||||||
if (currentPage === pageCount) return false;
|
if (currentPage === pageCount) {
|
||||||
|
// if the slideshow is running
|
||||||
|
// return to the first page
|
||||||
|
if (slideshowRunning) {
|
||||||
|
onChangePage(0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
onChangePage(currentPage + 1);
|
onChangePage(currentPage + 1);
|
||||||
}
|
}
|
||||||
return direction === -1 || direction === 1;
|
return direction === -1 || direction === 1;
|
||||||
},
|
},
|
||||||
[onChangePage, currentPage, pageCount]
|
[onChangePage, currentPage, pageCount, slideshowRunning]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSlideshowRunning(false);
|
||||||
|
}, [setSlideshowRunning]);
|
||||||
|
|
||||||
const showLightbox = useLightbox({
|
const showLightbox = useLightbox({
|
||||||
images,
|
images,
|
||||||
showNavigation: false,
|
showNavigation: false,
|
||||||
pageCallback: handleLightBoxPage,
|
pageCallback: handleLightBoxPage,
|
||||||
pageHeader: `Page ${currentPage} / ${pageCount}`,
|
pageHeader: `Page ${currentPage} / ${pageCount}`,
|
||||||
|
slideshowEnabled: slideshowRunning,
|
||||||
|
onClose: handleClose,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleImageOpen = useCallback(
|
||||||
|
(index) => {
|
||||||
|
setSlideshowRunning(true);
|
||||||
|
showLightbox(index, true);
|
||||||
|
},
|
||||||
|
[showLightbox]
|
||||||
|
);
|
||||||
|
|
||||||
const thumbs = images.map((image, index) => (
|
const thumbs = images.map((image, index) => (
|
||||||
<div
|
<div
|
||||||
role="link"
|
role="link"
|
||||||
tabIndex={index}
|
tabIndex={index}
|
||||||
key={image.id}
|
key={image.id}
|
||||||
onClick={() => showLightbox(index)}
|
onClick={() => handleImageOpen(index)}
|
||||||
onKeyPress={() => showLightbox(index)}
|
onKeyPress={() => handleImageOpen(index)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={image.paths.thumbnail ?? ""}
|
src={image.paths.thumbnail ?? ""}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const allMenuItems = [
|
|||||||
{ id: "tags", label: "Tags" },
|
{ id: "tags", label: "Tags" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SECONDS_TO_MS = 1000;
|
||||||
|
|
||||||
export const SettingsInterfacePanel: React.FC = () => {
|
export const SettingsInterfacePanel: React.FC = () => {
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
const { data: config, error, loading } = useConfiguration();
|
const { data: config, error, loading } = useConfiguration();
|
||||||
@@ -27,6 +29,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
const [wallPlayback, setWallPlayback] = useState<string>("video");
|
const [wallPlayback, setWallPlayback] = useState<string>("video");
|
||||||
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
|
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
|
||||||
const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
|
const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
|
||||||
|
const [slideshowDelay, setSlideshowDelay] = useState<number>(0);
|
||||||
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
|
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
|
||||||
const [css, setCSS] = useState<string>();
|
const [css, setCSS] = useState<string>();
|
||||||
const [cssEnabled, setCSSEnabled] = useState<boolean>(false);
|
const [cssEnabled, setCSSEnabled] = useState<boolean>(false);
|
||||||
@@ -43,6 +46,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
css,
|
css,
|
||||||
cssEnabled,
|
cssEnabled,
|
||||||
language,
|
language,
|
||||||
|
slideshowDelay,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,6 +61,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
setCSS(iCfg?.css ?? "");
|
setCSS(iCfg?.css ?? "");
|
||||||
setCSSEnabled(iCfg?.cssEnabled ?? false);
|
setCSSEnabled(iCfg?.cssEnabled ?? false);
|
||||||
setLanguage(iCfg?.language ?? "en-US");
|
setLanguage(iCfg?.language ?? "en-US");
|
||||||
|
setSlideshowDelay(iCfg?.slideshowDelay ?? 5000);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
async function onSave() {
|
async function onSave() {
|
||||||
@@ -187,6 +192,23 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
</Form.Group>
|
</Form.Group>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group id="slideshow-delay">
|
||||||
|
<h5>Slideshow Delay</h5>
|
||||||
|
<Form.Control
|
||||||
|
className="col col-sm-6 text-input"
|
||||||
|
type="number"
|
||||||
|
value={slideshowDelay / SECONDS_TO_MS}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSlideshowDelay(
|
||||||
|
Number.parseInt(e.currentTarget.value, 10) * SECONDS_TO_MS
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Slideshow is available in galleries when in wall view mode
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<h5>Custom CSS</h5>
|
<h5>Custom CSS</h5>
|
||||||
<Form.Check
|
<Form.Check
|
||||||
|
|||||||
65
ui/v2.5/src/hooks/Interval.ts
Normal file
65
ui/v2.5/src/hooks/Interval.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import noop from "lodash/noop";
|
||||||
|
|
||||||
|
const MIN_VALID_INTERVAL = 1000;
|
||||||
|
|
||||||
|
const useInterval = (
|
||||||
|
callback: () => void,
|
||||||
|
delay: number | null = 5000
|
||||||
|
): (() => void)[] => {
|
||||||
|
const savedCallback = useRef<() => void>();
|
||||||
|
const savedIntervalId = useRef<NodeJS.Timeout>();
|
||||||
|
const [savedDelay, setSavedDelay] = useState<number | null>(delay);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
savedCallback.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let validDelay;
|
||||||
|
if (delay !== null) {
|
||||||
|
validDelay = delay >= MIN_VALID_INTERVAL ? delay : MIN_VALID_INTERVAL;
|
||||||
|
} else {
|
||||||
|
validDelay = delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavedDelay(validDelay);
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
const intervalId = savedIntervalId.current;
|
||||||
|
if (intervalId) {
|
||||||
|
savedIntervalId.current = undefined;
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
cancel();
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (savedCallback.current) savedCallback.current();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (savedDelay !== null) {
|
||||||
|
savedIntervalId.current = setInterval(tick, savedDelay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cancel();
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (savedCallback.current) savedCallback.current();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (savedDelay !== null) {
|
||||||
|
savedIntervalId.current = setInterval(tick, savedDelay);
|
||||||
|
return cancel;
|
||||||
|
}
|
||||||
|
}, [callback, savedDelay]);
|
||||||
|
|
||||||
|
return delay ? [cancel, reset] : [noop, noop];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useInterval;
|
||||||
@@ -1,15 +1,30 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { Button } from "react-bootstrap";
|
import {
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
FormControl,
|
||||||
|
InputGroup,
|
||||||
|
FormLabel,
|
||||||
|
OverlayTrigger,
|
||||||
|
Popover,
|
||||||
|
} from "react-bootstrap";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import { debounce } from "lodash";
|
import debounce from "lodash/debounce";
|
||||||
|
|
||||||
import { Icon, LoadingIndicator } from "src/components/Shared";
|
import { Icon, LoadingIndicator } from "src/components/Shared";
|
||||||
|
import { useInterval, usePageVisibility } from "src/hooks";
|
||||||
|
import { useConfiguration } from "src/core/StashService";
|
||||||
|
|
||||||
const CLASSNAME = "Lightbox";
|
const CLASSNAME = "Lightbox";
|
||||||
const CLASSNAME_HEADER = `${CLASSNAME}-header`;
|
const CLASSNAME_HEADER = `${CLASSNAME}-header`;
|
||||||
|
const CLASSNAME_LEFT_SPACER = `${CLASSNAME_HEADER}-left-spacer`;
|
||||||
const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;
|
const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;
|
||||||
|
const CLASSNAME_DELAY = `${CLASSNAME_HEADER}-delay`;
|
||||||
|
const CLASSNAME_DELAY_ICON = `${CLASSNAME_DELAY}-icon`;
|
||||||
|
const CLASSNAME_DELAY_INLINE = `${CLASSNAME_DELAY}-inline`;
|
||||||
|
const CLASSNAME_RIGHT = `${CLASSNAME_HEADER}-right`;
|
||||||
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
|
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
|
||||||
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
||||||
const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;
|
const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;
|
||||||
@@ -19,6 +34,10 @@ const CLASSNAME_NAV = `${CLASSNAME}-nav`;
|
|||||||
const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`;
|
const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`;
|
||||||
const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`;
|
const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`;
|
||||||
|
|
||||||
|
const DEFAULT_SLIDESHOW_DELAY = 5000;
|
||||||
|
const SECONDS_TO_MS = 1000;
|
||||||
|
const MIN_VALID_INTERVAL_SECONDS = 1;
|
||||||
|
|
||||||
type Image = Pick<GQL.Image, "paths">;
|
type Image = Pick<GQL.Image, "paths">;
|
||||||
interface IProps {
|
interface IProps {
|
||||||
images: Image[];
|
images: Image[];
|
||||||
@@ -26,6 +45,7 @@ interface IProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
initialIndex?: number;
|
initialIndex?: number;
|
||||||
showNavigation: boolean;
|
showNavigation: boolean;
|
||||||
|
slideshowEnabled?: boolean;
|
||||||
pageHeader?: string;
|
pageHeader?: string;
|
||||||
pageCallback?: (direction: number) => boolean;
|
pageCallback?: (direction: number) => boolean;
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
@@ -37,6 +57,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
initialIndex = 0,
|
initialIndex = 0,
|
||||||
showNavigation,
|
showNavigation,
|
||||||
|
slideshowEnabled = false,
|
||||||
pageHeader,
|
pageHeader,
|
||||||
pageCallback,
|
pageCallback,
|
||||||
hide,
|
hide,
|
||||||
@@ -49,6 +70,27 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
const carouselRef = useRef<HTMLDivElement | null>(null);
|
const carouselRef = useRef<HTMLDivElement | null>(null);
|
||||||
const indicatorRef = useRef<HTMLDivElement | null>(null);
|
const indicatorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const navRef = useRef<HTMLDivElement | null>(null);
|
const navRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const clearIntervalCallback = useRef<() => void>();
|
||||||
|
const resetIntervalCallback = useRef<() => void>();
|
||||||
|
const config = useConfiguration();
|
||||||
|
|
||||||
|
const userSelectedSlideshowDelayOrDefault =
|
||||||
|
config?.data?.configuration.interface.slideshowDelay ??
|
||||||
|
DEFAULT_SLIDESHOW_DELAY;
|
||||||
|
|
||||||
|
// slideshowInterval is used for controlling the logic
|
||||||
|
// displaySlideshowInterval is for display purposes only
|
||||||
|
// keeping them separate and independant allows us to handle the logic however we want
|
||||||
|
// while still displaying something that makes sense to the user
|
||||||
|
const [slideshowInterval, setSlideshowInterval] = useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [
|
||||||
|
displayedSlideshowInterval,
|
||||||
|
setDisplayedSlideshowInterval,
|
||||||
|
] = useState<string>(
|
||||||
|
(userSelectedSlideshowDelayOrDefault / SECONDS_TO_MS).toString()
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSwitchingPage(false);
|
setIsSwitchingPage(false);
|
||||||
@@ -59,6 +101,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
() => setInstantTransition(false),
|
() => setInstantTransition(false),
|
||||||
400
|
400
|
||||||
);
|
);
|
||||||
|
|
||||||
const setInstant = useCallback(() => {
|
const setInstant = useCallback(() => {
|
||||||
setInstantTransition(true);
|
setInstantTransition(true);
|
||||||
disableInstantTransition();
|
disableInstantTransition();
|
||||||
@@ -108,6 +151,28 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
}
|
}
|
||||||
}, [initialIndex, isVisible, setIndex]);
|
}, [initialIndex, isVisible, setIndex]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
slideshowInterval,
|
||||||
|
userSelectedSlideshowDelayOrDefault,
|
||||||
|
displayedSlideshowInterval,
|
||||||
|
]);
|
||||||
|
|
||||||
|
usePageVisibility(() => {
|
||||||
|
toggleSlideshow();
|
||||||
|
});
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
if (!isFullscreen) {
|
if (!isFullscreen) {
|
||||||
hide();
|
hide();
|
||||||
@@ -122,37 +187,52 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
if (nodeName === "DIV" || nodeName === "PICTURE") close();
|
if (nodeName === "DIV" || nodeName === "PICTURE") close();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLeft = useCallback(() => {
|
const handleLeft = useCallback(
|
||||||
if (isSwitchingPage || index.current === -1) return;
|
(isUserAction = true) => {
|
||||||
|
if (isSwitchingPage || index.current === -1) return;
|
||||||
|
|
||||||
if (index.current === 0) {
|
if (index.current === 0) {
|
||||||
if (pageCallback) {
|
if (pageCallback) {
|
||||||
setIsSwitchingPage(true);
|
setIsSwitchingPage(true);
|
||||||
setIndex(-1);
|
setIndex(-1);
|
||||||
// Check if calling page wants to swap page
|
// Check if calling page wants to swap page
|
||||||
const repage = pageCallback(-1);
|
const repage = pageCallback(-1);
|
||||||
if (!repage) {
|
if (!repage) {
|
||||||
setIsSwitchingPage(false);
|
setIsSwitchingPage(false);
|
||||||
|
setIndex(0);
|
||||||
|
}
|
||||||
|
} else setIndex(images.length - 1);
|
||||||
|
} else setIndex((index.current ?? 0) - 1);
|
||||||
|
|
||||||
|
if (isUserAction && resetIntervalCallback.current) {
|
||||||
|
resetIntervalCallback.current();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRight = useCallback(
|
||||||
|
(isUserAction = true) => {
|
||||||
|
if (isSwitchingPage) return;
|
||||||
|
|
||||||
|
if (index.current === images.length - 1) {
|
||||||
|
if (pageCallback) {
|
||||||
|
setIsSwitchingPage(true);
|
||||||
setIndex(0);
|
setIndex(0);
|
||||||
}
|
const repage = pageCallback?.(1);
|
||||||
} else setIndex(images.length - 1);
|
if (!repage) {
|
||||||
} else setIndex((index.current ?? 0) - 1);
|
setIsSwitchingPage(false);
|
||||||
}, [images, setIndex, pageCallback, isSwitchingPage]);
|
setIndex(images.length - 1);
|
||||||
const handleRight = useCallback(() => {
|
}
|
||||||
if (isSwitchingPage) return;
|
} else setIndex(0);
|
||||||
|
} else setIndex((index.current ?? 0) + 1);
|
||||||
|
|
||||||
if (index.current === images.length - 1) {
|
if (isUserAction && resetIntervalCallback.current) {
|
||||||
if (pageCallback) {
|
resetIntervalCallback.current();
|
||||||
setIsSwitchingPage(true);
|
}
|
||||||
setIndex(0);
|
},
|
||||||
const repage = pageCallback?.(1);
|
[images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback]
|
||||||
if (!repage) {
|
);
|
||||||
setIsSwitchingPage(false);
|
|
||||||
setIndex(images.length - 1);
|
|
||||||
}
|
|
||||||
} else setIndex(0);
|
|
||||||
} else setIndex((index.current ?? 0) + 1);
|
|
||||||
}, [images, setIndex, pageCallback, isSwitchingPage]);
|
|
||||||
|
|
||||||
const handleKey = useCallback(
|
const handleKey = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
@@ -164,8 +244,12 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
},
|
},
|
||||||
[setInstant, handleLeft, handleRight, close]
|
[setInstant, handleLeft, handleRight, close]
|
||||||
);
|
);
|
||||||
const handleFullScreenChange = () =>
|
const handleFullScreenChange = () => {
|
||||||
|
if (clearIntervalCallback.current) {
|
||||||
|
clearIntervalCallback.current();
|
||||||
|
}
|
||||||
setFullscreen(document.fullscreenElement !== null);
|
setFullscreen(document.fullscreenElement !== null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleTouchStart = (ev: React.TouchEvent<HTMLDivElement>) => {
|
const handleTouchStart = (ev: React.TouchEvent<HTMLDivElement>) => {
|
||||||
setInstantTransition(true);
|
setInstantTransition(true);
|
||||||
@@ -212,6 +296,16 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
el.addEventListener("touchcancel", handleCancel);
|
el.addEventListener("touchcancel", handleCancel);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [clearCallback, resetCallback] = useInterval(
|
||||||
|
() => {
|
||||||
|
handleRight(false);
|
||||||
|
},
|
||||||
|
slideshowEnabled ? slideshowInterval : null
|
||||||
|
);
|
||||||
|
|
||||||
|
resetIntervalCallback.current = resetCallback;
|
||||||
|
clearIntervalCallback.current = clearCallback;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
document.addEventListener("keydown", handleKey);
|
document.addEventListener("keydown", handleKey);
|
||||||
@@ -228,6 +322,10 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
else document.exitFullscreen();
|
else document.exitFullscreen();
|
||||||
}, [isFullscreen]);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
const handleSlideshowIntervalChange = (newSlideshowInterval: number) => {
|
||||||
|
setSlideshowInterval(newSlideshowInterval);
|
||||||
|
};
|
||||||
|
|
||||||
const navItems = images.map((image, i) => (
|
const navItems = images.map((image, i) => (
|
||||||
<img
|
<img
|
||||||
src={image.paths.thumbnail ?? ""}
|
src={image.paths.thumbnail ?? ""}
|
||||||
@@ -242,40 +340,120 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
const onDelayChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let numberValue = Number.parseInt(e.currentTarget.value, 10);
|
||||||
|
// 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);
|
||||||
|
if (slideshowInterval !== null) {
|
||||||
|
numberValue =
|
||||||
|
numberValue >= MIN_VALID_INTERVAL_SECONDS
|
||||||
|
? numberValue
|
||||||
|
: MIN_VALID_INTERVAL_SECONDS;
|
||||||
|
handleSlideshowIntervalChange(numberValue * SECONDS_TO_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const currentIndex = index.current === null ? initialIndex : index.current;
|
const currentIndex = index.current === null ? initialIndex : index.current;
|
||||||
|
|
||||||
|
const DelayForm: React.FC<{}> = () => (
|
||||||
|
<>
|
||||||
|
<FormLabel column sm="5">
|
||||||
|
Delay (Sec)
|
||||||
|
</FormLabel>
|
||||||
|
<Col sm="4">
|
||||||
|
<FormControl
|
||||||
|
type="number"
|
||||||
|
className="text-input"
|
||||||
|
min={1}
|
||||||
|
value={displayedSlideshowInterval ?? 0}
|
||||||
|
onChange={onDelayChange}
|
||||||
|
size="sm"
|
||||||
|
id="delay-input"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const delayPopover = (
|
||||||
|
<Popover id="basic-bitch">
|
||||||
|
<Popover.Title>Set slideshow delay</Popover.Title>
|
||||||
|
<Popover.Content>
|
||||||
|
<InputGroup>
|
||||||
|
<DelayForm />
|
||||||
|
</InputGroup>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
|
||||||
const element = isVisible ? (
|
const element = isVisible ? (
|
||||||
<div
|
<div
|
||||||
className={CLASSNAME}
|
className={CLASSNAME}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onClick={handleClose}
|
onMouseDown={handleClose}
|
||||||
>
|
>
|
||||||
{images.length > 0 && !isLoading && !isSwitchingPage ? (
|
{images.length > 0 && !isLoading && !isSwitchingPage ? (
|
||||||
<>
|
<>
|
||||||
<div className={CLASSNAME_HEADER}>
|
<div className={CLASSNAME_HEADER}>
|
||||||
|
<div className={CLASSNAME_LEFT_SPACER} />
|
||||||
<div className={CLASSNAME_INDICATOR}>
|
<div className={CLASSNAME_INDICATOR}>
|
||||||
<span>{pageHeader}</span>
|
<span>{pageHeader}</span>
|
||||||
<b ref={indicatorRef}>
|
<b ref={indicatorRef}>
|
||||||
{`${currentIndex + 1} / ${images.length}`}
|
{`${currentIndex + 1} / ${images.length}`}
|
||||||
</b>
|
</b>
|
||||||
</div>
|
</div>
|
||||||
{document.fullscreenEnabled && (
|
<div className={CLASSNAME_RIGHT}>
|
||||||
|
{slideshowEnabled && (
|
||||||
|
<>
|
||||||
|
<div className={CLASSNAME_DELAY}>
|
||||||
|
<div className={CLASSNAME_DELAY_ICON}>
|
||||||
|
<OverlayTrigger
|
||||||
|
trigger="click"
|
||||||
|
placement="bottom"
|
||||||
|
overlay={delayPopover}
|
||||||
|
>
|
||||||
|
<Button variant="link" title="Slideshow delay settings">
|
||||||
|
<Icon icon="cog" />
|
||||||
|
</Button>
|
||||||
|
</OverlayTrigger>
|
||||||
|
</div>
|
||||||
|
<InputGroup className={CLASSNAME_DELAY_INLINE}>
|
||||||
|
<DelayForm />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={toggleSlideshow}
|
||||||
|
title="Toggle Slideshow"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={slideshowInterval !== null ? "pause" : "play"}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{document.fullscreenEnabled && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
title="Toggle Fullscreen"
|
||||||
|
>
|
||||||
|
<Icon icon="expand" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
onClick={toggleFullscreen}
|
onClick={() => close()}
|
||||||
title="Toggle Fullscreen"
|
title="Close Lightbox"
|
||||||
>
|
>
|
||||||
<Icon icon="expand" />
|
<Icon icon="times" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
onClick={() => close()}
|
|
||||||
title="Close Lightbox"
|
|
||||||
>
|
|
||||||
<Icon icon="times" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={CLASSNAME_DISPLAY} onTouchStart={handleTouchStart}>
|
<div className={CLASSNAME_DISPLAY} onTouchStart={handleTouchStart}>
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface IState {
|
|||||||
initialIndex?: number;
|
initialIndex?: number;
|
||||||
pageCallback?: (direction: number) => boolean;
|
pageCallback?: (direction: number) => boolean;
|
||||||
pageHeader?: string;
|
pageHeader?: string;
|
||||||
|
slideshowEnabled: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
interface IContext {
|
interface IContext {
|
||||||
setLightboxState: (state: Partial<IState>) => void;
|
setLightboxState: (state: Partial<IState>) => void;
|
||||||
@@ -26,6 +28,7 @@ const Lightbox: React.FC = ({ children }) => {
|
|||||||
isVisible: false,
|
isVisible: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
showNavigation: true,
|
showNavigation: true,
|
||||||
|
slideshowEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setPartialState = useCallback(
|
const setPartialState = useCallback(
|
||||||
@@ -38,14 +41,18 @@ const Lightbox: React.FC = ({ children }) => {
|
|||||||
[setLightboxState]
|
[setLightboxState]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onHide = () => {
|
||||||
|
setLightboxState({ ...lightboxState, isVisible: false });
|
||||||
|
if (lightboxState.onClose) {
|
||||||
|
lightboxState.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LightboxContext.Provider value={{ setLightboxState: setPartialState }}>
|
<LightboxContext.Provider value={{ setLightboxState: setPartialState }}>
|
||||||
{children}
|
{children}
|
||||||
{lightboxState.isVisible && (
|
{lightboxState.isVisible && (
|
||||||
<LightboxComponent
|
<LightboxComponent {...lightboxState} hide={onHide} />
|
||||||
{...lightboxState}
|
|
||||||
hide={() => setLightboxState({ ...lightboxState, isVisible: false })}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</LightboxContext.Provider>
|
</LightboxContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
|
|||||||
pageCallback: state.pageCallback,
|
pageCallback: state.pageCallback,
|
||||||
initialIndex: state.initialIndex,
|
initialIndex: state.initialIndex,
|
||||||
pageHeader: state.pageHeader,
|
pageHeader: state.pageHeader,
|
||||||
|
slideshowEnabled: state.slideshowEnabled,
|
||||||
|
onClose: state.onClose,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
setLightboxState,
|
setLightboxState,
|
||||||
@@ -20,13 +22,16 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
|
|||||||
state.pageCallback,
|
state.pageCallback,
|
||||||
state.initialIndex,
|
state.initialIndex,
|
||||||
state.pageHeader,
|
state.pageHeader,
|
||||||
|
state.slideshowEnabled,
|
||||||
|
state.onClose,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const show = useCallback(
|
const show = useCallback(
|
||||||
(index?: number) => {
|
(index?: number, slideshowEnabled = false) => {
|
||||||
setLightboxState({
|
setLightboxState({
|
||||||
initialIndex: index,
|
initialIndex: index,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
|
slideshowEnabled,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setLightboxState]
|
[setLightboxState]
|
||||||
|
|||||||
@@ -26,14 +26,51 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
|
|
||||||
|
&-left-spacer {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
&-indicator {
|
&-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-left: 49%;
|
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-delay {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-left: 100px;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-inline {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1300px) {
|
||||||
|
&-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-inline {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-right {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.fa-icon {
|
.fa-icon {
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
50
ui/v2.5/src/hooks/PageVisibility.ts
Normal file
50
ui/v2.5/src/hooks/PageVisibility.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const usePageVisibility = (visibilityChangeCallback: () => void): void => {
|
||||||
|
const savedVisibilityChangedCallback = useRef<() => void>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// resolve event names for different browsers
|
||||||
|
let hidden = "";
|
||||||
|
let visibilityChange = "";
|
||||||
|
|
||||||
|
if (typeof document.hidden !== "undefined") {
|
||||||
|
hidden = "hidden";
|
||||||
|
visibilityChange = "visibilitychange";
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} else if (typeof (document as any).msHidden !== "undefined") {
|
||||||
|
hidden = "msHidden";
|
||||||
|
visibilityChange = "msvisibilitychange";
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} else if (typeof (document as any).webkitHidden !== "undefined") {
|
||||||
|
hidden = "webkitHidden";
|
||||||
|
visibilityChange = "webkitvisibilitychange";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof document.addEventListener === "undefined" ||
|
||||||
|
hidden === undefined
|
||||||
|
) {
|
||||||
|
// this browser doesn't have support for modern event listeners or the Page Visibility API
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
savedVisibilityChangedCallback.current = visibilityChangeCallback;
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
visibilityChange,
|
||||||
|
savedVisibilityChangedCallback.current
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (savedVisibilityChangedCallback.current) {
|
||||||
|
document.removeEventListener(
|
||||||
|
visibilityChange,
|
||||||
|
savedVisibilityChangedCallback.current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [visibilityChangeCallback]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePageVisibility;
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export { default as useToast } from "./Toast";
|
export { default as useToast } from "./Toast";
|
||||||
|
export { default as useInterval } from "./Interval";
|
||||||
|
export { default as usePageVisibility } from "./PageVisibility";
|
||||||
export {
|
export {
|
||||||
useInterfaceLocalForage,
|
useInterfaceLocalForage,
|
||||||
useChangelogStorage,
|
useChangelogStorage,
|
||||||
|
|||||||
Reference in New Issue
Block a user