mirror of https://github.com/stashapp/stash.git
Bulk update scenes (#92)
* Add bulk update functionality * Restore multiselect fixes from previous branch * Prevent unsetting of studios/tags * Detect when slice fields are omitted and ignore
This commit is contained in:
parent
70ce01c604
commit
0655223c38
|
@ -26,6 +26,34 @@ mutation SceneUpdate(
|
|||
}
|
||||
}
|
||||
|
||||
mutation BulkSceneUpdate(
|
||||
$ids: [ID!] = [],
|
||||
$title: String,
|
||||
$details: String,
|
||||
$url: String,
|
||||
$date: String,
|
||||
$rating: Int,
|
||||
$studio_id: ID,
|
||||
$gallery_id: ID,
|
||||
$performer_ids: [ID!],
|
||||
$tag_ids: [ID!]) {
|
||||
|
||||
bulkSceneUpdate(input: {
|
||||
ids: $ids,
|
||||
title: $title,
|
||||
details: $details,
|
||||
url: $url,
|
||||
date: $date,
|
||||
rating: $rating,
|
||||
studio_id: $studio_id,
|
||||
gallery_id: $gallery_id,
|
||||
performer_ids: $performer_ids,
|
||||
tag_ids: $tag_ids
|
||||
}) {
|
||||
...SceneData
|
||||
}
|
||||
}
|
||||
|
||||
mutation SceneDestroy($id: ID!, $delete_file: Boolean, $delete_generated : Boolean) {
|
||||
sceneDestroy(input: {id: $id, delete_file: $delete_file, delete_generated: $delete_generated})
|
||||
}
|
|
@ -77,6 +77,7 @@ type Query {
|
|||
|
||||
type Mutation {
|
||||
sceneUpdate(input: SceneUpdateInput!): Scene
|
||||
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
|
||||
sceneDestroy(input: SceneDestroyInput!): Boolean!
|
||||
|
||||
sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker
|
||||
|
|
|
@ -53,6 +53,20 @@ input SceneUpdateInput {
|
|||
tag_ids: [ID!]
|
||||
}
|
||||
|
||||
input BulkSceneUpdateInput {
|
||||
clientMutationId: String
|
||||
ids: [ID!]
|
||||
title: String
|
||||
details: String
|
||||
url: String
|
||||
date: String
|
||||
rating: Int
|
||||
studio_id: ID
|
||||
gallery_id: ID
|
||||
performer_ids: [ID!]
|
||||
tag_ids: [ID!]
|
||||
}
|
||||
|
||||
input SceneDestroyInput {
|
||||
id: ID!
|
||||
delete_file: Boolean
|
||||
|
|
|
@ -5,6 +5,8 @@ import (
|
|||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scraper"
|
||||
)
|
||||
|
@ -165,3 +167,13 @@ func (r *queryResolver) ScrapeFreeones(ctx context.Context, performer_name strin
|
|||
func (r *queryResolver) ScrapeFreeonesPerformerList(ctx context.Context, query string) ([]string, error) {
|
||||
return scraper.GetPerformerNames(query)
|
||||
}
|
||||
|
||||
// wasFieldIncluded returns true if the given field was included in the request.
|
||||
// Slices are unmarshalled to empty slices even if the field was omitted. This
|
||||
// method determines if it was omitted altogether.
|
||||
func wasFieldIncluded(ctx context.Context, field string) bool {
|
||||
rctx := graphql.GetRequestContext(ctx)
|
||||
|
||||
_, ret := rctx.Variables[field]
|
||||
return ret
|
||||
}
|
||||
|
|
|
@ -119,6 +119,121 @@ func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUp
|
|||
return scene, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.BulkSceneUpdateInput) ([]*models.Scene, error) {
|
||||
// Populate scene from the input
|
||||
updatedTime := time.Now()
|
||||
|
||||
// Start the transaction and save the scene marker
|
||||
tx := database.DB.MustBeginTx(ctx, nil)
|
||||
qb := models.NewSceneQueryBuilder()
|
||||
jqb := models.NewJoinsQueryBuilder()
|
||||
|
||||
updatedScene := models.ScenePartial{
|
||||
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
|
||||
}
|
||||
if input.Title != nil {
|
||||
updatedScene.Title = &sql.NullString{String: *input.Title, Valid: true}
|
||||
}
|
||||
if input.Details != nil {
|
||||
updatedScene.Details = &sql.NullString{String: *input.Details, Valid: true}
|
||||
}
|
||||
if input.URL != nil {
|
||||
updatedScene.URL = &sql.NullString{String: *input.URL, Valid: true}
|
||||
}
|
||||
if input.Date != nil {
|
||||
updatedScene.Date = &models.SQLiteDate{String: *input.Date, Valid: true}
|
||||
}
|
||||
if input.Rating != nil {
|
||||
// a rating of 0 means unset the rating
|
||||
if *input.Rating == 0 {
|
||||
updatedScene.Rating = &sql.NullInt64{Int64: 0, Valid: false}
|
||||
} else {
|
||||
updatedScene.Rating = &sql.NullInt64{Int64: int64(*input.Rating), Valid: true}
|
||||
}
|
||||
}
|
||||
if input.StudioID != nil {
|
||||
// empty string means unset the studio
|
||||
if *input.StudioID == "" {
|
||||
updatedScene.StudioID = &sql.NullInt64{Int64: 0, Valid: false}
|
||||
} else {
|
||||
studioID, _ := strconv.ParseInt(*input.StudioID, 10, 64)
|
||||
updatedScene.StudioID = &sql.NullInt64{Int64: studioID, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
ret := []*models.Scene{}
|
||||
|
||||
for _, sceneIDStr := range input.Ids {
|
||||
sceneID, _ := strconv.Atoi(sceneIDStr)
|
||||
updatedScene.ID = sceneID
|
||||
|
||||
scene, err := qb.Update(updatedScene, tx)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret = append(ret, scene)
|
||||
|
||||
if input.GalleryID != nil {
|
||||
// Save the gallery
|
||||
galleryID, _ := strconv.Atoi(*input.GalleryID)
|
||||
updatedGallery := models.Gallery{
|
||||
ID: galleryID,
|
||||
SceneID: sql.NullInt64{Int64: int64(sceneID), Valid: true},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: updatedTime},
|
||||
}
|
||||
gqb := models.NewGalleryQueryBuilder()
|
||||
_, err := gqb.Update(updatedGallery, tx)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Save the performers
|
||||
if wasFieldIncluded(ctx, "performer_ids") {
|
||||
var performerJoins []models.PerformersScenes
|
||||
for _, pid := range input.PerformerIds {
|
||||
performerID, _ := strconv.Atoi(pid)
|
||||
performerJoin := models.PerformersScenes{
|
||||
PerformerID: performerID,
|
||||
SceneID: sceneID,
|
||||
}
|
||||
performerJoins = append(performerJoins, performerJoin)
|
||||
}
|
||||
if err := jqb.UpdatePerformersScenes(sceneID, performerJoins, tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Save the tags
|
||||
if wasFieldIncluded(ctx, "tag_ids") {
|
||||
var tagJoins []models.ScenesTags
|
||||
for _, tid := range input.TagIds {
|
||||
tagID, _ := strconv.Atoi(tid)
|
||||
tagJoin := models.ScenesTags{
|
||||
SceneID: sceneID,
|
||||
TagID: tagID,
|
||||
}
|
||||
tagJoins = append(tagJoins, tagJoin)
|
||||
}
|
||||
if err := jqb.UpdateScenesTags(sceneID, tagJoins, tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneDestroyInput) (bool, error) {
|
||||
qb := models.NewSceneQueryBuilder()
|
||||
tx := database.DB.MustBeginTx(ctx, nil)
|
||||
|
|
|
@ -25,6 +25,8 @@ interface IListFilterProps {
|
|||
onChangeDisplayMode: (displayMode: DisplayMode) => void;
|
||||
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
|
||||
onRemoveCriterion: (criterion: Criterion) => void;
|
||||
onSelectAll?: () => void;
|
||||
onSelectNone?: () => void;
|
||||
filter: ListFilterModel;
|
||||
}
|
||||
|
||||
|
@ -134,6 +136,39 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
|
|||
));
|
||||
}
|
||||
|
||||
function onSelectAll() {
|
||||
if (props.onSelectAll) {
|
||||
props.onSelectAll();
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectNone() {
|
||||
if (props.onSelectNone) {
|
||||
props.onSelectNone();
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectAll() {
|
||||
if (props.onSelectAll) {
|
||||
return <Button onClick={() => onSelectAll()} text="Select All"/>;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectNone() {
|
||||
if (props.onSelectNone) {
|
||||
return <Button onClick={() => onSelectNone()} text="Select None"/>;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectAllNone() {
|
||||
return (
|
||||
<>
|
||||
{renderSelectAll()}
|
||||
{renderSelectNone()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<>
|
||||
|
@ -176,6 +211,10 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
|
|||
<ButtonGroup className="filter-item">
|
||||
{renderDisplayModeOptions()}
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup className="filter-item">
|
||||
{renderSelectAllNone()}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div style={{display: "flex", justifyContent: "center", margin: "10px auto"}}>
|
||||
{renderFilterTags()}
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Elevation,
|
||||
H4,
|
||||
|
@ -19,11 +20,14 @@ import { SceneHelpers } from "./helpers";
|
|||
|
||||
interface ISceneCardProps {
|
||||
scene: GQL.SlimSceneDataFragment;
|
||||
selected: boolean | undefined;
|
||||
onSelectedChanged: (selected : boolean, shiftKey : boolean) => void;
|
||||
}
|
||||
|
||||
export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardProps) => {
|
||||
const [previewPath, setPreviewPath] = useState<string | undefined>(undefined);
|
||||
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: false});
|
||||
|
||||
|
||||
function maybeRenderRatingBanner() {
|
||||
if (!props.scene.rating) { return; }
|
||||
|
@ -115,6 +119,8 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
|
|||
setPreviewPath("");
|
||||
}
|
||||
|
||||
var shiftKey = false;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="grid-item"
|
||||
|
@ -122,6 +128,12 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
|
|||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Checkbox
|
||||
className="card-select"
|
||||
checked={props.selected}
|
||||
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
|
||||
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => { shiftKey = event.shiftKey; event.stopPropagation(); } }
|
||||
/>
|
||||
<Link to={`/scenes/${props.scene.id}`} className="image previewable">
|
||||
{maybeRenderRatingBanner()}
|
||||
<video className="preview" loop={true} poster={props.scene.paths.screenshot || ""} ref={videoHoverHook.videoEl}>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import _ from "lodash";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { QueryHookResult } from "react-apollo-hooks";
|
||||
import { FindScenesQuery, FindScenesVariables } from "../../core/generated-graphql";
|
||||
import { FindScenesQuery, FindScenesVariables, SlimSceneDataFragment } from "../../core/generated-graphql";
|
||||
import { ListHook } from "../../hooks/ListHook";
|
||||
import { IBaseProps } from "../../models/base-props";
|
||||
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||
|
@ -9,6 +9,7 @@ import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
|||
import { WallPanel } from "../Wall/WallPanel";
|
||||
import { SceneCard } from "./SceneCard";
|
||||
import { SceneListTable } from "./SceneListTable";
|
||||
import { SceneSelectedOptions } from "./SceneSelectedOptions";
|
||||
|
||||
interface ISceneListProps extends IBaseProps {}
|
||||
|
||||
|
@ -17,14 +18,50 @@ export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListP
|
|||
filterMode: FilterMode.Scenes,
|
||||
props,
|
||||
renderContent,
|
||||
renderSelectedOptions
|
||||
});
|
||||
|
||||
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel) {
|
||||
function renderSelectedOptions(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, selectedIds: Set<string>) {
|
||||
// find the selected items from the ids
|
||||
if (!result.data || !result.data.findScenes) { return undefined; }
|
||||
|
||||
var scenes = result.data.findScenes.scenes;
|
||||
|
||||
var selectedScenes : SlimSceneDataFragment[] = [];
|
||||
selectedIds.forEach((id) => {
|
||||
var scene = scenes.find((scene) => {
|
||||
return scene.id === id;
|
||||
});
|
||||
|
||||
if (scene) {
|
||||
selectedScenes.push(scene);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SceneSelectedOptions selected={selectedScenes} onScenesUpdated={() => { return; }}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSceneCard(scene : SlimSceneDataFragment, selectedIds: Set<string>) {
|
||||
return (
|
||||
<SceneCard
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
selected={selectedIds.has(scene.id)}
|
||||
onSelectedChanged={(selected: boolean, shiftKey: boolean) => listData.onSelectChange(scene.id, selected, shiftKey)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>) {
|
||||
if (!result.data || !result.data.findScenes) { return; }
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="grid">
|
||||
{result.data.findScenes.scenes.map((scene) => (<SceneCard key={scene.id} scene={scene} />))}
|
||||
{result.data.findScenes.scenes.map((scene) => renderSceneCard(scene, selectedIds))}
|
||||
</div>
|
||||
);
|
||||
} else if (filter.displayMode === DisplayMode.List) {
|
||||
|
|
|
@ -0,0 +1,308 @@
|
|||
import _ from "lodash";
|
||||
import {
|
||||
AnchorButton,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ControlGroup,
|
||||
FormGroup,
|
||||
HTMLSelect,
|
||||
InputGroup,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Spinner,
|
||||
Tag,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, SyntheticEvent, useEffect, useRef, useState } from "react";
|
||||
import { FilterSelect } from "../select/FilterSelect";
|
||||
import { FilterMultiSelect } from "../select/FilterMultiSelect";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { ErrorUtils } from "../../utils/errors";
|
||||
import { ToastUtils } from "../../utils/toasts";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.SlimSceneDataFragment[],
|
||||
onScenesUpdated: () => void;
|
||||
}
|
||||
|
||||
export const SceneSelectedOptions: FunctionComponent<IListOperationProps> = (props: IListOperationProps) => {
|
||||
const [rating, setRating] = useState<string>("");
|
||||
const [studioId, setStudioId] = useState<string | undefined>(undefined);
|
||||
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
|
||||
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
|
||||
|
||||
const updateScenes = StashService.useBulkSceneUpdate(getSceneInput());
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
function getSceneInput() : GQL.BulkSceneUpdateInput {
|
||||
// need to determine what we are actually setting on each scene
|
||||
var aggregateRating = getRating(props.selected);
|
||||
var aggregateStudioId = getStudioId(props.selected);
|
||||
var aggregatePerformerIds = getPerformerIds(props.selected);
|
||||
var aggregateTagIds = getTagIds(props.selected);
|
||||
|
||||
var sceneInput : GQL.BulkSceneUpdateInput = {
|
||||
ids: props.selected.map((scene) => {
|
||||
return scene.id;
|
||||
})
|
||||
};
|
||||
|
||||
// if rating is undefined
|
||||
if (rating === "") {
|
||||
// and all scenes have the same rating, then we are unsetting the rating.
|
||||
if(aggregateRating) {
|
||||
// an undefined rating is ignored in the server, so set it to 0 instead
|
||||
sceneInput.rating = 0;
|
||||
}
|
||||
// otherwise not setting the rating
|
||||
} else {
|
||||
// if rating is set, then we are setting the rating for all
|
||||
sceneInput.rating = Number.parseInt(rating);
|
||||
}
|
||||
|
||||
// if studioId is undefined
|
||||
if (studioId === undefined) {
|
||||
// and all scenes have the same studioId,
|
||||
// then unset the studioId, otherwise ignoring studioId
|
||||
if (aggregateStudioId) {
|
||||
// an undefined studio_id is ignored in the server, so set it to empty string instead
|
||||
sceneInput.studio_id = "";
|
||||
}
|
||||
} else {
|
||||
// if studioId is set, then we are setting it
|
||||
sceneInput.studio_id = studioId;
|
||||
}
|
||||
|
||||
// if performerIds are empty
|
||||
if (!performerIds || performerIds.length === 0) {
|
||||
// and all scenes have the same ids,
|
||||
if (aggregatePerformerIds.length > 0) {
|
||||
// then unset the performerIds, otherwise ignore
|
||||
sceneInput.performer_ids = performerIds;
|
||||
}
|
||||
} else {
|
||||
// if performerIds non-empty, then we are setting them
|
||||
sceneInput.performer_ids = performerIds;
|
||||
}
|
||||
|
||||
// if tagIds non-empty, then we are setting them
|
||||
if (!tagIds || tagIds.length === 0) {
|
||||
// and all scenes have the same ids,
|
||||
if (aggregateTagIds.length > 0) {
|
||||
// then unset the tagIds, otherwise ignore
|
||||
sceneInput.tag_ids = tagIds;
|
||||
}
|
||||
} else {
|
||||
// if tagIds non-empty, then we are setting them
|
||||
sceneInput.tag_ids = tagIds;
|
||||
}
|
||||
|
||||
return sceneInput;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateScenes();
|
||||
ToastUtils.success("Updated scenes");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
props.onScenesUpdated();
|
||||
}
|
||||
|
||||
function getRating(state: GQL.SlimSceneDataFragment[]) {
|
||||
var ret : number | undefined;
|
||||
var first = true;
|
||||
|
||||
state.forEach((scene : GQL.SlimSceneDataFragment) => {
|
||||
if (first) {
|
||||
ret = scene.rating;
|
||||
first = false;
|
||||
} else {
|
||||
if (ret !== scene.rating) {
|
||||
ret = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function getStudioId(state: GQL.SlimSceneDataFragment[]) {
|
||||
var ret : string | undefined;
|
||||
var first = true;
|
||||
|
||||
state.forEach((scene : GQL.SlimSceneDataFragment) => {
|
||||
if (first) {
|
||||
ret = scene.studio ? scene.studio.id : undefined;
|
||||
first = false;
|
||||
} else {
|
||||
var studioId = scene.studio ? scene.studio.id : undefined;
|
||||
if (ret != studioId) {
|
||||
ret = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function toId(object : any) {
|
||||
return object.id;
|
||||
}
|
||||
|
||||
function getPerformerIds(state: GQL.SlimSceneDataFragment[]) {
|
||||
var ret : string[] = [];
|
||||
var first = true;
|
||||
|
||||
state.forEach((scene : GQL.SlimSceneDataFragment) => {
|
||||
if (first) {
|
||||
ret = !!scene.performers ? scene.performers.map(toId).sort() : [];
|
||||
first = false;
|
||||
} else {
|
||||
const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
|
||||
|
||||
if (!_.isEqual(ret, perfIds)) {
|
||||
ret = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function getTagIds(state: GQL.SlimSceneDataFragment[]) {
|
||||
var ret : string[] = [];
|
||||
var first = true;
|
||||
|
||||
state.forEach((scene : GQL.SlimSceneDataFragment) => {
|
||||
if (first) {
|
||||
ret = !!scene.tags ? scene.tags.map(toId).sort() : [];
|
||||
first = false;
|
||||
} else {
|
||||
const tIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
|
||||
|
||||
if (!_.isEqual(ret, tIds)) {
|
||||
ret = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function updateScenesEditState(state: GQL.SlimSceneDataFragment[]) {
|
||||
function toId(object : any) {
|
||||
return object.id;
|
||||
}
|
||||
|
||||
var rating : string = "";
|
||||
var studioId : string | undefined;
|
||||
var performerIds : string[] = [];
|
||||
var tagIds : string[] = [];
|
||||
var first = true;
|
||||
|
||||
state.forEach((scene : GQL.SlimSceneDataFragment) => {
|
||||
var thisRating = scene.rating ? scene.rating.toString() : "";
|
||||
var thisStudio = scene.studio ? scene.studio.id : undefined;
|
||||
|
||||
if (first) {
|
||||
rating = thisRating;
|
||||
studioId = thisStudio;
|
||||
performerIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
|
||||
tagIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
|
||||
first = false;
|
||||
} else {
|
||||
if (rating !== thisRating) {
|
||||
rating = "";
|
||||
}
|
||||
if (studioId != thisStudio) {
|
||||
studioId = undefined;
|
||||
}
|
||||
const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
|
||||
const tIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
|
||||
|
||||
if (!_.isEqual(performerIds, perfIds)) {
|
||||
performerIds = [];
|
||||
}
|
||||
|
||||
if (!_.isEqual(tagIds, tIds)) {
|
||||
tagIds = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setRating(rating);
|
||||
setStudioId(studioId);
|
||||
setPerformerIds(performerIds);
|
||||
setTagIds(tagIds);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateScenesEditState(props.selected);
|
||||
}, [props.selected]);
|
||||
|
||||
function renderMultiSelect(type: "performers" | "tags", initialIds: string[] | undefined) {
|
||||
return (
|
||||
<FilterMultiSelect
|
||||
type={type}
|
||||
onUpdate={(items) => {
|
||||
const ids = items.map((i) => i.id);
|
||||
switch (type) {
|
||||
case "performers": setPerformerIds(ids); break;
|
||||
case "tags": setTagIds(ids); break;
|
||||
}
|
||||
}}
|
||||
initialIds={initialIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<>
|
||||
{isLoading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||
<div className="operation-container">
|
||||
<FormGroup className="operation-item" label="Rating">
|
||||
<HTMLSelect
|
||||
options={["", 1, 2, 3, 4, 5]}
|
||||
onChange={(event) => setRating(event.target.value)}
|
||||
value={rating}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup className="operation-item" label="Studio">
|
||||
<FilterSelect
|
||||
type="studios"
|
||||
onSelectItem={(item : any) => setStudioId(item ? item.id : undefined)}
|
||||
initialId={studioId}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup className="operation-item" label="Performers">
|
||||
{renderMultiSelect("performers", performerIds)}
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup className="operation-item" label="Tags">
|
||||
{renderMultiSelect("tags", tagIds)}
|
||||
</FormGroup>
|
||||
|
||||
<ButtonGroup className="operation-item">
|
||||
<Button
|
||||
intent="primary"
|
||||
onClick={() => onSave()}>
|
||||
Apply
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return render();
|
||||
};
|
|
@ -24,13 +24,21 @@ interface IProps extends HTMLInputProps, Partial<IMultiSelectProps<ValidTypes>>
|
|||
}
|
||||
|
||||
export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||
let items: ValidTypes[];
|
||||
let InternalMultiSelect: new (props: IMultiSelectProps<any>) => MultiSelect<any>;
|
||||
var createNewFunc = undefined;
|
||||
let MultiSelectImpl = getMultiSelectImpl();
|
||||
let InternalMultiSelect = MultiSelectImpl.getInternalMultiSelect();
|
||||
const data = MultiSelectImpl.getData();
|
||||
|
||||
const [selectedItems, setSelectedItems] = React.useState<ValidTypes[]>([]);
|
||||
const [items, setItems] = React.useState<ValidTypes[]>([]);
|
||||
const [newTagName, setNewTagName] = React.useState<string>("");
|
||||
const createTag = StashService.useTagCreate(getTagInput() as GQL.TagCreateInput);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!!data) {
|
||||
MultiSelectImpl.translateData();
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
function getTagInput() {
|
||||
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name: newTagName };
|
||||
return tagInput;
|
||||
|
@ -42,8 +50,10 @@ export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps
|
|||
try {
|
||||
created = await createTag();
|
||||
|
||||
items.push(created.data.tagCreate);
|
||||
setItems(items.slice());
|
||||
addSelectedItem(created.data.tagCreate);
|
||||
|
||||
|
||||
ToastUtils.success("Created tag");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
|
@ -76,43 +86,50 @@ export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps
|
|||
);
|
||||
}
|
||||
|
||||
switch (props.type) {
|
||||
case "performers": {
|
||||
const { data } = StashService.useAllPerformersForFilter();
|
||||
items = !!data && !!data.allPerformers ? data.allPerformers : [];
|
||||
InternalMultiSelect = InternalPerformerMultiSelect;
|
||||
break;
|
||||
}
|
||||
case "studios": {
|
||||
const { data } = StashService.useAllStudiosForFilter();
|
||||
items = !!data && !!data.allStudios ? data.allStudios : [];
|
||||
InternalMultiSelect = InternalStudioMultiSelect;
|
||||
break;
|
||||
}
|
||||
case "tags": {
|
||||
const { data } = StashService.useAllTagsForFilter();
|
||||
items = !!data && !!data.allTags ? data.allTags : [];
|
||||
InternalMultiSelect = InternalTagMultiSelect;
|
||||
createNewFunc = createNewTag;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.error("Unhandled case in FilterMultiSelect");
|
||||
return <>Unhandled case in FilterMultiSelect</>;
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
const [selectedItems, setSelectedItems] = React.useState<ValidTypes[]>([]);
|
||||
const [isInitialized, setIsInitialized] = React.useState<boolean>(false);
|
||||
/* eslint-enable */
|
||||
|
||||
if (!!props.initialIds && selectedItems.length === 0 && !isInitialized) {
|
||||
const initialItems = items.filter((item) => props.initialIds!.includes(item.id));
|
||||
if (initialItems.length > 0) {
|
||||
React.useEffect(() => {
|
||||
if (!!props.initialIds && !!items) {
|
||||
const initialItems = items.filter((item) => props.initialIds!.includes(item.id));
|
||||
setSelectedItems(initialItems);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [props.initialIds, items]);
|
||||
|
||||
function getMultiSelectImpl() {
|
||||
let getInternalMultiSelect: () => new (props: IMultiSelectProps<any>) => MultiSelect<any>;
|
||||
let getData: () => GQL.AllPerformersForFilterQuery | GQL.AllStudiosForFilterQuery | GQL.AllTagsForFilterQuery | undefined;
|
||||
let translateData: () => void;
|
||||
let createNewObject: ((query : string) => void) | undefined = undefined;
|
||||
|
||||
switch (props.type) {
|
||||
case "performers": {
|
||||
getInternalMultiSelect = () => { return InternalPerformerMultiSelect; };
|
||||
getData = () => { const { data } = StashService.useAllPerformersForFilter(); return data; }
|
||||
translateData = () => { let perfData = data as GQL.AllPerformersForFilterQuery; setItems(!!perfData && !!perfData.allPerformers ? perfData.allPerformers : []); };
|
||||
break;
|
||||
}
|
||||
case "studios": {
|
||||
getInternalMultiSelect = () => { return InternalStudioMultiSelect; };
|
||||
getData = () => { const { data } = StashService.useAllStudiosForFilter(); return data; }
|
||||
translateData = () => { let studioData = data as GQL.AllStudiosForFilterQuery; setItems(!!studioData && !!studioData.allStudios ? studioData.allStudios : []); };
|
||||
break;
|
||||
}
|
||||
case "tags": {
|
||||
getInternalMultiSelect = () => { return InternalTagMultiSelect; };
|
||||
getData = () => { const { data } = StashService.useAllTagsForFilter(); return data; }
|
||||
translateData = () => { let tagData = data as GQL.AllTagsForFilterQuery; setItems(!!tagData && !!tagData.allTags ? tagData.allTags : []); };
|
||||
createNewObject = createNewTag;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw "Unhandled case in FilterMultiSelect";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getInternalMultiSelect: getInternalMultiSelect,
|
||||
getData: getData,
|
||||
translateData: translateData,
|
||||
createNewObject: createNewObject
|
||||
};
|
||||
}
|
||||
|
||||
const renderItem: ItemRenderer<ValidTypes> = (item, itemProps) => {
|
||||
|
@ -165,7 +182,7 @@ export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps
|
|||
onItemSelect={onItemSelect}
|
||||
resetOnSelect={true}
|
||||
popoverProps={{position: "bottom"}}
|
||||
createNewItemFromQuery={createNewFunc}
|
||||
createNewItemFromQuery={MultiSelectImpl.createNewObject}
|
||||
createNewItemRenderer={createNewRenderer}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -179,6 +179,10 @@ export class StashService {
|
|||
return GQL.useSceneUpdate({ variables: input });
|
||||
}
|
||||
|
||||
public static useBulkSceneUpdate(input: GQL.BulkSceneUpdateInput) {
|
||||
return GQL.useBulkSceneUpdate({ variables: input, refetchQueries: ["FindScenes"] });
|
||||
}
|
||||
|
||||
public static useSceneDestroy(input: GQL.SceneDestroyInput) {
|
||||
return GQL.useSceneDestroy({ variables: input });
|
||||
}
|
||||
|
|
|
@ -15,17 +15,22 @@ export interface IListHookData {
|
|||
filter: ListFilterModel;
|
||||
template: JSX.Element;
|
||||
options: IListHookOptions;
|
||||
onSelectChange: (id: string, selected : boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
export interface IListHookOptions {
|
||||
filterMode: FilterMode;
|
||||
props: IBaseProps;
|
||||
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel) => JSX.Element | undefined;
|
||||
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel, selectedIds: Set<string>) => JSX.Element | undefined;
|
||||
renderSelectedOptions?: (result: QueryHookResult<any, any>, selectedIds: Set<string>) => JSX.Element | undefined;
|
||||
}
|
||||
|
||||
export class ListHook {
|
||||
public static useList(options: IListHookOptions): IListHookData {
|
||||
const [filter, setFilter] = useState<ListFilterModel>(new ListFilterModel(options.filterMode));
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastClickedId, setLastClickedId] = useState<string | undefined>(undefined);
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
|
||||
// Update the filter when the query parameters change
|
||||
useEffect(() => {
|
||||
|
@ -39,42 +44,61 @@ export class ListHook {
|
|||
}, [options.props.location.search]);
|
||||
|
||||
let result: QueryHookResult<any, any>;
|
||||
let totalCount: number;
|
||||
|
||||
let getData: (filter : ListFilterModel) => QueryHookResult<any, any>;
|
||||
let getItems: () => any[];
|
||||
let getCount: () => number;
|
||||
|
||||
switch (options.filterMode) {
|
||||
case FilterMode.Scenes: {
|
||||
result = StashService.useFindScenes(filter);
|
||||
totalCount = !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0;
|
||||
getData = (filter : ListFilterModel) => { return StashService.useFindScenes(filter); }
|
||||
getItems = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.scenes : []; }
|
||||
getCount = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0; }
|
||||
break;
|
||||
}
|
||||
case FilterMode.SceneMarkers: {
|
||||
result = StashService.useFindSceneMarkers(filter);
|
||||
totalCount = !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.count : 0;
|
||||
getData = (filter : ListFilterModel) => { return StashService.useFindSceneMarkers(filter); }
|
||||
getItems = () => { return !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.scene_markers : []; }
|
||||
getCount = () => { return !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.count : 0; }
|
||||
break;
|
||||
}
|
||||
case FilterMode.Galleries: {
|
||||
result = StashService.useFindGalleries(filter);
|
||||
totalCount = !!result.data && !!result.data.findGalleries ? result.data.findGalleries.count : 0;
|
||||
getData = (filter : ListFilterModel) => { return StashService.useFindGalleries(filter); }
|
||||
getItems = () => { return !!result.data && !!result.data.findGalleries ? result.data.findGalleries.galleries : []; }
|
||||
getCount = () => { return !!result.data && !!result.data.findGalleries ? result.data.findGalleries.count : 0; }
|
||||
break;
|
||||
}
|
||||
case FilterMode.Studios: {
|
||||
result = StashService.useFindStudios(filter);
|
||||
totalCount = !!result.data && !!result.data.findStudios ? result.data.findStudios.count : 0;
|
||||
getData = (filter : ListFilterModel) => { return StashService.useFindStudios(filter); }
|
||||
getItems = () => { return !!result.data && !!result.data.findStudios ? result.data.findStudios.studios : []; }
|
||||
getCount = () => { return !!result.data && !!result.data.findStudios ? result.data.findStudios.count : 0; }
|
||||
break;
|
||||
}
|
||||
case FilterMode.Performers: {
|
||||
result = StashService.useFindPerformers(filter);
|
||||
totalCount = !!result.data && !!result.data.findPerformers ? result.data.findPerformers.count : 0;
|
||||
getData = (filter : ListFilterModel) => { return StashService.useFindPerformers(filter); }
|
||||
getItems = () => { return !!result.data && !!result.data.findPerformers ? result.data.findPerformers.performers : []; }
|
||||
getCount = () => { return !!result.data && !!result.data.findPerformers ? result.data.findPerformers.count : 0; }
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.error("REMOVE DEFAULT IN LIST HOOK");
|
||||
result = StashService.useFindScenes(filter);
|
||||
totalCount = !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0;
|
||||
getData = (filter : ListFilterModel) => { return StashService.useFindScenes(filter); }
|
||||
getItems = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.scenes : []; }
|
||||
getCount = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result = getData(filter);
|
||||
|
||||
useEffect(() => {
|
||||
setTotalCount(getCount());
|
||||
|
||||
// select none when data changes
|
||||
onSelectNone();
|
||||
setLastClickedId(undefined);
|
||||
}, [result.data])
|
||||
|
||||
// Update the query parameters when the data changes
|
||||
useEffect(() => {
|
||||
const location = Object.assign({}, options.props.history.location);
|
||||
|
@ -159,6 +183,77 @@ export class ListHook {
|
|||
setFilter(newFilter);
|
||||
}
|
||||
|
||||
function onSelectChange(id: string, selected : boolean, shiftKey: boolean) {
|
||||
if (shiftKey) {
|
||||
multiSelect(id, selected);
|
||||
} else {
|
||||
singleSelect(id, selected);
|
||||
}
|
||||
}
|
||||
|
||||
function singleSelect(id: string, selected: boolean) {
|
||||
setLastClickedId(id);
|
||||
|
||||
const newSelectedIds = _.clone(selectedIds);
|
||||
if (selected) {
|
||||
newSelectedIds.add(id);
|
||||
} else {
|
||||
newSelectedIds.delete(id);
|
||||
}
|
||||
|
||||
setSelectedIds(newSelectedIds);
|
||||
}
|
||||
|
||||
function multiSelect(id: string, selected : boolean) {
|
||||
let startIndex = 0;
|
||||
let thisIndex = -1;
|
||||
|
||||
if (!!lastClickedId) {
|
||||
startIndex = getItems().findIndex((item) => {
|
||||
return item.id === lastClickedId;
|
||||
});
|
||||
}
|
||||
|
||||
thisIndex = getItems().findIndex((item) => {
|
||||
return item.id === id;
|
||||
});
|
||||
|
||||
selectRange(startIndex, thisIndex);
|
||||
}
|
||||
|
||||
function selectRange(startIndex : number, endIndex : number) {
|
||||
if (startIndex > endIndex) {
|
||||
let tmp = startIndex;
|
||||
startIndex = endIndex;
|
||||
endIndex = tmp;
|
||||
}
|
||||
|
||||
const subset = getItems().slice(startIndex, endIndex + 1);
|
||||
const newSelectedIds : Set<string> = new Set();
|
||||
|
||||
subset.forEach((item) => {
|
||||
newSelectedIds.add(item.id);
|
||||
});
|
||||
|
||||
setSelectedIds(newSelectedIds);
|
||||
}
|
||||
|
||||
function onSelectAll() {
|
||||
const newSelectedIds : Set<string> = new Set();
|
||||
getItems().forEach((item) => {
|
||||
newSelectedIds.add(item.id);
|
||||
});
|
||||
|
||||
setSelectedIds(newSelectedIds);
|
||||
setLastClickedId(undefined);
|
||||
}
|
||||
|
||||
function onSelectNone() {
|
||||
const newSelectedIds : Set<string> = new Set();
|
||||
setSelectedIds(newSelectedIds);
|
||||
setLastClickedId(undefined);
|
||||
}
|
||||
|
||||
const template = (
|
||||
<div>
|
||||
<ListFilter
|
||||
|
@ -169,11 +264,14 @@ export class ListHook {
|
|||
onChangeDisplayMode={onChangeDisplayMode}
|
||||
onAddCriterion={onAddCriterion}
|
||||
onRemoveCriterion={onRemoveCriterion}
|
||||
onSelectAll={onSelectAll}
|
||||
onSelectNone={onSelectNone}
|
||||
filter={filter}
|
||||
/>
|
||||
{options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined}
|
||||
{result.loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||
{result.error ? <h1>{result.error.message}</h1> : undefined}
|
||||
{options.renderContent(result, filter)}
|
||||
{options.renderContent(result, filter, selectedIds)}
|
||||
<Pagination
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
|
@ -183,6 +281,6 @@ export class ListHook {
|
|||
</div>
|
||||
);
|
||||
|
||||
return { filter, template, options };
|
||||
return { filter, template, options, onSelectChange };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -87,6 +87,14 @@ code {
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.grid-item label.card-select {
|
||||
position: absolute;
|
||||
padding-left: 15px;
|
||||
margin-top: -12px;
|
||||
z-index: 10;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
video.preview {
|
||||
// height: 225px; // slows down the page
|
||||
width: 100%;
|
||||
|
@ -95,7 +103,7 @@ video.preview {
|
|||
object-fit: cover;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
.filter-item, .operation-item {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
|
@ -116,7 +124,7 @@ video.preview {
|
|||
}
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
.filter-container, .operation-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 10px auto;
|
||||
|
|
Loading…
Reference in New Issue