mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Add selection and export for all list pages (#873)
* Include studios in movie export * Generalise cards * Add selection and export for movies * Refactor gallery card * Refactor export dialogs * Add performer selection and export * Add selection and export for studios * Add selection and export of tags * Include movie scenes and gallery images
This commit is contained in:
@@ -59,3 +59,12 @@ func GetStudioName(reader models.StudioReader, gallery *models.Gallery) (string,
|
|||||||
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetIDs(galleries []*models.Gallery) []int {
|
||||||
|
var results []int
|
||||||
|
for _, gallery := range galleries {
|
||||||
|
results = append(results, gallery.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,12 +125,25 @@ func (t *ExportTask) Start(wg *sync.WaitGroup) {
|
|||||||
|
|
||||||
paths.EnsureJSONDirs(t.baseDir)
|
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.ExportScenes(workerCount)
|
||||||
t.ExportImages(workerCount)
|
t.ExportImages(workerCount)
|
||||||
t.ExportGalleries(workerCount)
|
t.ExportGalleries(workerCount)
|
||||||
|
t.ExportMovies(workerCount)
|
||||||
t.ExportPerformers(workerCount)
|
t.ExportPerformers(workerCount)
|
||||||
t.ExportStudios(workerCount)
|
t.ExportStudios(workerCount)
|
||||||
t.ExportMovies(workerCount)
|
|
||||||
t.ExportTags(workerCount)
|
t.ExportTags(workerCount)
|
||||||
|
|
||||||
if err := t.json.saveMappings(t.Mappings); err != nil {
|
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
|
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) {
|
func (t *ExportTask) ExportScenes(workers int) {
|
||||||
var scenesWg sync.WaitGroup
|
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))
|
t.studios.IDs = utils.IntAppendUnique(t.studios.IDs, int(s.StudioID.Int64))
|
||||||
}
|
}
|
||||||
|
|
||||||
// if imageGallery != nil {
|
t.galleries.IDs = utils.IntAppendUniques(t.galleries.IDs, gallery.GetIDs(imageGalleries))
|
||||||
// t.galleries.IDs = utils.IntAppendUnique(t.galleries.IDs, imageGallery.ID)
|
|
||||||
// }
|
|
||||||
|
|
||||||
t.tags.IDs = utils.IntAppendUniques(t.tags.IDs, tag.GetIDs(tags))
|
t.tags.IDs = utils.IntAppendUniques(t.tags.IDs, tag.GetIDs(tags))
|
||||||
t.performers.IDs = utils.IntAppendUniques(t.performers.IDs, performer.GetIDs(performers))
|
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
|
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)
|
movieJSON, err := t.json.getMovie(m.Checksum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debugf("[movies] error reading movie json: %s", err.Error())
|
logger.Debugf("[movies] error reading movie json: %s", err.Error())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type ImageReader interface {
|
|||||||
// Find(id int) (*Image, error)
|
// Find(id int) (*Image, error)
|
||||||
FindMany(ids []int) ([]*Image, error)
|
FindMany(ids []int) ([]*Image, error)
|
||||||
FindByChecksum(checksum string) (*Image, error)
|
FindByChecksum(checksum string) (*Image, error)
|
||||||
|
FindByGalleryID(galleryID int) ([]*Image, error)
|
||||||
// FindByPath(path string) (*Image, error)
|
// FindByPath(path string) (*Image, error)
|
||||||
// FindByPerformerID(performerID int) ([]*Image, error)
|
// FindByPerformerID(performerID int) ([]*Image, error)
|
||||||
// CountByPerformerID(performerID int) (int, error)
|
// CountByPerformerID(performerID int) (int, error)
|
||||||
@@ -55,6 +56,10 @@ func (t *imageReaderWriter) FindByChecksum(checksum string) (*Image, error) {
|
|||||||
return t.qb.FindByChecksum(checksum)
|
return t.qb.FindByChecksum(checksum)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *imageReaderWriter) FindByGalleryID(galleryID int) ([]*Image, error) {
|
||||||
|
return t.qb.FindByGalleryID(galleryID)
|
||||||
|
}
|
||||||
|
|
||||||
func (t *imageReaderWriter) All() ([]*Image, error) {
|
func (t *imageReaderWriter) All() ([]*Image, error) {
|
||||||
return t.qb.All()
|
return t.qb.All()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,29 @@ func (_m *ImageReaderWriter) FindByChecksum(checksum string) (*models.Image, err
|
|||||||
return r0, r1
|
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
|
// FindMany provides a mock function with given fields: ids
|
||||||
func (_m *ImageReaderWriter) FindMany(ids []int) ([]*models.Image, error) {
|
func (_m *ImageReaderWriter) FindMany(ids []int) ([]*models.Image, error) {
|
||||||
ret := _m.Called(ids)
|
ret := _m.Called(ids)
|
||||||
|
|||||||
@@ -81,6 +81,29 @@ func (_m *SceneReaderWriter) FindByChecksum(checksum string) (*models.Scene, err
|
|||||||
return r0, r1
|
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
|
// FindByOSHash provides a mock function with given fields: oshash
|
||||||
func (_m *SceneReaderWriter) FindByOSHash(oshash string) (*models.Scene, error) {
|
func (_m *SceneReaderWriter) FindByOSHash(oshash string) (*models.Scene, error) {
|
||||||
ret := _m.Called(oshash)
|
ret := _m.Called(oshash)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type SceneReader interface {
|
|||||||
// FindByPerformerID(performerID int) ([]*Scene, error)
|
// FindByPerformerID(performerID int) ([]*Scene, error)
|
||||||
// CountByPerformerID(performerID int) (int, error)
|
// CountByPerformerID(performerID int) (int, error)
|
||||||
// FindByStudioID(studioID int) ([]*Scene, error)
|
// FindByStudioID(studioID int) ([]*Scene, error)
|
||||||
// FindByMovieID(movieID int) ([]*Scene, error)
|
FindByMovieID(movieID int) ([]*Scene, error)
|
||||||
// CountByMovieID(movieID int) (int, error)
|
// CountByMovieID(movieID int) (int, error)
|
||||||
// Count() (int, error)
|
// Count() (int, error)
|
||||||
// SizeCount() (string, error)
|
// SizeCount() (string, error)
|
||||||
@@ -73,6 +73,10 @@ func (t *sceneReaderWriter) FindByOSHash(oshash string) (*Scene, error) {
|
|||||||
return t.qb.FindByOSHash(oshash)
|
return t.qb.FindByOSHash(oshash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *sceneReaderWriter) FindByMovieID(movieID int) ([]*Scene, error) {
|
||||||
|
return t.qb.FindByMovieID(movieID)
|
||||||
|
}
|
||||||
|
|
||||||
func (t *sceneReaderWriter) All() ([]*Scene, error) {
|
func (t *sceneReaderWriter) All() ([]*Scene, error) {
|
||||||
return t.qb.All()
|
return t.qb.All()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
* Add selective export of all objects.
|
||||||
* Add stash-box tagger to scenes page.
|
* Add stash-box tagger to scenes page.
|
||||||
* Add filters tab in scene page.
|
* Add filters tab in scene page.
|
||||||
* Add selectable streaming quality profiles in the scene player.
|
* Add selectable streaming quality profiles in the scene player.
|
||||||
@@ -6,7 +7,6 @@
|
|||||||
* Add support for individual images and manual creation of galleries.
|
* Add support for individual images and manual creation of galleries.
|
||||||
* Add various fields to galleries.
|
* Add various fields to galleries.
|
||||||
* Add partial import from zip file.
|
* Add partial import from zip file.
|
||||||
* Add selective scene export.
|
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
* Increase page size limit to 1000 and add new page size options.
|
* Increase page size limit to 1000 and add new page size options.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Card, Button, ButtonGroup, Form } from "react-bootstrap";
|
import { Button, ButtonGroup } from "react-bootstrap";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { FormattedPlural } from "react-intl";
|
import { FormattedPlural } from "react-intl";
|
||||||
import { useConfiguration } from "src/core/StashService";
|
import { useConfiguration } from "src/core/StashService";
|
||||||
import { HoverPopover, Icon, TagLink } from "../Shared";
|
import { HoverPopover, Icon, TagLink } from "../Shared";
|
||||||
|
import { BasicCard } from "../Shared/BasicCard";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
gallery: GQL.GalleryDataFragment;
|
gallery: GQL.GalleryDataFragment;
|
||||||
@@ -137,61 +138,13 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleImageClick(
|
|
||||||
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
|
||||||
) {
|
|
||||||
const { shiftKey } = event;
|
|
||||||
|
|
||||||
if (props.selecting) {
|
|
||||||
props.onSelectedChanged(!props.selected, shiftKey);
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrag(event: React.DragEvent<HTMLAnchorElement>) {
|
|
||||||
if (props.selecting) {
|
|
||||||
event.dataTransfer.setData("text/plain", "");
|
|
||||||
event.dataTransfer.setDragImage(new Image(), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragOver(event: React.DragEvent<HTMLAnchorElement>) {
|
|
||||||
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 (
|
return (
|
||||||
<Card className={`gallery-card zoom-${props.zoomIndex}`}>
|
<BasicCard
|
||||||
<Form.Control
|
className={`gallery-card zoom-${props.zoomIndex}`}
|
||||||
type="checkbox"
|
url={`/galleries/${props.gallery.id}`}
|
||||||
className="gallery-card-check"
|
linkClassName="gallery-card-header"
|
||||||
checked={props.selected}
|
image={
|
||||||
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
|
<>
|
||||||
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
|
|
||||||
// eslint-disable-next-line prefer-destructuring
|
|
||||||
shiftKey = event.shiftKey;
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="gallery-section">
|
|
||||||
<Link
|
|
||||||
to={`/galleries/${props.gallery.id}`}
|
|
||||||
className="gallery-card-header"
|
|
||||||
onClick={handleImageClick}
|
|
||||||
onDragStart={handleDrag}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
draggable={props.selecting}
|
|
||||||
>
|
|
||||||
{props.gallery.cover ? (
|
{props.gallery.cover ? (
|
||||||
<img
|
<img
|
||||||
className="gallery-card-image"
|
className="gallery-card-image"
|
||||||
@@ -200,10 +153,11 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{maybeRenderRatingBanner()}
|
{maybeRenderRatingBanner()}
|
||||||
</Link>
|
</>
|
||||||
{maybeRenderSceneStudioOverlay()}
|
}
|
||||||
</div>
|
overlays={maybeRenderSceneStudioOverlay()}
|
||||||
<div className="card-section">
|
details={
|
||||||
|
<>
|
||||||
<Link to={`/galleries/${props.gallery.id}`}>
|
<Link to={`/galleries/${props.gallery.id}`}>
|
||||||
<h5 className="card-section-title">
|
<h5 className="card-section-title">
|
||||||
{props.gallery.title ?? props.gallery.path}
|
{props.gallery.title ?? props.gallery.path}
|
||||||
@@ -218,8 +172,12 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
.
|
.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</>
|
||||||
{maybeRenderPopoverButtonGroup()}
|
}
|
||||||
</Card>
|
popovers={maybeRenderPopoverButtonGroup()}
|
||||||
|
selected={props.selected}
|
||||||
|
selecting={props.selecting}
|
||||||
|
onSelectedChanged={props.onSelectedChanged}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<IGalleryExportDialogProps> = (
|
|
||||||
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 (
|
|
||||||
<Modal
|
|
||||||
show
|
|
||||||
icon="cogs"
|
|
||||||
header="Export"
|
|
||||||
accept={{ onClick: onExport, text: "Export" }}
|
|
||||||
cancel={{
|
|
||||||
onClick: () => props.onClose(),
|
|
||||||
text: "Cancel",
|
|
||||||
variant: "secondary",
|
|
||||||
}}
|
|
||||||
isRunning={isRunning}
|
|
||||||
>
|
|
||||||
<Form>
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Check
|
|
||||||
id="include-dependencies"
|
|
||||||
checked={includeDependencies}
|
|
||||||
label="Include related performers/tags/studio in export"
|
|
||||||
onChange={() => setIncludeDependencies(!includeDependencies)}
|
|
||||||
/>
|
|
||||||
</Form.Group>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -12,9 +12,9 @@ import { ListFilterModel } from "src/models/list-filter/filter";
|
|||||||
import { DisplayMode } from "src/models/list-filter/types";
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
import { queryFindGalleries } from "src/core/StashService";
|
import { queryFindGalleries } from "src/core/StashService";
|
||||||
import { GalleryCard } from "./GalleryCard";
|
import { GalleryCard } from "./GalleryCard";
|
||||||
import { GalleryExportDialog } from "./GalleryExportDialog";
|
|
||||||
import { EditGalleriesDialog } from "./EditGalleriesDialog";
|
import { EditGalleriesDialog } from "./EditGalleriesDialog";
|
||||||
import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog";
|
import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog";
|
||||||
|
import { ExportDialog } from "../Shared/ExportDialog";
|
||||||
|
|
||||||
interface IGalleryList {
|
interface IGalleryList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
@@ -110,9 +110,13 @@ export const GalleryList: React.FC<IGalleryList> = ({
|
|||||||
if (isExportDialogOpen) {
|
if (isExportDialogOpen) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GalleryExportDialog
|
<ExportDialog
|
||||||
selectedIds={Array.from(selectedIds.values())}
|
exportInput={{
|
||||||
all={isExportAll}
|
galleries: {
|
||||||
|
ids: Array.from(selectedIds.values()),
|
||||||
|
all: isExportAll,
|
||||||
|
},
|
||||||
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsExportDialogOpen(false);
|
setIsExportDialogOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import { IListHookOperation, showWhenSelected } from "src/hooks/ListHook";
|
|||||||
import { ImageCard } from "./ImageCard";
|
import { ImageCard } from "./ImageCard";
|
||||||
import { EditImagesDialog } from "./EditImagesDialog";
|
import { EditImagesDialog } from "./EditImagesDialog";
|
||||||
import { DeleteImagesDialog } from "./DeleteImagesDialog";
|
import { DeleteImagesDialog } from "./DeleteImagesDialog";
|
||||||
import { ImageExportDialog } from "./ImageExportDialog";
|
|
||||||
import "flexbin/flexbin.css";
|
import "flexbin/flexbin.css";
|
||||||
|
import { ExportDialog } from "../Shared/ExportDialog";
|
||||||
|
|
||||||
interface IImageWallProps {
|
interface IImageWallProps {
|
||||||
images: GQL.SlimImageDataFragment[];
|
images: GQL.SlimImageDataFragment[];
|
||||||
@@ -162,9 +162,13 @@ export const ImageList: React.FC<IImageList> = ({
|
|||||||
if (isExportDialogOpen) {
|
if (isExportDialogOpen) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ImageExportDialog
|
<ExportDialog
|
||||||
selectedIds={Array.from(selectedIds.values())}
|
exportInput={{
|
||||||
all={isExportAll}
|
images: {
|
||||||
|
ids: Array.from(selectedIds.values()),
|
||||||
|
all: isExportAll,
|
||||||
|
},
|
||||||
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsExportDialogOpen(false);
|
setIsExportDialogOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Card } from "react-bootstrap";
|
|
||||||
import React, { FunctionComponent } from "react";
|
import React, { FunctionComponent } from "react";
|
||||||
import { FormattedPlural } from "react-intl";
|
import { FormattedPlural } from "react-intl";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { BasicCard } from "../Shared/BasicCard";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
movie: GQL.MovieDataFragment;
|
movie: GQL.MovieDataFragment;
|
||||||
sceneIndex?: number;
|
sceneIndex?: number;
|
||||||
|
selecting?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
|
export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
@@ -43,19 +45,29 @@ export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="movie-card">
|
<BasicCard
|
||||||
<Link to={`/movies/${props.movie.id}`} className="movie-card-header">
|
className="movie-card"
|
||||||
|
url={`/movies/${props.movie.id}`}
|
||||||
|
linkClassName="movie-card-header"
|
||||||
|
image={
|
||||||
|
<>
|
||||||
<img
|
<img
|
||||||
className="movie-card-image"
|
className="movie-card-image"
|
||||||
alt={props.movie.name ?? ""}
|
alt={props.movie.name ?? ""}
|
||||||
src={props.movie.front_image_path ?? ""}
|
src={props.movie.front_image_path ?? ""}
|
||||||
/>
|
/>
|
||||||
{maybeRenderRatingBanner()}
|
{maybeRenderRatingBanner()}
|
||||||
</Link>
|
</>
|
||||||
<div className="card-section">
|
}
|
||||||
|
details={
|
||||||
|
<>
|
||||||
<h5 className="text-truncate">{props.movie.name}</h5>
|
<h5 className="text-truncate">{props.movie.name}</h5>
|
||||||
{maybeRenderSceneNumber()}
|
{maybeRenderSceneNumber()}
|
||||||
</div>
|
</>
|
||||||
</Card>
|
}
|
||||||
|
selected={props.selected}
|
||||||
|
selecting={props.selecting}
|
||||||
|
onSelectedChanged={props.onSelectedChanged}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,30 +1,138 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
import _ from "lodash";
|
||||||
import { FindMoviesQueryResult } from "src/core/generated-graphql";
|
import { FindMoviesQueryResult } from "src/core/generated-graphql";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
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 { MovieCard } from "./MovieCard";
|
||||||
|
import { ExportDialog } from "../Shared/ExportDialog";
|
||||||
|
|
||||||
export const MovieList: React.FC = () => {
|
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({
|
const listData = useMoviesList({
|
||||||
renderContent,
|
renderContent,
|
||||||
|
addKeybinds,
|
||||||
|
otherOperations,
|
||||||
|
selectable: true,
|
||||||
persistState: true,
|
persistState: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderContent(
|
async function viewRandom(
|
||||||
result: FindMoviesQueryResult,
|
result: FindMoviesQueryResult,
|
||||||
filter: ListFilterModel
|
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<string>) {
|
||||||
|
if (isExportDialogOpen) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExportDialog
|
||||||
|
exportInput={{
|
||||||
|
movies: {
|
||||||
|
ids: Array.from(selectedIds.values()),
|
||||||
|
all: isExportAll,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setIsExportDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(
|
||||||
|
result: FindMoviesQueryResult,
|
||||||
|
filter: ListFilterModel,
|
||||||
|
selectedIds: Set<string>
|
||||||
) {
|
) {
|
||||||
if (!result.data?.findMovies) {
|
if (!result.data?.findMovies) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (filter.displayMode === DisplayMode.Grid) {
|
if (filter.displayMode === DisplayMode.Grid) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{maybeRenderMovieExportDialog(selectedIds)}
|
||||||
<div className="row justify-content-center">
|
<div className="row justify-content-center">
|
||||||
{result.data.findMovies.movies.map((p) => (
|
{result.data.findMovies.movies.map((p) => (
|
||||||
<MovieCard key={p.id} movie={p} />
|
<MovieCard
|
||||||
|
key={p.id}
|
||||||
|
movie={p}
|
||||||
|
selecting={selectedIds.size > 0}
|
||||||
|
selected={selectedIds.has(p.id)}
|
||||||
|
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
|
||||||
|
listData.onSelectChange(p.id, selected, shiftKey)
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (filter.displayMode === DisplayMode.List) {
|
if (filter.displayMode === DisplayMode.List) {
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card } from "react-bootstrap";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { FormattedNumber, FormattedPlural, FormattedMessage } from "react-intl";
|
import { FormattedNumber, FormattedPlural, FormattedMessage } from "react-intl";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { NavUtils, TextUtils } from "src/utils";
|
import { NavUtils, TextUtils } from "src/utils";
|
||||||
import { CountryFlag } from "src/components/Shared";
|
import { CountryFlag } from "src/components/Shared";
|
||||||
|
import { BasicCard } from "../Shared/BasicCard";
|
||||||
|
|
||||||
interface IPerformerCardProps {
|
interface IPerformerCardProps {
|
||||||
performer: GQL.PerformerDataFragment;
|
performer: GQL.PerformerDataFragment;
|
||||||
ageFromDate?: string;
|
ageFromDate?: string;
|
||||||
|
selecting?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PerformerCard: React.FC<IPerformerCardProps> = ({
|
export const PerformerCard: React.FC<IPerformerCardProps> = ({
|
||||||
performer,
|
performer,
|
||||||
ageFromDate,
|
ageFromDate,
|
||||||
|
selecting,
|
||||||
|
selected,
|
||||||
|
onSelectedChanged,
|
||||||
}) => {
|
}) => {
|
||||||
const age = TextUtils.age(performer.birthdate, ageFromDate);
|
const age = TextUtils.age(performer.birthdate, ageFromDate);
|
||||||
const ageString = `${age} years old${ageFromDate ? " in this scene." : "."}`;
|
const ageString = `${age} years old${ageFromDate ? " in this scene." : "."}`;
|
||||||
@@ -30,16 +36,21 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="performer-card">
|
<BasicCard
|
||||||
<Link to={`/performers/${performer.id}`}>
|
className="performer-card"
|
||||||
|
url={`/performers/${performer.id}`}
|
||||||
|
image={
|
||||||
|
<>
|
||||||
<img
|
<img
|
||||||
className="performer-card-image"
|
className="performer-card-image"
|
||||||
alt={performer.name ?? ""}
|
alt={performer.name ?? ""}
|
||||||
src={performer.image_path ?? ""}
|
src={performer.image_path ?? ""}
|
||||||
/>
|
/>
|
||||||
{maybeRenderFavoriteBanner()}
|
{maybeRenderFavoriteBanner()}
|
||||||
</Link>
|
</>
|
||||||
<div className="card-section">
|
}
|
||||||
|
details={
|
||||||
|
<>
|
||||||
<h5 className="text-truncate">{performer.name}</h5>
|
<h5 className="text-truncate">{performer.name}</h5>
|
||||||
{age !== 0 ? <div className="text-muted">{ageString}</div> : ""}
|
{age !== 0 ? <div className="text-muted">{ageString}</div> : ""}
|
||||||
<Link to={NavUtils.makePerformersCountryUrl(performer)}>
|
<Link to={NavUtils.makePerformersCountryUrl(performer)}>
|
||||||
@@ -58,7 +69,11 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
|
|||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</Card>
|
}
|
||||||
|
selected={selected}
|
||||||
|
selecting={selecting}
|
||||||
|
onSelectedChanged={onSelectedChanged}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { FindPerformersQueryResult } from "src/core/generated-graphql";
|
import { FindPerformersQueryResult } from "src/core/generated-graphql";
|
||||||
import { queryFindPerformers } from "src/core/StashService";
|
import { queryFindPerformers } from "src/core/StashService";
|
||||||
import { usePerformersList } from "src/hooks";
|
import { usePerformersList } from "src/hooks";
|
||||||
|
import { showWhenSelected } from "src/hooks/ListHook";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
|
import { ExportDialog } from "src/components/Shared/ExportDialog";
|
||||||
import { PerformerCard } from "./PerformerCard";
|
import { PerformerCard } from "./PerformerCard";
|
||||||
import { PerformerListTable } from "./PerformerListTable";
|
import { PerformerListTable } from "./PerformerListTable";
|
||||||
|
|
||||||
export const PerformerList: React.FC = () => {
|
export const PerformerList: React.FC = () => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||||
|
const [isExportAll, setIsExportAll] = useState(false);
|
||||||
|
|
||||||
const otherOperations = [
|
const otherOperations = [
|
||||||
{
|
{
|
||||||
text: "Open Random",
|
text: "Open Random",
|
||||||
onClick: getRandom,
|
onClick: getRandom,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: "Export...",
|
||||||
|
onClick: onExport,
|
||||||
|
isDisplayed: showWhenSelected,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Export all...",
|
||||||
|
onClick: onExportAll,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const addKeybinds = (
|
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<string>) {
|
||||||
|
if (isExportDialogOpen) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExportDialog
|
||||||
|
exportInput={{
|
||||||
|
performers: {
|
||||||
|
ids: Array.from(selectedIds.values()),
|
||||||
|
all: isExportAll,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setIsExportDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const listData = usePerformersList({
|
const listData = usePerformersList({
|
||||||
otherOperations,
|
otherOperations,
|
||||||
renderContent,
|
renderContent,
|
||||||
addKeybinds,
|
addKeybinds,
|
||||||
|
selectable: true,
|
||||||
persistState: true,
|
persistState: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,18 +108,30 @@ export const PerformerList: React.FC = () => {
|
|||||||
|
|
||||||
function renderContent(
|
function renderContent(
|
||||||
result: FindPerformersQueryResult,
|
result: FindPerformersQueryResult,
|
||||||
filter: ListFilterModel
|
filter: ListFilterModel,
|
||||||
|
selectedIds: Set<string>
|
||||||
) {
|
) {
|
||||||
if (!result.data?.findPerformers) {
|
if (!result.data?.findPerformers) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (filter.displayMode === DisplayMode.Grid) {
|
if (filter.displayMode === DisplayMode.Grid) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{maybeRenderPerformerExportDialog(selectedIds)}
|
||||||
<div className="row justify-content-center">
|
<div className="row justify-content-center">
|
||||||
{result.data.findPerformers.performers.map((p) => (
|
{result.data.findPerformers.performers.map((p) => (
|
||||||
<PerformerCard key={p.id} performer={p} />
|
<PerformerCard
|
||||||
|
key={p.id}
|
||||||
|
performer={p}
|
||||||
|
selecting={selectedIds.size > 0}
|
||||||
|
selected={selectedIds.has(p.id)}
|
||||||
|
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
|
||||||
|
listData.onSelectChange(p.id, selected, shiftKey)
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (filter.displayMode === DisplayMode.List) {
|
if (filter.displayMode === DisplayMode.List) {
|
||||||
|
|||||||
@@ -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<ISceneExportDialogProps> = (
|
|
||||||
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 (
|
|
||||||
<Modal
|
|
||||||
show
|
|
||||||
icon="cogs"
|
|
||||||
header="Generate"
|
|
||||||
accept={{ onClick: onExport, text: "Export" }}
|
|
||||||
cancel={{
|
|
||||||
onClick: () => props.onClose(),
|
|
||||||
text: "Cancel",
|
|
||||||
variant: "secondary",
|
|
||||||
}}
|
|
||||||
isRunning={isRunning}
|
|
||||||
>
|
|
||||||
<Form>
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Check
|
|
||||||
id="include-dependencies"
|
|
||||||
checked={includeDependencies}
|
|
||||||
label="Include related performers/movies/tags/studio in export"
|
|
||||||
onChange={() => setIncludeDependencies(!includeDependencies)}
|
|
||||||
/>
|
|
||||||
</Form.Group>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -17,7 +17,7 @@ import { SceneListTable } from "./SceneListTable";
|
|||||||
import { EditScenesDialog } from "./EditScenesDialog";
|
import { EditScenesDialog } from "./EditScenesDialog";
|
||||||
import { DeleteScenesDialog } from "./DeleteScenesDialog";
|
import { DeleteScenesDialog } from "./DeleteScenesDialog";
|
||||||
import { SceneGenerateDialog } from "./SceneGenerateDialog";
|
import { SceneGenerateDialog } from "./SceneGenerateDialog";
|
||||||
import { SceneExportDialog } from "./SceneExportDialog";
|
import { ExportDialog } from "../Shared/ExportDialog";
|
||||||
|
|
||||||
interface ISceneList {
|
interface ISceneList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
@@ -138,9 +138,13 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||||||
if (isExportDialogOpen) {
|
if (isExportDialogOpen) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SceneExportDialog
|
<ExportDialog
|
||||||
selectedIds={Array.from(selectedIds.values())}
|
exportInput={{
|
||||||
all={isExportAll}
|
scenes: {
|
||||||
|
ids: Array.from(selectedIds.values()),
|
||||||
|
all: isExportAll,
|
||||||
|
},
|
||||||
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsExportDialogOpen(false);
|
setIsExportDialogOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
101
ui/v2.5/src/components/Shared/BasicCard.tsx
Normal file
101
ui/v2.5/src/components/Shared/BasicCard.tsx
Normal file
@@ -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<IBasicCardProps> = (
|
||||||
|
props: IBasicCardProps
|
||||||
|
) => {
|
||||||
|
function handleImageClick(
|
||||||
|
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||||
|
) {
|
||||||
|
const { shiftKey } = event;
|
||||||
|
|
||||||
|
if (!props.onSelectedChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.selecting) {
|
||||||
|
props.onSelectedChanged(!props.selected, shiftKey);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrag(event: React.DragEvent<HTMLAnchorElement>) {
|
||||||
|
if (props.selecting) {
|
||||||
|
event.dataTransfer.setData("text/plain", "");
|
||||||
|
event.dataTransfer.setDragImage(new Image(), 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: React.DragEvent<HTMLAnchorElement>) {
|
||||||
|
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 (
|
||||||
|
<Form.Control
|
||||||
|
type="checkbox"
|
||||||
|
className="card-check"
|
||||||
|
checked={props.selected}
|
||||||
|
onChange={() => props.onSelectedChanged!(!props.selected, shiftKey)}
|
||||||
|
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
|
||||||
|
// eslint-disable-next-line prefer-destructuring
|
||||||
|
shiftKey = event.shiftKey;
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={props.className}>
|
||||||
|
{maybeRenderCheckbox()}
|
||||||
|
|
||||||
|
<div className="image-section">
|
||||||
|
<Link
|
||||||
|
to={props.url}
|
||||||
|
className={props.linkClassName}
|
||||||
|
onClick={handleImageClick}
|
||||||
|
onDragStart={handleDrag}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
draggable={props.onSelectedChanged && props.selecting}
|
||||||
|
>
|
||||||
|
{props.image}
|
||||||
|
</Link>
|
||||||
|
{props.overlays}
|
||||||
|
</div>
|
||||||
|
<div className="card-section">{props.details}</div>
|
||||||
|
|
||||||
|
{props.popovers}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,15 +4,15 @@ import { mutateExportObjects } from "src/core/StashService";
|
|||||||
import { Modal } from "src/components/Shared";
|
import { Modal } from "src/components/Shared";
|
||||||
import { useToast } from "src/hooks";
|
import { useToast } from "src/hooks";
|
||||||
import { downloadFile } from "src/utils";
|
import { downloadFile } from "src/utils";
|
||||||
|
import { ExportObjectsInput } from "src/core/generated-graphql";
|
||||||
|
|
||||||
interface IImageExportDialogProps {
|
interface IExportDialogProps {
|
||||||
selectedIds?: string[];
|
exportInput: ExportObjectsInput;
|
||||||
all?: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageExportDialog: React.FC<IImageExportDialogProps> = (
|
export const ExportDialog: React.FC<IExportDialogProps> = (
|
||||||
props: IImageExportDialogProps
|
props: IExportDialogProps
|
||||||
) => {
|
) => {
|
||||||
const [includeDependencies, setIncludeDependencies] = useState(true);
|
const [includeDependencies, setIncludeDependencies] = useState(true);
|
||||||
|
|
||||||
@@ -25,10 +25,7 @@ export const ImageExportDialog: React.FC<IImageExportDialogProps> = (
|
|||||||
try {
|
try {
|
||||||
setIsRunning(true);
|
setIsRunning(true);
|
||||||
const ret = await mutateExportObjects({
|
const ret = await mutateExportObjects({
|
||||||
images: {
|
...props.exportInput,
|
||||||
ids: props.selectedIds,
|
|
||||||
all: props.all,
|
|
||||||
},
|
|
||||||
includeDependencies,
|
includeDependencies,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,7 +60,7 @@ export const ImageExportDialog: React.FC<IImageExportDialogProps> = (
|
|||||||
<Form.Check
|
<Form.Check
|
||||||
id="include-dependencies"
|
id="include-dependencies"
|
||||||
checked={includeDependencies}
|
checked={includeDependencies}
|
||||||
label="Include related performers/tags/studio in export"
|
label="Include related objects in export"
|
||||||
onChange={() => setIncludeDependencies(!includeDependencies)}
|
onChange={() => setIncludeDependencies(!includeDependencies)}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
@@ -149,3 +149,25 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active {
|
|||||||
display: inline-block;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Card } from "react-bootstrap";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { FormattedPlural } from "react-intl";
|
import { FormattedPlural } from "react-intl";
|
||||||
import { NavUtils } from "src/utils";
|
import { NavUtils } from "src/utils";
|
||||||
|
import { BasicCard } from "../Shared/BasicCard";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
studio: GQL.StudioDataFragment;
|
studio: GQL.StudioDataFragment;
|
||||||
hideParent?: boolean;
|
hideParent?: boolean;
|
||||||
|
selecting?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeRenderParent(
|
function maybeRenderParent(
|
||||||
@@ -41,17 +44,27 @@ function maybeRenderChildren(studio: GQL.StudioDataFragment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StudioCard: React.FC<IProps> = ({ studio, hideParent }) => {
|
export const StudioCard: React.FC<IProps> = ({
|
||||||
|
studio,
|
||||||
|
hideParent,
|
||||||
|
selecting,
|
||||||
|
selected,
|
||||||
|
onSelectedChanged,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Card className="studio-card">
|
<BasicCard
|
||||||
<Link to={`/studios/${studio.id}`} className="studio-card-header">
|
className="studio-card"
|
||||||
|
url={`/studios/${studio.id}`}
|
||||||
|
linkClassName="studio-card-header"
|
||||||
|
image={
|
||||||
<img
|
<img
|
||||||
className="studio-card-image"
|
className="studio-card-image"
|
||||||
alt={studio.name}
|
alt={studio.name}
|
||||||
src={studio.image_path ?? ""}
|
src={studio.image_path ?? ""}
|
||||||
/>
|
/>
|
||||||
</Link>
|
}
|
||||||
<div className="card-section">
|
details={
|
||||||
|
<>
|
||||||
<h5 className="text-truncate">{studio.name}</h5>
|
<h5 className="text-truncate">{studio.name}</h5>
|
||||||
<span>
|
<span>
|
||||||
{studio.scene_count}
|
{studio.scene_count}
|
||||||
@@ -64,7 +77,11 @@ export const StudioCard: React.FC<IProps> = ({ studio, hideParent }) => {
|
|||||||
</span>
|
</span>
|
||||||
{maybeRenderParent(studio, hideParent)}
|
{maybeRenderParent(studio, hideParent)}
|
||||||
{maybeRenderChildren(studio)}
|
{maybeRenderChildren(studio)}
|
||||||
</div>
|
</>
|
||||||
</Card>
|
}
|
||||||
|
selected={selected}
|
||||||
|
selecting={selecting}
|
||||||
|
onSelectedChanged={onSelectedChanged}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { FindStudiosQueryResult } from "src/core/generated-graphql";
|
||||||
import { useStudiosList } from "src/hooks";
|
import { useStudiosList } from "src/hooks";
|
||||||
|
import { showWhenSelected } from "src/hooks/ListHook";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
|
import { queryFindStudios } from "src/core/StashService";
|
||||||
|
import { ExportDialog } from "../Shared/ExportDialog";
|
||||||
import { StudioCard } from "./StudioCard";
|
import { StudioCard } from "./StudioCard";
|
||||||
|
|
||||||
interface IStudioList {
|
interface IStudioList {
|
||||||
@@ -14,15 +19,108 @@ export const StudioList: React.FC<IStudioList> = ({
|
|||||||
fromParent,
|
fromParent,
|
||||||
filterHook,
|
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<string>) {
|
||||||
|
if (isExportDialogOpen) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExportDialog
|
||||||
|
exportInput={{
|
||||||
|
studios: {
|
||||||
|
ids: Array.from(selectedIds.values()),
|
||||||
|
all: isExportAll,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setIsExportDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const listData = useStudiosList({
|
const listData = useStudiosList({
|
||||||
renderContent,
|
renderContent,
|
||||||
filterHook,
|
filterHook,
|
||||||
|
addKeybinds,
|
||||||
|
otherOperations,
|
||||||
|
selectable: true,
|
||||||
persistState: !fromParent,
|
persistState: !fromParent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderContent(
|
function renderStudios(
|
||||||
result: FindStudiosQueryResult,
|
result: FindStudiosQueryResult,
|
||||||
filter: ListFilterModel
|
filter: ListFilterModel,
|
||||||
|
selectedIds: Set<string>
|
||||||
) {
|
) {
|
||||||
if (!result.data?.findStudios) return;
|
if (!result.data?.findStudios) return;
|
||||||
|
|
||||||
@@ -34,6 +132,11 @@ export const StudioList: React.FC<IStudioList> = ({
|
|||||||
key={studio.id}
|
key={studio.id}
|
||||||
studio={studio}
|
studio={studio}
|
||||||
hideParent={fromParent}
|
hideParent={fromParent}
|
||||||
|
selecting={selectedIds.size > 0}
|
||||||
|
selected={selectedIds.has(studio.id)}
|
||||||
|
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
|
||||||
|
listData.onSelectChange(studio.id, selected, shiftKey)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -47,5 +150,18 @@ export const StudioList: React.FC<IStudioList> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderContent(
|
||||||
|
result: FindStudiosQueryResult,
|
||||||
|
filter: ListFilterModel,
|
||||||
|
selectedIds: Set<string>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{maybeRenderExportDialog(selectedIds)}
|
||||||
|
{renderStudios(result, filter, selectedIds)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return listData.template;
|
return listData.template;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { Card, Button, ButtonGroup } from "react-bootstrap";
|
import { Button, ButtonGroup } from "react-bootstrap";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { NavUtils } from "src/utils";
|
import { NavUtils } from "src/utils";
|
||||||
import { Icon } from "../Shared";
|
import { Icon } from "../Shared";
|
||||||
|
import { BasicCard } from "../Shared/BasicCard";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
tag: GQL.TagDataFragment;
|
tag: GQL.TagDataFragment;
|
||||||
zoomIndex: number;
|
zoomIndex: number;
|
||||||
|
selecting?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TagCard: React.FC<IProps> = ({ tag, zoomIndex }) => {
|
export const TagCard: React.FC<IProps> = ({
|
||||||
|
tag,
|
||||||
|
zoomIndex,
|
||||||
|
selecting,
|
||||||
|
selected,
|
||||||
|
onSelectedChanged,
|
||||||
|
}) => {
|
||||||
function maybeRenderScenesPopoverButton() {
|
function maybeRenderScenesPopoverButton() {
|
||||||
if (!tag.scene_count) return;
|
if (!tag.scene_count) return;
|
||||||
|
|
||||||
@@ -52,18 +62,22 @@ export const TagCard: React.FC<IProps> = ({ tag, zoomIndex }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`tag-card zoom-${zoomIndex}`}>
|
<BasicCard
|
||||||
<Link to={`/tags/${tag.id}`} className="tag-card-header">
|
className={`tag-card zoom-${zoomIndex}`}
|
||||||
|
url={`/tags/${tag.id}`}
|
||||||
|
linkClassName="tag-card-header"
|
||||||
|
image={
|
||||||
<img
|
<img
|
||||||
className="tag-card-image"
|
className="tag-card-image"
|
||||||
alt={tag.name}
|
alt={tag.name}
|
||||||
src={tag.image_path ?? ""}
|
src={tag.image_path ?? ""}
|
||||||
/>
|
/>
|
||||||
</Link>
|
}
|
||||||
<div className="card-section">
|
details={<h5 className="text-truncate">{tag.name}</h5>}
|
||||||
<h5 className="text-truncate">{tag.name}</h5>
|
popovers={maybeRenderPopoverButtonGroup()}
|
||||||
</div>
|
selected={selected}
|
||||||
{maybeRenderPopoverButtonGroup()}
|
selecting={selecting}
|
||||||
</Card>
|
onSelectedChanged={onSelectedChanged}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import _ from "lodash";
|
||||||
import { FindTagsQueryResult } from "src/core/generated-graphql";
|
import { FindTagsQueryResult } from "src/core/generated-graphql";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
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 { 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 * 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 { useToast } from "src/hooks";
|
||||||
import { FormattedNumber } from "react-intl";
|
import { FormattedNumber } from "react-intl";
|
||||||
import { NavUtils } from "src/utils";
|
import { NavUtils } from "src/utils";
|
||||||
import { Icon, Modal } from "src/components/Shared";
|
import { Icon, Modal } from "src/components/Shared";
|
||||||
import { TagCard } from "./TagCard";
|
import { TagCard } from "./TagCard";
|
||||||
|
import { ExportDialog } from "../Shared/ExportDialog";
|
||||||
|
|
||||||
interface ITagList {
|
interface ITagList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
@@ -25,9 +31,101 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
|
|||||||
|
|
||||||
const [deleteTag] = useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput);
|
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<string>) {
|
||||||
|
if (isExportDialogOpen) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExportDialog
|
||||||
|
exportInput={{
|
||||||
|
tags: {
|
||||||
|
ids: Array.from(selectedIds.values()),
|
||||||
|
all: isExportAll,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setIsExportDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const listData = useTagsList({
|
const listData = useTagsList({
|
||||||
renderContent,
|
renderContent,
|
||||||
filterHook,
|
filterHook,
|
||||||
|
addKeybinds,
|
||||||
|
otherOperations,
|
||||||
|
selectable: true,
|
||||||
zoomable: true,
|
zoomable: true,
|
||||||
defaultZoomIndex: 0,
|
defaultZoomIndex: 0,
|
||||||
persistState: true,
|
persistState: true,
|
||||||
@@ -61,7 +159,7 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(
|
function renderTags(
|
||||||
result: FindTagsQueryResult,
|
result: FindTagsQueryResult,
|
||||||
filter: ListFilterModel,
|
filter: ListFilterModel,
|
||||||
selectedIds: Set<string>,
|
selectedIds: Set<string>,
|
||||||
@@ -73,7 +171,16 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="row px-xl-5 justify-content-center">
|
<div className="row px-xl-5 justify-content-center">
|
||||||
{result.data.findTags.tags.map((tag) => (
|
{result.data.findTags.tags.map((tag) => (
|
||||||
<TagCard key={tag.id} tag={tag} zoomIndex={zoomIndex} />
|
<TagCard
|
||||||
|
key={tag.id}
|
||||||
|
tag={tag}
|
||||||
|
zoomIndex={zoomIndex}
|
||||||
|
selecting={selectedIds.size > 0}
|
||||||
|
selected={selectedIds.has(tag.id)}
|
||||||
|
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
|
||||||
|
listData.onSelectChange(tag.id, selected, shiftKey)
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -149,5 +256,19 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderContent(
|
||||||
|
result: FindTagsQueryResult,
|
||||||
|
filter: ListFilterModel,
|
||||||
|
selectedIds: Set<string>,
|
||||||
|
zoomIndex: number
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{maybeRenderExportDialog(selectedIds)}
|
||||||
|
{renderTags(result, filter, selectedIds, zoomIndex)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return listData.template;
|
return listData.template;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,6 +118,15 @@ export const useFindStudios = (filter: ListFilterModel) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const queryFindStudios = (filter: ListFilterModel) =>
|
||||||
|
client.query<GQL.FindStudiosQuery>({
|
||||||
|
query: GQL.FindStudiosDocument,
|
||||||
|
variables: {
|
||||||
|
filter: filter.makeFindFilter(),
|
||||||
|
studio_filter: filter.makeStudioFilter(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const useFindMovies = (filter: ListFilterModel) =>
|
export const useFindMovies = (filter: ListFilterModel) =>
|
||||||
GQL.useFindMoviesQuery({
|
GQL.useFindMoviesQuery({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -126,6 +135,15 @@ export const useFindMovies = (filter: ListFilterModel) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const queryFindMovies = (filter: ListFilterModel) =>
|
||||||
|
client.query<GQL.FindMoviesQuery>({
|
||||||
|
query: GQL.FindMoviesDocument,
|
||||||
|
variables: {
|
||||||
|
filter: filter.makeFindFilter(),
|
||||||
|
movie_filter: filter.makeMovieFilter(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const useFindPerformers = (filter: ListFilterModel) =>
|
export const useFindPerformers = (filter: ListFilterModel) =>
|
||||||
GQL.useFindPerformersQuery({
|
GQL.useFindPerformersQuery({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -142,6 +160,15 @@ export const useFindTags = (filter: ListFilterModel) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const queryFindTags = (filter: ListFilterModel) =>
|
||||||
|
client.query<GQL.FindTagsQuery>({
|
||||||
|
query: GQL.FindTagsDocument,
|
||||||
|
variables: {
|
||||||
|
filter: filter.makeFindFilter(),
|
||||||
|
tag_filter: filter.makeTagFilter(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const queryFindPerformers = (filter: ListFilterModel) =>
|
export const queryFindPerformers = (filter: ListFilterModel) =>
|
||||||
client.query<GQL.FindPerformersQuery>({
|
client.query<GQL.FindPerformersQuery>({
|
||||||
query: GQL.FindPerformersDocument,
|
query: GQL.FindPerformersDocument,
|
||||||
|
|||||||
Reference in New Issue
Block a user