mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Gallery scrubber (#5133)
This commit is contained in:
parent
ce47efc415
commit
996dfb1c2f
21 changed files with 501 additions and 102 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ package api
|
|||
type key int
|
||||
|
||||
const (
|
||||
// galleryKey key = 0
|
||||
performerKey key = iota + 1
|
||||
galleryKey key = 0
|
||||
performerKey
|
||||
sceneKey
|
||||
studioKey
|
||||
groupKey
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
116
internal/api/routes_gallery.go
Normal file
116
internal/api/routes_gallery.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
23
internal/api/urlbuilders/gallery.go
Normal file
23
internal/api/urlbuilders/gallery.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,4 +48,7 @@ fragment SlimGalleryData on Gallery {
|
|||
scenes {
|
||||
...SlimSceneData
|
||||
}
|
||||
paths {
|
||||
preview
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,3 +28,11 @@ query FindGalleriesForSelect(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
query FindGalleryImageID($id: ID!, $index: Int!) {
|
||||
findGallery(id: $id) {
|
||||
image(index: $index) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue