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"
|
"Gallery type"
|
||||||
type Gallery {
|
type Gallery {
|
||||||
id: ID!
|
id: ID!
|
||||||
|
|
@ -25,6 +29,9 @@ type Gallery {
|
||||||
performers: [Performer!]!
|
performers: [Performer!]!
|
||||||
|
|
||||||
cover: Image
|
cover: Image
|
||||||
|
|
||||||
|
paths: GalleryPathsType! # Resolver
|
||||||
|
image(index: Int!): Image!
|
||||||
}
|
}
|
||||||
|
|
||||||
input GalleryCreateInput {
|
input GalleryCreateInput {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ package api
|
||||||
type key int
|
type key int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// galleryKey key = 0
|
galleryKey key = 0
|
||||||
performerKey key = iota + 1
|
performerKey
|
||||||
sceneKey
|
sceneKey
|
||||||
studioKey
|
studioKey
|
||||||
groupKey
|
groupKey
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/stashapp/stash/internal/api/loaders"
|
"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/internal/manager/config"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/image"
|
"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
|
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) {
|
func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||||
mgr := manager.GetInstance()
|
|
||||||
img := r.Context().Value(imageKey).(*models.Image)
|
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)
|
filepath := mgr.Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth)
|
||||||
|
|
||||||
// if the thumbnail doesn't exist, encode on the fly
|
// 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("/performer", server.getPerformerRoutes())
|
||||||
r.Mount("/scene", server.getSceneRoutes())
|
r.Mount("/scene", server.getSceneRoutes())
|
||||||
|
r.Mount("/gallery", server.getGalleryRoutes())
|
||||||
r.Mount("/image", server.getImageRoutes())
|
r.Mount("/image", server.getImageRoutes())
|
||||||
r.Mount("/studio", server.getStudioRoutes())
|
r.Mount("/studio", server.getStudioRoutes())
|
||||||
r.Mount("/group", server.getGroupRoutes())
|
r.Mount("/group", server.getGroupRoutes())
|
||||||
|
|
@ -326,6 +327,16 @@ func (s *Server) getSceneRoutes() chi.Router {
|
||||||
}.Routes()
|
}.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 {
|
func (s *Server) getImageRoutes() chi.Router {
|
||||||
repo := s.manager.Repository
|
repo := s.manager.Repository
|
||||||
return imageRoutes{
|
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
|
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
|
// FindByZipFileID provides a mock function with given fields: ctx, zipFileID
|
||||||
func (_m *ImageReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) {
|
func (_m *ImageReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) {
|
||||||
ret := _m.Called(ctx, zipFileID)
|
ret := _m.Called(ctx, zipFileID)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ type ImageFinder interface {
|
||||||
FindByFolderID(ctx context.Context, fileID FolderID) ([]*Image, error)
|
FindByFolderID(ctx context.Context, fileID FolderID) ([]*Image, error)
|
||||||
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Image, error)
|
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Image, error)
|
||||||
FindByGalleryID(ctx context.Context, galleryID int) ([]*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.
|
// 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) {
|
func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Image, error) {
|
||||||
table := qb.table()
|
table := qb.table()
|
||||||
fileTable := fileTableMgr.table
|
|
||||||
folderTable := folderTableMgr.table
|
|
||||||
|
|
||||||
sq := dialect.From(table).
|
sq := dialect.From(table).
|
||||||
InnerJoin(
|
InnerJoin(
|
||||||
|
|
@ -584,7 +582,7 @@ func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mo
|
||||||
table.Col(idColumn).Eq(
|
table.Col(idColumn).Eq(
|
||||||
sq,
|
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)
|
ret, err := qb.getMany(ctx, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -594,6 +592,33 @@ func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mo
|
||||||
return ret, nil
|
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) {
|
func (qb *ImageStore) CountByGalleryID(ctx context.Context, galleryID int) (int, error) {
|
||||||
joinTable := goqu.T(galleriesImagesTable)
|
joinTable := goqu.T(galleriesImagesTable)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,7 @@ fragment SlimGalleryData on Gallery {
|
||||||
scenes {
|
scenes {
|
||||||
...SlimSceneData
|
...SlimSceneData
|
||||||
}
|
}
|
||||||
|
paths {
|
||||||
|
preview
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ fragment GalleryData on Gallery {
|
||||||
rating100
|
rating100
|
||||||
organized
|
organized
|
||||||
|
|
||||||
|
paths {
|
||||||
|
preview
|
||||||
|
}
|
||||||
|
|
||||||
files {
|
files {
|
||||||
...GalleryFileData
|
...GalleryFileData
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +56,9 @@ fragment SelectGalleryData on Gallery {
|
||||||
thumbnail
|
thumbnail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
paths {
|
||||||
|
preview
|
||||||
|
}
|
||||||
files {
|
files {
|
||||||
path
|
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 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 { Helmet } from "react-helmet";
|
||||||
import { useTitleProps } from "src/hooks/title";
|
import { useTitleProps } from "src/hooks/title";
|
||||||
import Gallery from "./GalleryDetails/Gallery";
|
import Gallery from "./GalleryDetails/Gallery";
|
||||||
|
|
@ -7,6 +7,38 @@ import GalleryCreate from "./GalleryDetails/GalleryCreate";
|
||||||
import { GalleryList } from "./GalleryList";
|
import { GalleryList } from "./GalleryList";
|
||||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
import { View } from "../List/views";
|
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 = () => {
|
const Galleries: React.FC = () => {
|
||||||
useScrollToTopOnMount();
|
useScrollToTopOnMount();
|
||||||
|
|
@ -22,6 +54,11 @@ const GalleryRoutes: React.FC = () => {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/galleries" component={Galleries} />
|
<Route exact path="/galleries" component={Galleries} />
|
||||||
<Route exact path="/galleries/new" component={GalleryCreate} />
|
<Route exact path="/galleries/new" component={GalleryCreate} />
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path="/galleries/:id/images/:index"
|
||||||
|
component={GalleryImage}
|
||||||
|
/>
|
||||||
<Route path="/galleries/:id/:tab?" component={Gallery} />
|
<Route path="/galleries/:id/:tab?" component={Gallery} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,45 @@ import { faBox, faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { galleryTitle } from "src/core/galleries";
|
import { galleryTitle } from "src/core/galleries";
|
||||||
import ScreenUtils from "src/utils/screen";
|
import ScreenUtils from "src/utils/screen";
|
||||||
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
|
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 {
|
interface IProps {
|
||||||
gallery: GQL.SlimGalleryDataFragment;
|
gallery: GQL.SlimGalleryDataFragment;
|
||||||
|
|
@ -25,6 +64,7 @@ interface IProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GalleryCard: React.FC<IProps> = (props) => {
|
export const GalleryCard: React.FC<IProps> = (props) => {
|
||||||
|
const history = useHistory();
|
||||||
const [cardWidth, setCardWidth] = useState<number>();
|
const [cardWidth, setCardWidth] = useState<number>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -167,14 +207,13 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
||||||
linkClassName="gallery-card-header"
|
linkClassName="gallery-card-header"
|
||||||
image={
|
image={
|
||||||
<>
|
<>
|
||||||
{props.gallery.cover ? (
|
<GalleryPreview
|
||||||
<img
|
gallery={props.gallery}
|
||||||
loading="lazy"
|
onScrubberClick={(i) => {
|
||||||
className="gallery-card-image"
|
console.log(i);
|
||||||
alt={props.gallery.title ?? ""}
|
history.push(`/galleries/${props.gallery.id}/images/${i}`);
|
||||||
src={`${props.gallery.cover.paths.thumbnail}`}
|
}}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
|
||||||
<RatingBanner rating={props.gallery.rating100} />
|
<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;
|
color: $text-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-cover {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scrubber {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&-image {
|
&-image {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,89 +8,7 @@ import React, {
|
||||||
import { useSpriteInfo } from "src/hooks/sprite";
|
import { useSpriteInfo } from "src/hooks/sprite";
|
||||||
import { useThrottle } from "src/hooks/throttle";
|
import { useThrottle } from "src/hooks/throttle";
|
||||||
import TextUtils from "src/utils/text";
|
import TextUtils from "src/utils/text";
|
||||||
import cx from "classnames";
|
import { HoverScrubber } from "../Shared/HoverScrubber";
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IScenePreviewProps {
|
interface IScenePreviewProps {
|
||||||
vttPath: string | undefined;
|
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 });
|
return GQL.useFindGalleryQuery({ variables: { id }, skip });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useFindGalleryImageID = (id: string, index: number) => {
|
||||||
|
return GQL.useFindGalleryImageIdQuery({ variables: { id, index } });
|
||||||
|
};
|
||||||
|
|
||||||
export const useFindGalleries = (filter?: ListFilterModel) =>
|
export const useFindGalleries = (filter?: ListFilterModel) =>
|
||||||
GQL.useFindGalleriesQuery({
|
GQL.useFindGalleriesQuery({
|
||||||
skip: filter === undefined,
|
skip: filter === undefined,
|
||||||
|
|
|
||||||
|
|
@ -500,7 +500,7 @@ textarea.text-input {
|
||||||
.zoom-0 {
|
.zoom-0 {
|
||||||
.gallery-card-image,
|
.gallery-card-image,
|
||||||
.tag-card-image {
|
.tag-card-image {
|
||||||
max-height: 180px;
|
height: 180px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,7 +509,7 @@ textarea.text-input {
|
||||||
|
|
||||||
.gallery-card-image,
|
.gallery-card-image,
|
||||||
.tag-card-image {
|
.tag-card-image {
|
||||||
max-height: 240px;
|
height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-card-preview {
|
.image-card-preview {
|
||||||
|
|
@ -520,7 +520,7 @@ textarea.text-input {
|
||||||
.zoom-2 {
|
.zoom-2 {
|
||||||
.gallery-card-image,
|
.gallery-card-image,
|
||||||
.tag-card-image {
|
.tag-card-image {
|
||||||
max-height: 360px;
|
height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-card-preview {
|
.image-card-preview {
|
||||||
|
|
@ -531,7 +531,7 @@ textarea.text-input {
|
||||||
.zoom-3 {
|
.zoom-3 {
|
||||||
.tag-card-image,
|
.tag-card-image,
|
||||||
.gallery-card-image {
|
.gallery-card-image {
|
||||||
max-height: 480px;
|
height: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-card-preview {
|
.image-card-preview {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue