Gallery scrubber (#5133)

This commit is contained in:
WithoutPants
2024-08-28 08:59:41 +10:00
committed by GitHub
parent ce47efc415
commit 996dfb1c2f
21 changed files with 501 additions and 102 deletions

View File

@@ -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>
</>

View File

@@ -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} />
</>
}

View 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>
);
};

View File

@@ -102,6 +102,14 @@
color: $text-color;
}
&-cover {
position: relative;
}
.preview-scrubber {
top: 0;
}
&-image {
object-fit: contain;
}

View File

@@ -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;

View 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>
);
};

View File

@@ -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,

View File

@@ -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 {