mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Gallery scrubber (#5133)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTitleProps } from "src/hooks/title";
|
||||
import Gallery from "./GalleryDetails/Gallery";
|
||||
@@ -7,6 +7,38 @@ import GalleryCreate from "./GalleryDetails/GalleryCreate";
|
||||
import { GalleryList } from "./GalleryList";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
import { View } from "../List/views";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import { ErrorMessage } from "../Shared/ErrorMessage";
|
||||
import { useFindGalleryImageID } from "src/core/StashService";
|
||||
|
||||
interface IGalleryImageParams {
|
||||
id: string;
|
||||
index: string;
|
||||
}
|
||||
|
||||
const GalleryImage: React.FC<RouteComponentProps<IGalleryImageParams>> = ({
|
||||
match,
|
||||
}) => {
|
||||
const { id, index: indexStr } = match.params;
|
||||
|
||||
let index = parseInt(indexStr);
|
||||
if (isNaN(index)) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
const { data, loading, error } = useFindGalleryImageID(id, index);
|
||||
|
||||
if (isNaN(index)) {
|
||||
return <Redirect to={`/galleries/${id}`} />;
|
||||
}
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
if (error) return <ErrorMessage error={error.message} />;
|
||||
if (!data?.findGallery)
|
||||
return <ErrorMessage error={`No gallery found with id ${id}.`} />;
|
||||
|
||||
return <Redirect to={`/images/${data.findGallery.image.id}`} />;
|
||||
};
|
||||
|
||||
const Galleries: React.FC = () => {
|
||||
useScrollToTopOnMount();
|
||||
@@ -22,6 +54,11 @@ const GalleryRoutes: React.FC = () => {
|
||||
<Switch>
|
||||
<Route exact path="/galleries" component={Galleries} />
|
||||
<Route exact path="/galleries/new" component={GalleryCreate} />
|
||||
<Route
|
||||
exact
|
||||
path="/galleries/:id/images/:index"
|
||||
component={GalleryImage}
|
||||
/>
|
||||
<Route path="/galleries/:id/:tab?" component={Gallery} />
|
||||
</Switch>
|
||||
</>
|
||||
|
||||
@@ -14,6 +14,45 @@ import { faBox, faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import ScreenUtils from "src/utils/screen";
|
||||
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
|
||||
import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber";
|
||||
import cx from "classnames";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
interface IScenePreviewProps {
|
||||
isPortrait?: boolean;
|
||||
gallery: GQL.SlimGalleryDataFragment;
|
||||
onScrubberClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
export const GalleryPreview: React.FC<IScenePreviewProps> = ({
|
||||
gallery,
|
||||
isPortrait = false,
|
||||
onScrubberClick,
|
||||
}) => {
|
||||
const [imgSrc, setImgSrc] = useState<string | undefined>(
|
||||
gallery.cover?.paths.thumbnail ?? undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx("gallery-card-cover", { portrait: isPortrait })}>
|
||||
{!!imgSrc && (
|
||||
<img
|
||||
loading="lazy"
|
||||
className="gallery-card-image"
|
||||
alt={gallery.title ?? ""}
|
||||
src={imgSrc}
|
||||
/>
|
||||
)}
|
||||
<GalleryPreviewScrubber
|
||||
previewPath={gallery.paths.preview}
|
||||
defaultPath={gallery.cover?.paths.thumbnail ?? ""}
|
||||
imageCount={gallery.image_count}
|
||||
onClick={onScrubberClick}
|
||||
onPathChanged={setImgSrc}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
gallery: GQL.SlimGalleryDataFragment;
|
||||
@@ -25,6 +64,7 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const GalleryCard: React.FC<IProps> = (props) => {
|
||||
const history = useHistory();
|
||||
const [cardWidth, setCardWidth] = useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -167,14 +207,13 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
||||
linkClassName="gallery-card-header"
|
||||
image={
|
||||
<>
|
||||
{props.gallery.cover ? (
|
||||
<img
|
||||
loading="lazy"
|
||||
className="gallery-card-image"
|
||||
alt={props.gallery.title ?? ""}
|
||||
src={`${props.gallery.cover.paths.thumbnail}`}
|
||||
/>
|
||||
) : undefined}
|
||||
<GalleryPreview
|
||||
gallery={props.gallery}
|
||||
onScrubberClick={(i) => {
|
||||
console.log(i);
|
||||
history.push(`/galleries/${props.gallery.id}/images/${i}`);
|
||||
}}
|
||||
/>
|
||||
<RatingBanner rating={props.gallery.rating100} />
|
||||
</>
|
||||
}
|
||||
|
||||
54
ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx
Normal file
54
ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useThrottle } from "src/hooks/throttle";
|
||||
import { HoverScrubber } from "../Shared/HoverScrubber";
|
||||
import cx from "classnames";
|
||||
|
||||
export const GalleryPreviewScrubber: React.FC<{
|
||||
className?: string;
|
||||
previewPath: string;
|
||||
defaultPath: string;
|
||||
imageCount: number;
|
||||
onClick?: (imageIndex: number) => void;
|
||||
onPathChanged: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
}> = ({
|
||||
className,
|
||||
previewPath,
|
||||
defaultPath,
|
||||
imageCount,
|
||||
onClick,
|
||||
onPathChanged,
|
||||
}) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number>();
|
||||
const debounceSetActiveIndex = useThrottle(setActiveIndex, 50);
|
||||
|
||||
function onScrubberClick() {
|
||||
if (activeIndex === undefined || !onClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClick(activeIndex);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function getPath() {
|
||||
if (activeIndex === undefined) {
|
||||
return defaultPath;
|
||||
}
|
||||
|
||||
return `${previewPath}/${activeIndex}`;
|
||||
}
|
||||
|
||||
onPathChanged(getPath());
|
||||
}, [activeIndex, defaultPath, previewPath, onPathChanged]);
|
||||
|
||||
return (
|
||||
<div className={cx("preview-scrubber", className)}>
|
||||
<HoverScrubber
|
||||
totalSprites={imageCount}
|
||||
activeIndex={activeIndex}
|
||||
setActiveIndex={(i) => debounceSetActiveIndex(i)}
|
||||
onClick={() => onScrubberClick()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -102,6 +102,14 @@
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
&-cover {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-scrubber {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&-image {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -8,89 +8,7 @@ import React, {
|
||||
import { useSpriteInfo } from "src/hooks/sprite";
|
||||
import { useThrottle } from "src/hooks/throttle";
|
||||
import TextUtils from "src/utils/text";
|
||||
import cx from "classnames";
|
||||
|
||||
interface IHoverScrubber {
|
||||
totalSprites: number;
|
||||
activeIndex: number | undefined;
|
||||
setActiveIndex: (index: number | undefined) => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const HoverScrubber: React.FC<IHoverScrubber> = ({
|
||||
totalSprites,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
onClick,
|
||||
}) => {
|
||||
function getActiveIndex(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
const { width } = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.nativeEvent.offsetX;
|
||||
|
||||
const i = Math.floor((x / width) * totalSprites);
|
||||
|
||||
// clamp to [0, totalSprites)
|
||||
if (i < 0) return 0;
|
||||
if (i >= totalSprites) return totalSprites - 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
function onMouseMove(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
const relatedTarget = e.currentTarget;
|
||||
|
||||
if (relatedTarget !== e.target) return;
|
||||
|
||||
setActiveIndex(getActiveIndex(e));
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
setActiveIndex(undefined);
|
||||
}
|
||||
|
||||
function onScrubberClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
if (!onClick) return;
|
||||
|
||||
const relatedTarget = e.currentTarget;
|
||||
|
||||
if (relatedTarget !== e.target) return;
|
||||
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
|
||||
const indicatorStyle = useMemo(() => {
|
||||
if (activeIndex === undefined || !totalSprites) return {};
|
||||
|
||||
const width = (activeIndex / totalSprites) * 100;
|
||||
|
||||
return {
|
||||
width: `${width}%`,
|
||||
};
|
||||
}, [activeIndex, totalSprites]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("hover-scrubber", {
|
||||
"hover-scrubber-inactive": !totalSprites,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="hover-scrubber-area"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onScrubberClick}
|
||||
/>
|
||||
<div className="hover-scrubber-indicator">
|
||||
{activeIndex !== undefined && (
|
||||
<div
|
||||
className="hover-scrubber-indicator-marker"
|
||||
style={indicatorStyle}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { HoverScrubber } from "../Shared/HoverScrubber";
|
||||
|
||||
interface IScenePreviewProps {
|
||||
vttPath: string | undefined;
|
||||
|
||||
84
ui/v2.5/src/components/Shared/HoverScrubber.tsx
Normal file
84
ui/v2.5/src/components/Shared/HoverScrubber.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useMemo } from "react";
|
||||
import cx from "classnames";
|
||||
|
||||
interface IHoverScrubber {
|
||||
totalSprites: number;
|
||||
activeIndex: number | undefined;
|
||||
setActiveIndex: (index: number | undefined) => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const HoverScrubber: React.FC<IHoverScrubber> = ({
|
||||
totalSprites,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
onClick,
|
||||
}) => {
|
||||
function getActiveIndex(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
const { width } = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.nativeEvent.offsetX;
|
||||
|
||||
const i = Math.round((x / width) * (totalSprites - 1));
|
||||
|
||||
// clamp to [0, totalSprites)
|
||||
if (i < 0) return 0;
|
||||
if (i >= totalSprites) return totalSprites - 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
function onMouseMove(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
const relatedTarget = e.currentTarget;
|
||||
|
||||
if (relatedTarget !== e.target) return;
|
||||
|
||||
setActiveIndex(getActiveIndex(e));
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
setActiveIndex(undefined);
|
||||
}
|
||||
|
||||
function onScrubberClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
if (!onClick) return;
|
||||
|
||||
const relatedTarget = e.currentTarget;
|
||||
|
||||
if (relatedTarget !== e.target) return;
|
||||
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
|
||||
const indicatorStyle = useMemo(() => {
|
||||
if (activeIndex === undefined || !totalSprites) return {};
|
||||
|
||||
const width = ((activeIndex + 1) / totalSprites) * 100;
|
||||
|
||||
return {
|
||||
width: `${width}%`,
|
||||
};
|
||||
}, [activeIndex, totalSprites]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("hover-scrubber", {
|
||||
"hover-scrubber-inactive": !totalSprites,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="hover-scrubber-area"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onScrubberClick}
|
||||
/>
|
||||
<div className="hover-scrubber-indicator">
|
||||
{activeIndex !== undefined && (
|
||||
<div
|
||||
className="hover-scrubber-indicator-marker"
|
||||
style={indicatorStyle}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -275,6 +275,10 @@ export const useFindGallery = (id: string) => {
|
||||
return GQL.useFindGalleryQuery({ variables: { id }, skip });
|
||||
};
|
||||
|
||||
export const useFindGalleryImageID = (id: string, index: number) => {
|
||||
return GQL.useFindGalleryImageIdQuery({ variables: { id, index } });
|
||||
};
|
||||
|
||||
export const useFindGalleries = (filter?: ListFilterModel) =>
|
||||
GQL.useFindGalleriesQuery({
|
||||
skip: filter === undefined,
|
||||
|
||||
@@ -500,7 +500,7 @@ textarea.text-input {
|
||||
.zoom-0 {
|
||||
.gallery-card-image,
|
||||
.tag-card-image {
|
||||
max-height: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,7 +509,7 @@ textarea.text-input {
|
||||
|
||||
.gallery-card-image,
|
||||
.tag-card-image {
|
||||
max-height: 240px;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.image-card-preview {
|
||||
@@ -520,7 +520,7 @@ textarea.text-input {
|
||||
.zoom-2 {
|
||||
.gallery-card-image,
|
||||
.tag-card-image {
|
||||
max-height: 360px;
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.image-card-preview {
|
||||
@@ -531,7 +531,7 @@ textarea.text-input {
|
||||
.zoom-3 {
|
||||
.tag-card-image,
|
||||
.gallery-card-image {
|
||||
max-height: 480px;
|
||||
height: 480px;
|
||||
}
|
||||
|
||||
.image-card-preview {
|
||||
|
||||
Reference in New Issue
Block a user