diff --git a/pkg/gallery/export.go b/pkg/gallery/export.go index 085dc543f..9b63ec8c2 100644 --- a/pkg/gallery/export.go +++ b/pkg/gallery/export.go @@ -59,3 +59,12 @@ func GetStudioName(reader models.StudioReader, gallery *models.Gallery) (string, return "", nil } + +func GetIDs(galleries []*models.Gallery) []int { + var results []int + for _, gallery := range galleries { + results = append(results, gallery.ID) + } + + return results +} diff --git a/pkg/manager/task_export.go b/pkg/manager/task_export.go index b8f0306d6..5e654876c 100644 --- a/pkg/manager/task_export.go +++ b/pkg/manager/task_export.go @@ -125,12 +125,25 @@ func (t *ExportTask) Start(wg *sync.WaitGroup) { paths.EnsureJSONDirs(t.baseDir) + // include movie scenes and gallery images + if !t.full { + // only include movie scenes if includeDependencies is also set + if !t.scenes.all && t.includeDependencies { + t.populateMovieScenes() + } + + // always export gallery images + if !t.images.all { + t.populateGalleryImages() + } + } + t.ExportScenes(workerCount) t.ExportImages(workerCount) t.ExportGalleries(workerCount) + t.ExportMovies(workerCount) t.ExportPerformers(workerCount) t.ExportStudios(workerCount) - t.ExportMovies(workerCount) t.ExportTags(workerCount) if err := t.json.saveMappings(t.Mappings); err != nil { @@ -229,6 +242,66 @@ func (t *ExportTask) zipFile(fn, outDir string, z *zip.Writer) error { return nil } +func (t *ExportTask) populateMovieScenes() { + reader := models.NewMovieReaderWriter(nil) + sceneReader := models.NewSceneReaderWriter(nil) + + var movies []*models.Movie + var err error + all := t.full || (t.movies != nil && t.movies.all) + if all { + movies, err = reader.All() + } else if t.movies != nil && len(t.movies.IDs) > 0 { + movies, err = reader.FindMany(t.movies.IDs) + } + + if err != nil { + logger.Errorf("[movies] failed to fetch movies: %s", err.Error()) + } + + for _, m := range movies { + scenes, err := sceneReader.FindByMovieID(m.ID) + if err != nil { + logger.Errorf("[movies] <%s> failed to fetch scenes for movie: %s", m.Checksum, err.Error()) + continue + } + + for _, s := range scenes { + t.scenes.IDs = utils.IntAppendUnique(t.scenes.IDs, s.ID) + } + } +} + +func (t *ExportTask) populateGalleryImages() { + reader := models.NewGalleryReaderWriter(nil) + imageReader := models.NewImageReaderWriter(nil) + + var galleries []*models.Gallery + var err error + all := t.full || (t.galleries != nil && t.galleries.all) + if all { + galleries, err = reader.All() + } else if t.galleries != nil && len(t.galleries.IDs) > 0 { + galleries, err = reader.FindMany(t.galleries.IDs) + } + + if err != nil { + logger.Errorf("[galleries] failed to fetch galleries: %s", err.Error()) + } + + for _, g := range galleries { + images, err := imageReader.FindByGalleryID(g.ID) + if err != nil { + logger.Errorf("[galleries] <%s> failed to fetch images for gallery: %s", g.Checksum, err.Error()) + continue + } + + for _, i := range images { + t.images.IDs = utils.IntAppendUnique(t.images.IDs, i.ID) + } + } +} + func (t *ExportTask) ExportScenes(workers int) { var scenesWg sync.WaitGroup @@ -464,10 +537,7 @@ func exportImage(wg *sync.WaitGroup, jobChan <-chan *models.Image, t *ExportTask t.studios.IDs = utils.IntAppendUnique(t.studios.IDs, int(s.StudioID.Int64)) } - // if imageGallery != nil { - // t.galleries.IDs = utils.IntAppendUnique(t.galleries.IDs, imageGallery.ID) - // } - + t.galleries.IDs = utils.IntAppendUniques(t.galleries.IDs, gallery.GetIDs(imageGalleries)) t.tags.IDs = utils.IntAppendUniques(t.tags.IDs, tag.GetIDs(tags)) t.performers.IDs = utils.IntAppendUniques(t.performers.IDs, performer.GetIDs(performers)) } @@ -853,6 +923,12 @@ func (t *ExportTask) exportMovie(wg *sync.WaitGroup, jobChan <-chan *models.Movi continue } + if t.includeDependencies { + if m.StudioID.Valid { + t.studios.IDs = utils.IntAppendUnique(t.studios.IDs, int(m.StudioID.Int64)) + } + } + movieJSON, err := t.json.getMovie(m.Checksum) if err != nil { logger.Debugf("[movies] error reading movie json: %s", err.Error()) diff --git a/pkg/models/image.go b/pkg/models/image.go index ed1735817..5ea398bed 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -8,6 +8,7 @@ type ImageReader interface { // Find(id int) (*Image, error) FindMany(ids []int) ([]*Image, error) FindByChecksum(checksum string) (*Image, error) + FindByGalleryID(galleryID int) ([]*Image, error) // FindByPath(path string) (*Image, error) // FindByPerformerID(performerID int) ([]*Image, error) // CountByPerformerID(performerID int) (int, error) @@ -55,6 +56,10 @@ func (t *imageReaderWriter) FindByChecksum(checksum string) (*Image, error) { return t.qb.FindByChecksum(checksum) } +func (t *imageReaderWriter) FindByGalleryID(galleryID int) ([]*Image, error) { + return t.qb.FindByGalleryID(galleryID) +} + func (t *imageReaderWriter) All() ([]*Image, error) { return t.qb.All() } diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index bed6fbd5d..7010075cb 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -81,6 +81,29 @@ func (_m *ImageReaderWriter) FindByChecksum(checksum string) (*models.Image, err return r0, r1 } +// FindByGalleryID provides a mock function with given fields: galleryID +func (_m *ImageReaderWriter) FindByGalleryID(galleryID int) ([]*models.Image, error) { + ret := _m.Called(galleryID) + + var r0 []*models.Image + if rf, ok := ret.Get(0).(func(int) []*models.Image); ok { + r0 = rf(galleryID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Image) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(galleryID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindMany provides a mock function with given fields: ids func (_m *ImageReaderWriter) FindMany(ids []int) ([]*models.Image, error) { ret := _m.Called(ids) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index eee27824b..a88acc723 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -81,6 +81,29 @@ func (_m *SceneReaderWriter) FindByChecksum(checksum string) (*models.Scene, err return r0, r1 } +// FindByMovieID provides a mock function with given fields: movieID +func (_m *SceneReaderWriter) FindByMovieID(movieID int) ([]*models.Scene, error) { + ret := _m.Called(movieID) + + var r0 []*models.Scene + if rf, ok := ret.Get(0).(func(int) []*models.Scene); ok { + r0 = rf(movieID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Scene) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(movieID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByOSHash provides a mock function with given fields: oshash func (_m *SceneReaderWriter) FindByOSHash(oshash string) (*models.Scene, error) { ret := _m.Called(oshash) diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 60f282e41..2580506b6 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -13,7 +13,7 @@ type SceneReader interface { // FindByPerformerID(performerID int) ([]*Scene, error) // CountByPerformerID(performerID int) (int, error) // FindByStudioID(studioID int) ([]*Scene, error) - // FindByMovieID(movieID int) ([]*Scene, error) + FindByMovieID(movieID int) ([]*Scene, error) // CountByMovieID(movieID int) (int, error) // Count() (int, error) // SizeCount() (string, error) @@ -73,6 +73,10 @@ func (t *sceneReaderWriter) FindByOSHash(oshash string) (*Scene, error) { return t.qb.FindByOSHash(oshash) } +func (t *sceneReaderWriter) FindByMovieID(movieID int) ([]*Scene, error) { + return t.qb.FindByMovieID(movieID) +} + func (t *sceneReaderWriter) All() ([]*Scene, error) { return t.qb.All() } diff --git a/ui/v2.5/src/components/Changelog/versions/v040.md b/ui/v2.5/src/components/Changelog/versions/v040.md index 3a684ee1f..8492d9ce0 100644 --- a/ui/v2.5/src/components/Changelog/versions/v040.md +++ b/ui/v2.5/src/components/Changelog/versions/v040.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Add selective export of all objects. * Add stash-box tagger to scenes page. * Add filters tab in scene page. * Add selectable streaming quality profiles in the scene player. @@ -6,7 +7,6 @@ * Add support for individual images and manual creation of galleries. * Add various fields to galleries. * Add partial import from zip file. -* Add selective scene export. ### 🎨 Improvements * Increase page size limit to 1000 and add new page size options. diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 20a4a60be..e5352a8e9 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -1,10 +1,11 @@ -import { Card, Button, ButtonGroup, Form } from "react-bootstrap"; +import { Button, ButtonGroup } from "react-bootstrap"; import React from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { FormattedPlural } from "react-intl"; import { useConfiguration } from "src/core/StashService"; import { HoverPopover, Icon, TagLink } from "../Shared"; +import { BasicCard } from "../Shared/BasicCard"; interface IProps { gallery: GQL.GalleryDataFragment; @@ -137,61 +138,13 @@ export const GalleryCard: React.FC = (props) => { ); } - function handleImageClick( - event: React.MouseEvent - ) { - const { shiftKey } = event; - - if (props.selecting) { - props.onSelectedChanged(!props.selected, shiftKey); - event.preventDefault(); - } - } - - function handleDrag(event: React.DragEvent) { - if (props.selecting) { - event.dataTransfer.setData("text/plain", ""); - event.dataTransfer.setDragImage(new Image(), 0, 0); - } - } - - function handleDragOver(event: React.DragEvent) { - const ev = event; - const shiftKey = false; - - if (props.selecting && !props.selected) { - props.onSelectedChanged(true, shiftKey); - } - - ev.dataTransfer.dropEffect = "move"; - ev.preventDefault(); - } - - let shiftKey = false; - return ( - - props.onSelectedChanged(!props.selected, shiftKey)} - onClick={(event: React.MouseEvent) => { - // eslint-disable-next-line prefer-destructuring - shiftKey = event.shiftKey; - event.stopPropagation(); - }} - /> - -
- + {props.gallery.cover ? ( = (props) => { /> ) : undefined} {maybeRenderRatingBanner()} - - {maybeRenderSceneStudioOverlay()} -
-
- -
- {props.gallery.title ?? props.gallery.path} -
- - - {props.gallery.images.length}  - - . - -
- {maybeRenderPopoverButtonGroup()} -
+ + } + overlays={maybeRenderSceneStudioOverlay()} + details={ + <> + +
+ {props.gallery.title ?? props.gallery.path} +
+ + + {props.gallery.images.length}  + + . + + + } + popovers={maybeRenderPopoverButtonGroup()} + selected={props.selected} + selecting={props.selecting} + onSelectedChanged={props.onSelectedChanged} + /> ); }; diff --git a/ui/v2.5/src/components/Galleries/GalleryExportDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryExportDialog.tsx deleted file mode 100644 index 9ff1ce451..000000000 --- a/ui/v2.5/src/components/Galleries/GalleryExportDialog.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useState } from "react"; -import { Form } from "react-bootstrap"; -import { mutateExportObjects } from "src/core/StashService"; -import { Modal } from "src/components/Shared"; -import { useToast } from "src/hooks"; -import { downloadFile } from "src/utils"; - -interface IGalleryExportDialogProps { - selectedIds?: string[]; - all?: boolean; - onClose: () => void; -} - -export const GalleryExportDialog: React.FC = ( - props: IGalleryExportDialogProps -) => { - const [includeDependencies, setIncludeDependencies] = useState(true); - - // Network state - const [isRunning, setIsRunning] = useState(false); - - const Toast = useToast(); - - async function onExport() { - try { - setIsRunning(true); - const ret = await mutateExportObjects({ - galleries: { - ids: props.selectedIds, - all: props.all, - }, - includeDependencies, - }); - - // download the result - if (ret.data && ret.data.exportObjects) { - const link = ret.data.exportObjects; - downloadFile(link); - } - } catch (e) { - Toast.error(e); - } finally { - setIsRunning(false); - props.onClose(); - } - } - - return ( - props.onClose(), - text: "Cancel", - variant: "secondary", - }} - isRunning={isRunning} - > -
- - setIncludeDependencies(!includeDependencies)} - /> - -
-
- ); -}; diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index da6a9388e..b7870bbae 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -12,9 +12,9 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { queryFindGalleries } from "src/core/StashService"; import { GalleryCard } from "./GalleryCard"; -import { GalleryExportDialog } from "./GalleryExportDialog"; import { EditGalleriesDialog } from "./EditGalleriesDialog"; import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog"; +import { ExportDialog } from "../Shared/ExportDialog"; interface IGalleryList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -110,9 +110,13 @@ export const GalleryList: React.FC = ({ if (isExportDialogOpen) { return ( <> - { setIsExportDialogOpen(false); }} diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 559b6ee9c..d8868cfaa 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -16,8 +16,8 @@ import { IListHookOperation, showWhenSelected } from "src/hooks/ListHook"; import { ImageCard } from "./ImageCard"; import { EditImagesDialog } from "./EditImagesDialog"; import { DeleteImagesDialog } from "./DeleteImagesDialog"; -import { ImageExportDialog } from "./ImageExportDialog"; import "flexbin/flexbin.css"; +import { ExportDialog } from "../Shared/ExportDialog"; interface IImageWallProps { images: GQL.SlimImageDataFragment[]; @@ -162,9 +162,13 @@ export const ImageList: React.FC = ({ if (isExportDialogOpen) { return ( <> - { setIsExportDialogOpen(false); }} diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx index f90ab5088..1980f72f0 100644 --- a/ui/v2.5/src/components/Movies/MovieCard.tsx +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -1,12 +1,14 @@ -import { Card } from "react-bootstrap"; import React, { FunctionComponent } from "react"; import { FormattedPlural } from "react-intl"; -import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; +import { BasicCard } from "../Shared/BasicCard"; interface IProps { movie: GQL.MovieDataFragment; sceneIndex?: number; + selecting?: boolean; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } export const MovieCard: FunctionComponent = (props: IProps) => { @@ -43,19 +45,29 @@ export const MovieCard: FunctionComponent = (props: IProps) => { } return ( - - - {props.movie.name - {maybeRenderRatingBanner()} - -
-
{props.movie.name}
- {maybeRenderSceneNumber()} -
-
+ + {props.movie.name + {maybeRenderRatingBanner()} + + } + details={ + <> +
{props.movie.name}
+ {maybeRenderSceneNumber()} + + } + selected={props.selected} + selecting={props.selecting} + onSelectedChanged={props.onSelectedChanged} + /> ); }; diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx index a8a5cdba3..e801bc056 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -1,30 +1,138 @@ -import React from "react"; +import React, { useState } from "react"; +import _ from "lodash"; import { FindMoviesQueryResult } from "src/core/generated-graphql"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { useMoviesList } from "src/hooks/ListHook"; +import { queryFindMovies } from "src/core/StashService"; +import { showWhenSelected, useMoviesList } from "src/hooks/ListHook"; +import { useHistory } from "react-router-dom"; import { MovieCard } from "./MovieCard"; +import { ExportDialog } from "../Shared/ExportDialog"; export const MovieList: React.FC = () => { + const history = useHistory(); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [isExportAll, setIsExportAll] = useState(false); + + const otherOperations = [ + { + text: "View Random", + onClick: viewRandom, + }, + { + text: "Export...", + onClick: onExport, + isDisplayed: showWhenSelected, + }, + { + text: "Export all...", + onClick: onExportAll, + }, + ]; + + const addKeybinds = ( + result: FindMoviesQueryResult, + filter: ListFilterModel + ) => { + Mousetrap.bind("p r", () => { + viewRandom(result, filter); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }; + const listData = useMoviesList({ renderContent, + addKeybinds, + otherOperations, + selectable: true, persistState: true, }); - function renderContent( + async function viewRandom( result: FindMoviesQueryResult, filter: ListFilterModel + ) { + // query for a random image + if (result.data && result.data.findMovies) { + const { count } = result.data.findMovies; + + const index = Math.floor(Math.random() * count); + const filterCopy = _.cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindMovies(filterCopy); + if ( + singleResult && + singleResult.data && + singleResult.data.findMovies && + singleResult.data.findMovies.movies.length === 1 + ) { + const { id } = singleResult!.data!.findMovies!.movies[0]; + // navigate to the movie page + history.push(`/movies/${id}`); + } + } + } + + async function onExport() { + setIsExportAll(false); + setIsExportDialogOpen(true); + } + + async function onExportAll() { + setIsExportAll(true); + setIsExportDialogOpen(true); + } + + function maybeRenderMovieExportDialog(selectedIds: Set) { + if (isExportDialogOpen) { + return ( + <> + { + setIsExportDialogOpen(false); + }} + /> + + ); + } + } + + function renderContent( + result: FindMoviesQueryResult, + filter: ListFilterModel, + selectedIds: Set ) { if (!result.data?.findMovies) { return; } if (filter.displayMode === DisplayMode.Grid) { return ( -
- {result.data.findMovies.movies.map((p) => ( - - ))} -
+ <> + {maybeRenderMovieExportDialog(selectedIds)} +
+ {result.data.findMovies.movies.map((p) => ( + 0} + selected={selectedIds.has(p.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + listData.onSelectChange(p.id, selected, shiftKey) + } + /> + ))} +
+ ); } if (filter.displayMode === DisplayMode.List) { diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 8ff264583..958f2803e 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -1,19 +1,25 @@ import React from "react"; -import { Card } from "react-bootstrap"; import { Link } from "react-router-dom"; import { FormattedNumber, FormattedPlural, FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { NavUtils, TextUtils } from "src/utils"; import { CountryFlag } from "src/components/Shared"; +import { BasicCard } from "../Shared/BasicCard"; interface IPerformerCardProps { performer: GQL.PerformerDataFragment; ageFromDate?: string; + selecting?: boolean; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } export const PerformerCard: React.FC = ({ performer, ageFromDate, + selecting, + selected, + onSelectedChanged, }) => { const age = TextUtils.age(performer.birthdate, ageFromDate); const ageString = `${age} years old${ageFromDate ? " in this scene." : "."}`; @@ -30,35 +36,44 @@ export const PerformerCard: React.FC = ({ } return ( - - - {performer.name - {maybeRenderFavoriteBanner()} - -
-
{performer.name}
- {age !== 0 ?
{ageString}
: ""} - - - -
- Stars in  - -   - - + + {performer.name + {maybeRenderFavoriteBanner()} + + } + details={ + <> +
{performer.name}
+ {age !== 0 ?
{ageString}
: ""} + + - . -
-
-
+
+ Stars in  + +   + + + + . +
+ + } + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} + /> ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index a0f45d2c4..de6f3cf09 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -1,21 +1,35 @@ import _ from "lodash"; -import React from "react"; +import React, { useState } from "react"; import { useHistory } from "react-router-dom"; import { FindPerformersQueryResult } from "src/core/generated-graphql"; import { queryFindPerformers } from "src/core/StashService"; import { usePerformersList } from "src/hooks"; +import { showWhenSelected } from "src/hooks/ListHook"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; +import { ExportDialog } from "src/components/Shared/ExportDialog"; import { PerformerCard } from "./PerformerCard"; import { PerformerListTable } from "./PerformerListTable"; export const PerformerList: React.FC = () => { const history = useHistory(); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [isExportAll, setIsExportAll] = useState(false); + const otherOperations = [ { text: "Open Random", onClick: getRandom, }, + { + text: "Export...", + onClick: onExport, + isDisplayed: showWhenSelected, + }, + { + text: "Export all...", + onClick: onExportAll, + }, ]; const addKeybinds = ( @@ -31,10 +45,41 @@ export const PerformerList: React.FC = () => { }; }; + async function onExport() { + setIsExportAll(false); + setIsExportDialogOpen(true); + } + + async function onExportAll() { + setIsExportAll(true); + setIsExportDialogOpen(true); + } + + function maybeRenderPerformerExportDialog(selectedIds: Set) { + if (isExportDialogOpen) { + return ( + <> + { + setIsExportDialogOpen(false); + }} + /> + + ); + } + } + const listData = usePerformersList({ otherOperations, renderContent, addKeybinds, + selectable: true, persistState: true, }); @@ -63,18 +108,30 @@ export const PerformerList: React.FC = () => { function renderContent( result: FindPerformersQueryResult, - filter: ListFilterModel + filter: ListFilterModel, + selectedIds: Set ) { if (!result.data?.findPerformers) { return; } if (filter.displayMode === DisplayMode.Grid) { return ( -
- {result.data.findPerformers.performers.map((p) => ( - - ))} -
+ <> + {maybeRenderPerformerExportDialog(selectedIds)} +
+ {result.data.findPerformers.performers.map((p) => ( + 0} + selected={selectedIds.has(p.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + listData.onSelectChange(p.id, selected, shiftKey) + } + /> + ))} +
+ ); } if (filter.displayMode === DisplayMode.List) { diff --git a/ui/v2.5/src/components/Scenes/SceneExportDialog.tsx b/ui/v2.5/src/components/Scenes/SceneExportDialog.tsx deleted file mode 100644 index 6d1084caa..000000000 --- a/ui/v2.5/src/components/Scenes/SceneExportDialog.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useState } from "react"; -import { Form } from "react-bootstrap"; -import { mutateExportObjects } from "src/core/StashService"; -import { Modal } from "src/components/Shared"; -import { useToast } from "src/hooks"; -import { downloadFile } from "src/utils"; - -interface ISceneExportDialogProps { - selectedIds?: string[]; - all?: boolean; - onClose: () => void; -} - -export const SceneExportDialog: React.FC = ( - props: ISceneExportDialogProps -) => { - const [includeDependencies, setIncludeDependencies] = useState(true); - - // Network state - const [isRunning, setIsRunning] = useState(false); - - const Toast = useToast(); - - async function onExport() { - try { - setIsRunning(true); - const ret = await mutateExportObjects({ - scenes: { - ids: props.selectedIds, - all: props.all, - }, - includeDependencies, - }); - - // download the result - if (ret.data && ret.data.exportObjects) { - const link = ret.data.exportObjects; - downloadFile(link); - } - } catch (e) { - Toast.error(e); - } finally { - setIsRunning(false); - props.onClose(); - } - } - - return ( - props.onClose(), - text: "Cancel", - variant: "secondary", - }} - isRunning={isRunning} - > -
- - setIncludeDependencies(!includeDependencies)} - /> - -
-
- ); -}; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index e7d54cd0f..e6045a902 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -17,7 +17,7 @@ import { SceneListTable } from "./SceneListTable"; import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; import { SceneGenerateDialog } from "./SceneGenerateDialog"; -import { SceneExportDialog } from "./SceneExportDialog"; +import { ExportDialog } from "../Shared/ExportDialog"; interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -138,9 +138,13 @@ export const SceneList: React.FC = ({ if (isExportDialogOpen) { return ( <> - { setIsExportDialogOpen(false); }} diff --git a/ui/v2.5/src/components/Shared/BasicCard.tsx b/ui/v2.5/src/components/Shared/BasicCard.tsx new file mode 100644 index 000000000..a4662281b --- /dev/null +++ b/ui/v2.5/src/components/Shared/BasicCard.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { Card, Form } from "react-bootstrap"; +import { Link } from "react-router-dom"; + +interface IBasicCardProps { + className?: string; + linkClassName?: string; + url: string; + image: JSX.Element; + details: JSX.Element; + overlays?: JSX.Element; + popovers?: JSX.Element; + selecting?: boolean; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; +} + +export const BasicCard: React.FC = ( + props: IBasicCardProps +) => { + function handleImageClick( + event: React.MouseEvent + ) { + const { shiftKey } = event; + + if (!props.onSelectedChanged) { + return; + } + + if (props.selecting) { + props.onSelectedChanged(!props.selected, shiftKey); + event.preventDefault(); + } + } + + function handleDrag(event: React.DragEvent) { + if (props.selecting) { + event.dataTransfer.setData("text/plain", ""); + event.dataTransfer.setDragImage(new Image(), 0, 0); + } + } + + function handleDragOver(event: React.DragEvent) { + const ev = event; + const shiftKey = false; + + if (!props.onSelectedChanged) { + return; + } + + if (props.selecting && !props.selected) { + props.onSelectedChanged(true, shiftKey); + } + + ev.dataTransfer.dropEffect = "move"; + ev.preventDefault(); + } + + let shiftKey = false; + + function maybeRenderCheckbox() { + if (props.onSelectedChanged) { + return ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + // eslint-disable-next-line prefer-destructuring + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + ); + } + } + + return ( + + {maybeRenderCheckbox()} + +
+ + {props.image} + + {props.overlays} +
+
{props.details}
+ + {props.popovers} +
+ ); +}; diff --git a/ui/v2.5/src/components/Images/ImageExportDialog.tsx b/ui/v2.5/src/components/Shared/ExportDialog.tsx similarity index 81% rename from ui/v2.5/src/components/Images/ImageExportDialog.tsx rename to ui/v2.5/src/components/Shared/ExportDialog.tsx index 1b61cca1a..22fe31c67 100644 --- a/ui/v2.5/src/components/Images/ImageExportDialog.tsx +++ b/ui/v2.5/src/components/Shared/ExportDialog.tsx @@ -4,15 +4,15 @@ import { mutateExportObjects } from "src/core/StashService"; import { Modal } from "src/components/Shared"; import { useToast } from "src/hooks"; import { downloadFile } from "src/utils"; +import { ExportObjectsInput } from "src/core/generated-graphql"; -interface IImageExportDialogProps { - selectedIds?: string[]; - all?: boolean; +interface IExportDialogProps { + exportInput: ExportObjectsInput; onClose: () => void; } -export const ImageExportDialog: React.FC = ( - props: IImageExportDialogProps +export const ExportDialog: React.FC = ( + props: IExportDialogProps ) => { const [includeDependencies, setIncludeDependencies] = useState(true); @@ -25,10 +25,7 @@ export const ImageExportDialog: React.FC = ( try { setIsRunning(true); const ret = await mutateExportObjects({ - images: { - ids: props.selectedIds, - all: props.all, - }, + ...props.exportInput, includeDependencies, }); @@ -63,7 +60,7 @@ export const ImageExportDialog: React.FC = ( setIncludeDependencies(!includeDependencies)} /> diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 8b92d96c8..bc33f5252 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -149,3 +149,25 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active { display: inline-block; } } + +.card { + .card-check { + left: 0.5rem; + margin-top: -12px; + opacity: 0; + padding-left: 15px; + position: absolute; + top: 0.7rem; + width: 1.2rem; + z-index: 1; + + &:checked { + opacity: 0.75; + } + } + + &:hover .card-check { + opacity: 0.75; + transition: opacity 0.5s; + } +} diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 8a1bc4b7b..f521d94c4 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -1,13 +1,16 @@ -import { Card } from "react-bootstrap"; import React from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { FormattedPlural } from "react-intl"; import { NavUtils } from "src/utils"; +import { BasicCard } from "../Shared/BasicCard"; interface IProps { studio: GQL.StudioDataFragment; hideParent?: boolean; + selecting?: boolean; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } function maybeRenderParent( @@ -41,30 +44,44 @@ function maybeRenderChildren(studio: GQL.StudioDataFragment) { } } -export const StudioCard: React.FC = ({ studio, hideParent }) => { +export const StudioCard: React.FC = ({ + studio, + hideParent, + selecting, + selected, + onSelectedChanged, +}) => { return ( - - + - -
-
{studio.name}
- - {studio.scene_count}  - - . - - {maybeRenderParent(studio, hideParent)} - {maybeRenderChildren(studio)} -
-
+ } + details={ + <> +
{studio.name}
+ + {studio.scene_count}  + + . + + {maybeRenderParent(studio, hideParent)} + {maybeRenderChildren(studio)} + + } + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} + /> ); }; diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index a8a780d91..e22c50bc6 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -1,8 +1,13 @@ -import React from "react"; +import React, { useState } from "react"; +import _ from "lodash"; +import { useHistory } from "react-router-dom"; import { FindStudiosQueryResult } from "src/core/generated-graphql"; import { useStudiosList } from "src/hooks"; +import { showWhenSelected } from "src/hooks/ListHook"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; +import { queryFindStudios } from "src/core/StashService"; +import { ExportDialog } from "../Shared/ExportDialog"; import { StudioCard } from "./StudioCard"; interface IStudioList { @@ -14,15 +19,108 @@ export const StudioList: React.FC = ({ fromParent, filterHook, }) => { + const history = useHistory(); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [isExportAll, setIsExportAll] = useState(false); + + const otherOperations = [ + { + text: "View Random", + onClick: viewRandom, + }, + { + text: "Export...", + onClick: onExport, + isDisplayed: showWhenSelected, + }, + { + text: "Export all...", + onClick: onExportAll, + }, + ]; + + const addKeybinds = ( + result: FindStudiosQueryResult, + filter: ListFilterModel + ) => { + Mousetrap.bind("p r", () => { + viewRandom(result, filter); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }; + + async function viewRandom( + result: FindStudiosQueryResult, + filter: ListFilterModel + ) { + // query for a random studio + if (result.data && result.data.findStudios) { + const { count } = result.data.findStudios; + + const index = Math.floor(Math.random() * count); + const filterCopy = _.cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindStudios(filterCopy); + if ( + singleResult && + singleResult.data && + singleResult.data.findStudios && + singleResult.data.findStudios.studios.length === 1 + ) { + const { id } = singleResult!.data!.findStudios!.studios[0]; + // navigate to the studio page + history.push(`/studios/${id}`); + } + } + } + + async function onExport() { + setIsExportAll(false); + setIsExportDialogOpen(true); + } + + async function onExportAll() { + setIsExportAll(true); + setIsExportDialogOpen(true); + } + + function maybeRenderExportDialog(selectedIds: Set) { + if (isExportDialogOpen) { + return ( + <> + { + setIsExportDialogOpen(false); + }} + /> + + ); + } + } + const listData = useStudiosList({ renderContent, filterHook, + addKeybinds, + otherOperations, + selectable: true, persistState: !fromParent, }); - function renderContent( + function renderStudios( result: FindStudiosQueryResult, - filter: ListFilterModel + filter: ListFilterModel, + selectedIds: Set ) { if (!result.data?.findStudios) return; @@ -34,6 +132,11 @@ export const StudioList: React.FC = ({ key={studio.id} studio={studio} hideParent={fromParent} + selecting={selectedIds.size > 0} + selected={selectedIds.has(studio.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + listData.onSelectChange(studio.id, selected, shiftKey) + } /> ))} @@ -47,5 +150,18 @@ export const StudioList: React.FC = ({ } } + function renderContent( + result: FindStudiosQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + return ( + <> + {maybeRenderExportDialog(selectedIds)} + {renderStudios(result, filter, selectedIds)} + + ); + } + return listData.template; }; diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index 3da8f4b68..d157703df 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -1,16 +1,26 @@ -import { Card, Button, ButtonGroup } from "react-bootstrap"; +import { Button, ButtonGroup } from "react-bootstrap"; import React from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { NavUtils } from "src/utils"; import { Icon } from "../Shared"; +import { BasicCard } from "../Shared/BasicCard"; interface IProps { tag: GQL.TagDataFragment; zoomIndex: number; + selecting?: boolean; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } -export const TagCard: React.FC = ({ tag, zoomIndex }) => { +export const TagCard: React.FC = ({ + tag, + zoomIndex, + selecting, + selected, + onSelectedChanged, +}) => { function maybeRenderScenesPopoverButton() { if (!tag.scene_count) return; @@ -52,18 +62,22 @@ export const TagCard: React.FC = ({ tag, zoomIndex }) => { } return ( - - + - -
-
{tag.name}
-
- {maybeRenderPopoverButtonGroup()} -
+ } + details={
{tag.name}
} + popovers={maybeRenderPopoverButtonGroup()} + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} + /> ); }; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 6044506a7..ab58468d8 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -1,17 +1,23 @@ import React, { useState } from "react"; +import _ from "lodash"; import { FindTagsQueryResult } from "src/core/generated-graphql"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { useTagsList } from "src/hooks/ListHook"; +import { showWhenSelected, useTagsList } from "src/hooks/ListHook"; import { Button } from "react-bootstrap"; -import { Link } from "react-router-dom"; +import { Link, useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; -import { mutateMetadataAutoTag, useTagDestroy } from "src/core/StashService"; +import { + queryFindTags, + mutateMetadataAutoTag, + useTagDestroy, +} from "src/core/StashService"; import { useToast } from "src/hooks"; import { FormattedNumber } from "react-intl"; import { NavUtils } from "src/utils"; import { Icon, Modal } from "src/components/Shared"; import { TagCard } from "./TagCard"; +import { ExportDialog } from "../Shared/ExportDialog"; interface ITagList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -25,9 +31,101 @@ export const TagList: React.FC = ({ filterHook }) => { const [deleteTag] = useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput); + const history = useHistory(); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [isExportAll, setIsExportAll] = useState(false); + + const otherOperations = [ + { + text: "View Random", + onClick: viewRandom, + }, + { + text: "Export...", + onClick: onExport, + isDisplayed: showWhenSelected, + }, + { + text: "Export all...", + onClick: onExportAll, + }, + ]; + + const addKeybinds = ( + result: FindTagsQueryResult, + filter: ListFilterModel + ) => { + Mousetrap.bind("p r", () => { + viewRandom(result, filter); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }; + + async function viewRandom( + result: FindTagsQueryResult, + filter: ListFilterModel + ) { + // query for a random tag + if (result.data && result.data.findTags) { + const { count } = result.data.findTags; + + const index = Math.floor(Math.random() * count); + const filterCopy = _.cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindTags(filterCopy); + if ( + singleResult && + singleResult.data && + singleResult.data.findTags && + singleResult.data.findTags.tags.length === 1 + ) { + const { id } = singleResult!.data!.findTags!.tags[0]; + // navigate to the tag page + history.push(`/tags/${id}`); + } + } + } + + async function onExport() { + setIsExportAll(false); + setIsExportDialogOpen(true); + } + + async function onExportAll() { + setIsExportAll(true); + setIsExportDialogOpen(true); + } + + function maybeRenderExportDialog(selectedIds: Set) { + if (isExportDialogOpen) { + return ( + <> + { + setIsExportDialogOpen(false); + }} + /> + + ); + } + } + const listData = useTagsList({ renderContent, filterHook, + addKeybinds, + otherOperations, + selectable: true, zoomable: true, defaultZoomIndex: 0, persistState: true, @@ -61,7 +159,7 @@ export const TagList: React.FC = ({ filterHook }) => { } } - function renderContent( + function renderTags( result: FindTagsQueryResult, filter: ListFilterModel, selectedIds: Set, @@ -73,7 +171,16 @@ export const TagList: React.FC = ({ filterHook }) => { return (
{result.data.findTags.tags.map((tag) => ( - + 0} + selected={selectedIds.has(tag.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + listData.onSelectChange(tag.id, selected, shiftKey) + } + /> ))}
); @@ -149,5 +256,19 @@ export const TagList: React.FC = ({ filterHook }) => { } } + function renderContent( + result: FindTagsQueryResult, + filter: ListFilterModel, + selectedIds: Set, + zoomIndex: number + ) { + return ( + <> + {maybeRenderExportDialog(selectedIds)} + {renderTags(result, filter, selectedIds, zoomIndex)} + + ); + } + return listData.template; }; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 0f5f4739a..8bbc9e3ce 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -118,6 +118,15 @@ export const useFindStudios = (filter: ListFilterModel) => }, }); +export const queryFindStudios = (filter: ListFilterModel) => + client.query({ + query: GQL.FindStudiosDocument, + variables: { + filter: filter.makeFindFilter(), + studio_filter: filter.makeStudioFilter(), + }, + }); + export const useFindMovies = (filter: ListFilterModel) => GQL.useFindMoviesQuery({ variables: { @@ -126,6 +135,15 @@ export const useFindMovies = (filter: ListFilterModel) => }, }); +export const queryFindMovies = (filter: ListFilterModel) => + client.query({ + query: GQL.FindMoviesDocument, + variables: { + filter: filter.makeFindFilter(), + movie_filter: filter.makeMovieFilter(), + }, + }); + export const useFindPerformers = (filter: ListFilterModel) => GQL.useFindPerformersQuery({ variables: { @@ -142,6 +160,15 @@ export const useFindTags = (filter: ListFilterModel) => }, }); +export const queryFindTags = (filter: ListFilterModel) => + client.query({ + query: GQL.FindTagsDocument, + variables: { + filter: filter.makeFindFilter(), + tag_filter: filter.makeTagFilter(), + }, + }); + export const queryFindPerformers = (filter: ListFilterModel) => client.query({ query: GQL.FindPerformersDocument,