From 996dfb1c2f85481a66929e700e1931991b51a6e4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Aug 2024 08:59:41 +1000 Subject: [PATCH] Gallery scrubber (#5133) --- graphql/schema/types/gallery.graphql | 7 ++ internal/api/context_keys.go | 4 +- internal/api/resolver_model_gallery.go | 27 ++++ internal/api/routes_gallery.go | 116 ++++++++++++++++++ internal/api/routes_image.go | 6 +- internal/api/server.go | 11 ++ internal/api/urlbuilders/gallery.go | 23 ++++ pkg/models/mocks/ImageReaderWriter.go | 23 ++++ pkg/models/repository_image.go | 1 + pkg/sqlite/image.go | 31 ++++- ui/v2.5/graphql/data/gallery-slim.graphql | 3 + ui/v2.5/graphql/data/gallery.graphql | 7 ++ ui/v2.5/graphql/queries/gallery.graphql | 8 ++ .../src/components/Galleries/Galleries.tsx | 39 +++++- .../src/components/Galleries/GalleryCard.tsx | 55 +++++++-- .../Galleries/GalleryPreviewScrubber.tsx | 54 ++++++++ ui/v2.5/src/components/Galleries/styles.scss | 8 ++ .../src/components/Scenes/PreviewScrubber.tsx | 84 +------------ .../src/components/Shared/HoverScrubber.tsx | 84 +++++++++++++ ui/v2.5/src/core/StashService.ts | 4 + ui/v2.5/src/index.scss | 8 +- 21 files changed, 501 insertions(+), 102 deletions(-) create mode 100644 internal/api/routes_gallery.go create mode 100644 internal/api/urlbuilders/gallery.go create mode 100644 ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx create mode 100644 ui/v2.5/src/components/Shared/HoverScrubber.tsx diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index 47f6c7d7e..3cf3216b9 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -1,3 +1,7 @@ +type GalleryPathsType { + preview: String! # Resolver +} + "Gallery type" type Gallery { id: ID! @@ -25,6 +29,9 @@ type Gallery { performers: [Performer!]! cover: Image + + paths: GalleryPathsType! # Resolver + image(index: Int!): Image! } input GalleryCreateInput { diff --git a/internal/api/context_keys.go b/internal/api/context_keys.go index df61139f8..b3a7d135b 100644 --- a/internal/api/context_keys.go +++ b/internal/api/context_keys.go @@ -5,8 +5,8 @@ package api type key int const ( - // galleryKey key = 0 - performerKey key = iota + 1 + galleryKey key = 0 + performerKey sceneKey studioKey groupKey diff --git a/internal/api/resolver_model_gallery.go b/internal/api/resolver_model_gallery.go index 3057843e0..7877e819d 100644 --- a/internal/api/resolver_model_gallery.go +++ b/internal/api/resolver_model_gallery.go @@ -2,8 +2,10 @@ package api import ( "context" + "fmt" "github.com/stashapp/stash/internal/api/loaders" + "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/image" @@ -189,3 +191,28 @@ func (r *galleryResolver) Urls(ctx context.Context, obj *models.Gallery) ([]stri return obj.URLs.List(), nil } + +func (r *galleryResolver) Paths(ctx context.Context, obj *models.Gallery) (*GalleryPathsType, error) { + baseURL, _ := ctx.Value(BaseURLCtxKey).(string) + builder := urlbuilders.NewGalleryURLBuilder(baseURL, obj) + previewPath := builder.GetPreviewURL() + + return &GalleryPathsType{ + Preview: previewPath, + }, nil +} + +func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index int) (ret *models.Image, err error) { + if index < 0 { + return nil, fmt.Errorf("index must >= 0") + } + + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.Image.FindByGalleryIDIndex(ctx, obj.ID, uint(index)) + return err + }); err != nil { + return nil, err + } + + return +} diff --git a/internal/api/routes_gallery.go b/internal/api/routes_gallery.go new file mode 100644 index 000000000..fcadae5f9 --- /dev/null +++ b/internal/api/routes_gallery.go @@ -0,0 +1,116 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +type GalleryFinder interface { + models.GalleryGetter + FindByChecksum(ctx context.Context, checksum string) ([]*models.Gallery, error) +} + +type ImageByIndexer interface { + FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) +} + +type galleryRoutes struct { + routes + imageRoutes imageRoutes + galleryFinder GalleryFinder + imageFinder ImageByIndexer + fileGetter models.FileGetter +} + +func (rs galleryRoutes) Routes() chi.Router { + r := chi.NewRouter() + + r.Route("/{galleryId}", func(r chi.Router) { + r.Use(rs.GalleryCtx) + + r.Get("/preview/{imageIndex}", rs.Preview) + }) + + return r +} + +func (rs galleryRoutes) Preview(w http.ResponseWriter, r *http.Request) { + g := r.Context().Value(galleryKey).(*models.Gallery) + indexQueryParam := chi.URLParam(r, "imageIndex") + var i *models.Image + + index, err := strconv.Atoi(indexQueryParam) + if err != nil || index < 0 { + http.Error(w, "bad index", 400) + return + } + + _ = rs.withReadTxn(r, func(ctx context.Context) error { + qb := rs.imageFinder + i, _ = qb.FindByGalleryIDIndex(ctx, g.ID, uint(index)) + // TODO - handle errors? + + // serveThumbnail needs files populated + if err := i.LoadPrimaryFile(ctx, rs.fileGetter); err != nil { + if !errors.Is(err, context.Canceled) { + logger.Errorf("error loading primary file for image %d: %v", i.ID, err) + } + // set image to nil so that it doesn't try to use the primary file + i = nil + } + + return nil + }) + if i == nil { + http.Error(w, http.StatusText(404), 404) + return + } + + rs.imageRoutes.serveThumbnail(w, r, i) +} + +func (rs galleryRoutes) GalleryCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + galleryIdentifierQueryParam := chi.URLParam(r, "galleryId") + galleryID, _ := strconv.Atoi(galleryIdentifierQueryParam) + + var gallery *models.Gallery + _ = rs.withReadTxn(r, func(ctx context.Context) error { + qb := rs.galleryFinder + if galleryID == 0 { + galleries, _ := qb.FindByChecksum(ctx, galleryIdentifierQueryParam) + if len(galleries) > 0 { + gallery = galleries[0] + } + } else { + gallery, _ = qb.Find(ctx, galleryID) + } + + if gallery != nil { + if err := gallery.LoadPrimaryFile(ctx, rs.fileGetter); err != nil { + if !errors.Is(err, context.Canceled) { + logger.Errorf("error loading primary file for gallery %d: %v", galleryID, err) + } + // set image to nil so that it doesn't try to use the primary file + gallery = nil + } + } + + return nil + }) + if gallery == nil { + http.Error(w, http.StatusText(404), 404) + return + } + + ctx := context.WithValue(r.Context(), galleryKey, gallery) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index 270b4de7f..89e6d2db4 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -46,8 +46,12 @@ func (rs imageRoutes) Routes() chi.Router { } func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { - mgr := manager.GetInstance() img := r.Context().Value(imageKey).(*models.Image) + rs.serveThumbnail(w, r, img) +} + +func (rs imageRoutes) serveThumbnail(w http.ResponseWriter, r *http.Request, img *models.Image) { + mgr := manager.GetInstance() filepath := mgr.Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth) // if the thumbnail doesn't exist, encode on the fly diff --git a/internal/api/server.go b/internal/api/server.go index 679bd3f1c..1ddf1baef 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -207,6 +207,7 @@ func Initialize() (*Server, error) { r.Mount("/performer", server.getPerformerRoutes()) r.Mount("/scene", server.getSceneRoutes()) + r.Mount("/gallery", server.getGalleryRoutes()) r.Mount("/image", server.getImageRoutes()) r.Mount("/studio", server.getStudioRoutes()) r.Mount("/group", server.getGroupRoutes()) @@ -326,6 +327,16 @@ func (s *Server) getSceneRoutes() chi.Router { }.Routes() } +func (s *Server) getGalleryRoutes() chi.Router { + repo := s.manager.Repository + return galleryRoutes{ + routes: routes{txnManager: repo.TxnManager}, + imageFinder: repo.Image, + galleryFinder: repo.Gallery, + fileGetter: repo.File, + }.Routes() +} + func (s *Server) getImageRoutes() chi.Router { repo := s.manager.Repository return imageRoutes{ diff --git a/internal/api/urlbuilders/gallery.go b/internal/api/urlbuilders/gallery.go new file mode 100644 index 000000000..8aeff1e04 --- /dev/null +++ b/internal/api/urlbuilders/gallery.go @@ -0,0 +1,23 @@ +package urlbuilders + +import ( + "strconv" + + "github.com/stashapp/stash/pkg/models" +) + +type GalleryURLBuilder struct { + BaseURL string + GalleryID string +} + +func NewGalleryURLBuilder(baseURL string, gallery *models.Gallery) GalleryURLBuilder { + return GalleryURLBuilder{ + BaseURL: baseURL, + GalleryID: strconv.Itoa(gallery.ID), + } +} + +func (b GalleryURLBuilder) GetPreviewURL() string { + return b.BaseURL + "/gallery/" + b.GalleryID + "/preview" +} diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index 5a525857b..4cdd0d8ee 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -301,6 +301,29 @@ func (_m *ImageReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) return r0, r1 } +// FindByGalleryIDIndex provides a mock function with given fields: ctx, galleryID, index +func (_m *ImageReaderWriter) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) { + ret := _m.Called(ctx, galleryID, index) + + var r0 *models.Image + if rf, ok := ret.Get(0).(func(context.Context, int, uint) *models.Image); ok { + r0 = rf(ctx, galleryID, index) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Image) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, uint) error); ok { + r1 = rf(ctx, galleryID, index) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByZipFileID provides a mock function with given fields: ctx, zipFileID func (_m *ImageReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) { ret := _m.Called(ctx, zipFileID) diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index ead05105b..fd58ed762 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -18,6 +18,7 @@ type ImageFinder interface { FindByFolderID(ctx context.Context, fileID FolderID) ([]*Image, error) FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Image, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Image, error) + FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*Image, error) } // ImageQueryer provides methods to query images. diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index dc4ed920f..3d1882a1e 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -568,8 +568,6 @@ func (qb *ImageStore) FindByChecksum(ctx context.Context, checksum string) ([]*m func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Image, error) { table := qb.table() - fileTable := fileTableMgr.table - folderTable := folderTableMgr.table sq := dialect.From(table). InnerJoin( @@ -584,7 +582,7 @@ func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mo table.Col(idColumn).Eq( sq, ), - ).Order(folderTable.Col("path").Asc(), fileTable.Col("basename").Asc()) + ).Order(goqu.L("COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI").Asc()) ret, err := qb.getMany(ctx, q) if err != nil { @@ -594,6 +592,33 @@ func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mo return ret, nil } +func (qb *ImageStore) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) { + table := qb.table() + fileTable := fileTableMgr.table + folderTable := folderTableMgr.table + + q := qb.selectDataset(). + InnerJoin( + galleriesImagesJoinTable, + goqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))), + ). + Where(galleriesImagesJoinTable.Col(galleryIDColumn).Eq(galleryID)). + Prepared(true). + Order(folderTable.Col("path").Asc(), fileTable.Col("basename").Asc()). + Limit(1).Offset(index) + + ret, err := qb.getMany(ctx, q) + if err != nil { + return nil, fmt.Errorf("getting images for gallery %d: %w", galleryID, err) + } + + if len(ret) == 0 { + return nil, nil + } + + return ret[0], nil +} + func (qb *ImageStore) CountByGalleryID(ctx context.Context, galleryID int) (int, error) { joinTable := goqu.T(galleriesImagesTable) diff --git a/ui/v2.5/graphql/data/gallery-slim.graphql b/ui/v2.5/graphql/data/gallery-slim.graphql index fd2688777..51036d0e3 100644 --- a/ui/v2.5/graphql/data/gallery-slim.graphql +++ b/ui/v2.5/graphql/data/gallery-slim.graphql @@ -48,4 +48,7 @@ fragment SlimGalleryData on Gallery { scenes { ...SlimSceneData } + paths { + preview + } } diff --git a/ui/v2.5/graphql/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql index 5a5db3c1a..9eb570f8e 100644 --- a/ui/v2.5/graphql/data/gallery.graphql +++ b/ui/v2.5/graphql/data/gallery.graphql @@ -11,6 +11,10 @@ fragment GalleryData on Gallery { rating100 organized + paths { + preview + } + files { ...GalleryFileData } @@ -52,6 +56,9 @@ fragment SelectGalleryData on Gallery { thumbnail } } + paths { + preview + } files { path } diff --git a/ui/v2.5/graphql/queries/gallery.graphql b/ui/v2.5/graphql/queries/gallery.graphql index 6c33b9910..5c9f786e7 100644 --- a/ui/v2.5/graphql/queries/gallery.graphql +++ b/ui/v2.5/graphql/queries/gallery.graphql @@ -28,3 +28,11 @@ query FindGalleriesForSelect( } } } + +query FindGalleryImageID($id: ID!, $index: Int!) { + findGallery(id: $id) { + image(index: $index) { + id + } + } +} diff --git a/ui/v2.5/src/components/Galleries/Galleries.tsx b/ui/v2.5/src/components/Galleries/Galleries.tsx index cc2e84ff7..db3db8ddd 100644 --- a/ui/v2.5/src/components/Galleries/Galleries.tsx +++ b/ui/v2.5/src/components/Galleries/Galleries.tsx @@ -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> = ({ + 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 ; + } + + if (loading) return ; + if (error) return ; + if (!data?.findGallery) + return ; + + return ; +}; const Galleries: React.FC = () => { useScrollToTopOnMount(); @@ -22,6 +54,11 @@ const GalleryRoutes: React.FC = () => { + diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 0c7491dc6..26542f4af 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -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 = ({ + gallery, + isPortrait = false, + onScrubberClick, +}) => { + const [imgSrc, setImgSrc] = useState( + gallery.cover?.paths.thumbnail ?? undefined + ); + + return ( +
+ {!!imgSrc && ( + {gallery.title + )} + +
+ ); +}; interface IProps { gallery: GQL.SlimGalleryDataFragment; @@ -25,6 +64,7 @@ interface IProps { } export const GalleryCard: React.FC = (props) => { + const history = useHistory(); const [cardWidth, setCardWidth] = useState(); useEffect(() => { @@ -167,14 +207,13 @@ export const GalleryCard: React.FC = (props) => { linkClassName="gallery-card-header" image={ <> - {props.gallery.cover ? ( - {props.gallery.title - ) : undefined} + { + console.log(i); + history.push(`/galleries/${props.gallery.id}/images/${i}`); + }} + /> } diff --git a/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx new file mode 100644 index 000000000..6bc10274a --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx @@ -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>; +}> = ({ + className, + previewPath, + defaultPath, + imageCount, + onClick, + onPathChanged, +}) => { + const [activeIndex, setActiveIndex] = useState(); + 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 ( +
+ debounceSetActiveIndex(i)} + onClick={() => onScrubberClick()} + /> +
+ ); +}; diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index f9fdaffcd..4d5b6bcae 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -102,6 +102,14 @@ color: $text-color; } + &-cover { + position: relative; + } + + .preview-scrubber { + top: 0; + } + &-image { object-fit: contain; } diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index aec85eda6..cdfc3a6e9 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -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 = ({ - totalSprites, - activeIndex, - setActiveIndex, - onClick, -}) => { - function getActiveIndex(e: React.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) { - 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(); - } - - const indicatorStyle = useMemo(() => { - if (activeIndex === undefined || !totalSprites) return {}; - - const width = (activeIndex / totalSprites) * 100; - - return { - width: `${width}%`, - }; - }, [activeIndex, totalSprites]); - - return ( -
-
-
- {activeIndex !== undefined && ( -
- )} -
-
- ); -}; +import { HoverScrubber } from "../Shared/HoverScrubber"; interface IScenePreviewProps { vttPath: string | undefined; diff --git a/ui/v2.5/src/components/Shared/HoverScrubber.tsx b/ui/v2.5/src/components/Shared/HoverScrubber.tsx new file mode 100644 index 000000000..f658e1fa2 --- /dev/null +++ b/ui/v2.5/src/components/Shared/HoverScrubber.tsx @@ -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 = ({ + totalSprites, + activeIndex, + setActiveIndex, + onClick, +}) => { + function getActiveIndex(e: React.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) { + 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(); + } + + const indicatorStyle = useMemo(() => { + if (activeIndex === undefined || !totalSprites) return {}; + + const width = ((activeIndex + 1) / totalSprites) * 100; + + return { + width: `${width}%`, + }; + }, [activeIndex, totalSprites]); + + return ( +
+
+
+ {activeIndex !== undefined && ( +
+ )} +
+
+ ); +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 949a061c1..1685e92dc 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -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, diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index c277e864a..a2dce6acb 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -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 {