mirror of https://github.com/stashapp/stash.git
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:
parent
07212dbea9
commit
8e75a8fff5
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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<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 (
|
||||
<Card className={`gallery-card zoom-${props.zoomIndex}`}>
|
||||
<Form.Control
|
||||
type="checkbox"
|
||||
className="gallery-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();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="gallery-section">
|
||||
<Link
|
||||
to={`/galleries/${props.gallery.id}`}
|
||||
className="gallery-card-header"
|
||||
onClick={handleImageClick}
|
||||
onDragStart={handleDrag}
|
||||
onDragOver={handleDragOver}
|
||||
draggable={props.selecting}
|
||||
>
|
||||
<BasicCard
|
||||
className={`gallery-card zoom-${props.zoomIndex}`}
|
||||
url={`/galleries/${props.gallery.id}`}
|
||||
linkClassName="gallery-card-header"
|
||||
image={
|
||||
<>
|
||||
{props.gallery.cover ? (
|
||||
<img
|
||||
className="gallery-card-image"
|
||||
|
@ -200,26 +153,31 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||
/>
|
||||
) : undefined}
|
||||
{maybeRenderRatingBanner()}
|
||||
</Link>
|
||||
{maybeRenderSceneStudioOverlay()}
|
||||
</div>
|
||||
<div className="card-section">
|
||||
<Link to={`/galleries/${props.gallery.id}`}>
|
||||
<h5 className="card-section-title">
|
||||
{props.gallery.title ?? props.gallery.path}
|
||||
</h5>
|
||||
</Link>
|
||||
<span>
|
||||
{props.gallery.images.length}
|
||||
<FormattedPlural
|
||||
value={props.gallery.images.length ?? 0}
|
||||
one="image"
|
||||
other="images"
|
||||
/>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
{maybeRenderPopoverButtonGroup()}
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
overlays={maybeRenderSceneStudioOverlay()}
|
||||
details={
|
||||
<>
|
||||
<Link to={`/galleries/${props.gallery.id}`}>
|
||||
<h5 className="card-section-title">
|
||||
{props.gallery.title ?? props.gallery.path}
|
||||
</h5>
|
||||
</Link>
|
||||
<span>
|
||||
{props.gallery.images.length}
|
||||
<FormattedPlural
|
||||
value={props.gallery.images.length ?? 0}
|
||||
one="image"
|
||||
other="images"
|
||||
/>
|
||||
.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
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 { 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<IGalleryList> = ({
|
|||
if (isExportDialogOpen) {
|
||||
return (
|
||||
<>
|
||||
<GalleryExportDialog
|
||||
selectedIds={Array.from(selectedIds.values())}
|
||||
all={isExportAll}
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
galleries: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: isExportAll,
|
||||
},
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsExportDialogOpen(false);
|
||||
}}
|
||||
|
|
|
@ -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<IImageList> = ({
|
|||
if (isExportDialogOpen) {
|
||||
return (
|
||||
<>
|
||||
<ImageExportDialog
|
||||
selectedIds={Array.from(selectedIds.values())}
|
||||
all={isExportAll}
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
images: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: isExportAll,
|
||||
},
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsExportDialogOpen(false);
|
||||
}}
|
||||
|
|
|
@ -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<IProps> = (props: IProps) => {
|
||||
|
@ -43,19 +45,29 @@ export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Card className="movie-card">
|
||||
<Link to={`/movies/${props.movie.id}`} className="movie-card-header">
|
||||
<img
|
||||
className="movie-card-image"
|
||||
alt={props.movie.name ?? ""}
|
||||
src={props.movie.front_image_path ?? ""}
|
||||
/>
|
||||
{maybeRenderRatingBanner()}
|
||||
</Link>
|
||||
<div className="card-section">
|
||||
<h5 className="text-truncate">{props.movie.name}</h5>
|
||||
{maybeRenderSceneNumber()}
|
||||
</div>
|
||||
</Card>
|
||||
<BasicCard
|
||||
className="movie-card"
|
||||
url={`/movies/${props.movie.id}`}
|
||||
linkClassName="movie-card-header"
|
||||
image={
|
||||
<>
|
||||
<img
|
||||
className="movie-card-image"
|
||||
alt={props.movie.name ?? ""}
|
||||
src={props.movie.front_image_path ?? ""}
|
||||
/>
|
||||
{maybeRenderRatingBanner()}
|
||||
</>
|
||||
}
|
||||
details={
|
||||
<>
|
||||
<h5 className="text-truncate">{props.movie.name}</h5>
|
||||
{maybeRenderSceneNumber()}
|
||||
</>
|
||||
}
|
||||
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 { 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<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) {
|
||||
return;
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="row justify-content-center">
|
||||
{result.data.findMovies.movies.map((p) => (
|
||||
<MovieCard key={p.id} movie={p} />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{maybeRenderMovieExportDialog(selectedIds)}
|
||||
<div className="row justify-content-center">
|
||||
{result.data.findMovies.movies.map((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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.List) {
|
||||
|
|
|
@ -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<IPerformerCardProps> = ({
|
||||
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<IPerformerCardProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<Card className="performer-card">
|
||||
<Link to={`/performers/${performer.id}`}>
|
||||
<img
|
||||
className="performer-card-image"
|
||||
alt={performer.name ?? ""}
|
||||
src={performer.image_path ?? ""}
|
||||
/>
|
||||
{maybeRenderFavoriteBanner()}
|
||||
</Link>
|
||||
<div className="card-section">
|
||||
<h5 className="text-truncate">{performer.name}</h5>
|
||||
{age !== 0 ? <div className="text-muted">{ageString}</div> : ""}
|
||||
<Link to={NavUtils.makePerformersCountryUrl(performer)}>
|
||||
<CountryFlag country={performer.country} />
|
||||
</Link>
|
||||
<div className="text-muted">
|
||||
Stars in
|
||||
<FormattedNumber value={performer.scene_count ?? 0} />
|
||||
|
||||
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
|
||||
<FormattedPlural
|
||||
value={performer.scene_count ?? 0}
|
||||
one="scene"
|
||||
other="scenes"
|
||||
/>
|
||||
<BasicCard
|
||||
className="performer-card"
|
||||
url={`/performers/${performer.id}`}
|
||||
image={
|
||||
<>
|
||||
<img
|
||||
className="performer-card-image"
|
||||
alt={performer.name ?? ""}
|
||||
src={performer.image_path ?? ""}
|
||||
/>
|
||||
{maybeRenderFavoriteBanner()}
|
||||
</>
|
||||
}
|
||||
details={
|
||||
<>
|
||||
<h5 className="text-truncate">{performer.name}</h5>
|
||||
{age !== 0 ? <div className="text-muted">{ageString}</div> : ""}
|
||||
<Link to={NavUtils.makePerformersCountryUrl(performer)}>
|
||||
<CountryFlag country={performer.country} />
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="text-muted">
|
||||
Stars in
|
||||
<FormattedNumber value={performer.scene_count ?? 0} />
|
||||
|
||||
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
|
||||
<FormattedPlural
|
||||
value={performer.scene_count ?? 0}
|
||||
one="scene"
|
||||
other="scenes"
|
||||
/>
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selected={selected}
|
||||
selecting={selecting}
|
||||
onSelectedChanged={onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<string>) {
|
||||
if (isExportDialogOpen) {
|
||||
return (
|
||||
<>
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
performers: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: isExportAll,
|
||||
},
|
||||
}}
|
||||
onClose={() => {
|
||||
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<string>
|
||||
) {
|
||||
if (!result.data?.findPerformers) {
|
||||
return;
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="row justify-content-center">
|
||||
{result.data.findPerformers.performers.map((p) => (
|
||||
<PerformerCard key={p.id} performer={p} />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{maybeRenderPerformerExportDialog(selectedIds)}
|
||||
<div className="row justify-content-center">
|
||||
{result.data.findPerformers.performers.map((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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 { 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<ISceneList> = ({
|
|||
if (isExportDialogOpen) {
|
||||
return (
|
||||
<>
|
||||
<SceneExportDialog
|
||||
selectedIds={Array.from(selectedIds.values())}
|
||||
all={isExportAll}
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
scenes: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: isExportAll,
|
||||
},
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsExportDialogOpen(false);
|
||||
}}
|
||||
|
|
|
@ -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 { 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<IImageExportDialogProps> = (
|
||||
props: IImageExportDialogProps
|
||||
export const ExportDialog: React.FC<IExportDialogProps> = (
|
||||
props: IExportDialogProps
|
||||
) => {
|
||||
const [includeDependencies, setIncludeDependencies] = useState(true);
|
||||
|
||||
|
@ -25,10 +25,7 @@ export const ImageExportDialog: React.FC<IImageExportDialogProps> = (
|
|||
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<IImageExportDialogProps> = (
|
|||
<Form.Check
|
||||
id="include-dependencies"
|
||||
checked={includeDependencies}
|
||||
label="Include related performers/tags/studio in export"
|
||||
label="Include related objects in export"
|
||||
onChange={() => setIncludeDependencies(!includeDependencies)}
|
||||
/>
|
||||
</Form.Group>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<IProps> = ({ studio, hideParent }) => {
|
||||
export const StudioCard: React.FC<IProps> = ({
|
||||
studio,
|
||||
hideParent,
|
||||
selecting,
|
||||
selected,
|
||||
onSelectedChanged,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="studio-card">
|
||||
<Link to={`/studios/${studio.id}`} className="studio-card-header">
|
||||
<BasicCard
|
||||
className="studio-card"
|
||||
url={`/studios/${studio.id}`}
|
||||
linkClassName="studio-card-header"
|
||||
image={
|
||||
<img
|
||||
className="studio-card-image"
|
||||
alt={studio.name}
|
||||
src={studio.image_path ?? ""}
|
||||
/>
|
||||
</Link>
|
||||
<div className="card-section">
|
||||
<h5 className="text-truncate">{studio.name}</h5>
|
||||
<span>
|
||||
{studio.scene_count}
|
||||
<FormattedPlural
|
||||
value={studio.scene_count ?? 0}
|
||||
one="scene"
|
||||
other="scenes"
|
||||
/>
|
||||
.
|
||||
</span>
|
||||
{maybeRenderParent(studio, hideParent)}
|
||||
{maybeRenderChildren(studio)}
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
details={
|
||||
<>
|
||||
<h5 className="text-truncate">{studio.name}</h5>
|
||||
<span>
|
||||
{studio.scene_count}
|
||||
<FormattedPlural
|
||||
value={studio.scene_count ?? 0}
|
||||
one="scene"
|
||||
other="scenes"
|
||||
/>
|
||||
.
|
||||
</span>
|
||||
{maybeRenderParent(studio, hideParent)}
|
||||
{maybeRenderChildren(studio)}
|
||||
</>
|
||||
}
|
||||
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 { 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<IStudioList> = ({
|
|||
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<string>) {
|
||||
if (isExportDialogOpen) {
|
||||
return (
|
||||
<>
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
studios: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: isExportAll,
|
||||
},
|
||||
}}
|
||||
onClose={() => {
|
||||
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<string>
|
||||
) {
|
||||
if (!result.data?.findStudios) return;
|
||||
|
||||
|
@ -34,6 +132,11 @@ export const StudioList: React.FC<IStudioList> = ({
|
|||
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)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</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;
|
||||
};
|
||||
|
|
|
@ -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<IProps> = ({ tag, zoomIndex }) => {
|
||||
export const TagCard: React.FC<IProps> = ({
|
||||
tag,
|
||||
zoomIndex,
|
||||
selecting,
|
||||
selected,
|
||||
onSelectedChanged,
|
||||
}) => {
|
||||
function maybeRenderScenesPopoverButton() {
|
||||
if (!tag.scene_count) return;
|
||||
|
||||
|
@ -52,18 +62,22 @@ export const TagCard: React.FC<IProps> = ({ tag, zoomIndex }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Card className={`tag-card zoom-${zoomIndex}`}>
|
||||
<Link to={`/tags/${tag.id}`} className="tag-card-header">
|
||||
<BasicCard
|
||||
className={`tag-card zoom-${zoomIndex}`}
|
||||
url={`/tags/${tag.id}`}
|
||||
linkClassName="tag-card-header"
|
||||
image={
|
||||
<img
|
||||
className="tag-card-image"
|
||||
alt={tag.name}
|
||||
src={tag.image_path ?? ""}
|
||||
/>
|
||||
</Link>
|
||||
<div className="card-section">
|
||||
<h5 className="text-truncate">{tag.name}</h5>
|
||||
</div>
|
||||
{maybeRenderPopoverButtonGroup()}
|
||||
</Card>
|
||||
}
|
||||
details={<h5 className="text-truncate">{tag.name}</h5>}
|
||||
popovers={maybeRenderPopoverButtonGroup()}
|
||||
selected={selected}
|
||||
selecting={selecting}
|
||||
onSelectedChanged={onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<ITagList> = ({ 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<string>) {
|
||||
if (isExportDialogOpen) {
|
||||
return (
|
||||
<>
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
tags: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: isExportAll,
|
||||
},
|
||||
}}
|
||||
onClose={() => {
|
||||
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<ITagList> = ({ filterHook }) => {
|
|||
}
|
||||
}
|
||||
|
||||
function renderContent(
|
||||
function renderTags(
|
||||
result: FindTagsQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>,
|
||||
|
@ -73,7 +171,16 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
|
|||
return (
|
||||
<div className="row px-xl-5 justify-content-center">
|
||||
{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>
|
||||
);
|
||||
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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) =>
|
||||
GQL.useFindMoviesQuery({
|
||||
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) =>
|
||||
GQL.useFindPerformersQuery({
|
||||
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) =>
|
||||
client.query<GQL.FindPerformersQuery>({
|
||||
query: GQL.FindPerformersDocument,
|
||||
|
|
Loading…
Reference in New Issue