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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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
}
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)
// 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())

View File

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

View File

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

View File

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

View File

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

View File

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

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 { 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}&nbsp;
<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}&nbsp;
<FormattedPlural
value={props.gallery.images.length ?? 0}
one="image"
other="images"
/>
.
</span>
</>
}
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 { 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);
}}

View File

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

View File

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

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

View File

@ -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&nbsp;
<FormattedNumber value={performer.scene_count ?? 0} />
&nbsp;
<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&nbsp;
<FormattedNumber value={performer.scene_count ?? 0} />
&nbsp;
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
<FormattedPlural
value={performer.scene_count ?? 0}
one="scene"
other="scenes"
/>
</Link>
.
</div>
</>
}
selected={selected}
selecting={selecting}
onSelectedChanged={onSelectedChanged}
/>
);
};

View File

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

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

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

View File

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

View File

@ -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}&nbsp;
<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}&nbsp;
<FormattedPlural
value={studio.scene_count ?? 0}
one="scene"
other="scenes"
/>
.
</span>
{maybeRenderParent(studio, hideParent)}
{maybeRenderChildren(studio)}
</>
}
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 { 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;
};

View File

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

View File

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

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) =>
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,