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:
WithoutPants
2020-10-31 09:41:12 +11:00
committed by GitHub
parent 07212dbea9
commit 8e75a8fff5
25 changed files with 921 additions and 350 deletions

View File

@@ -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
}

View File

@@ -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())

View File

@@ -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()
} }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()
} }

View File

@@ -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.

View File

@@ -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}
/>
); );
}; };

View File

@@ -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>
);
};

View File

@@ -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);
}} }}

View File

@@ -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);
}} }}

View File

@@ -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}
/>
); );
}; };

View File

@@ -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) {

View File

@@ -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}
/>
); );
}; };

View File

@@ -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) {

View File

@@ -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>
);
};

View File

@@ -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);
}} }}

View 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>
);
};

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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}&nbsp; {studio.scene_count}&nbsp;
@@ -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}
/>
); );
}; };

View File

@@ -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;
}; };

View File

@@ -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}
/>
); );
}; };

View File

@@ -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;
}; };

View File

@@ -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,