Support deleting multiple scenes (#630)

* Improve layout and add buttons
* Move functionality into ListFilter
* Make modal style dark
* Convert scene options into edit scenes dialog
* Add delete scenes dialog
* Clear selected ids on delete
* Refetch after update/delete
* Use DeleteScenesDialog in Scene page
* Show scene check boxes in small screens
* Change default multi-set mode to set
This commit is contained in:
WithoutPants 2020-06-23 10:40:11 +10:00 committed by GitHub
parent 83f8bc0832
commit 455e16ece9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 613 additions and 366 deletions

View file

@ -80,6 +80,10 @@ mutation SceneDestroy($id: ID!, $delete_file: Boolean, $delete_generated : Boole
sceneDestroy(input: {id: $id, delete_file: $delete_file, delete_generated: $delete_generated}) sceneDestroy(input: {id: $id, delete_file: $delete_file, delete_generated: $delete_generated})
} }
mutation ScenesDestroy($ids: [ID!]!, $delete_file: Boolean, $delete_generated : Boolean) {
scenesDestroy(input: {ids: $ids, delete_file: $delete_file, delete_generated: $delete_generated})
}
mutation SceneGenerateScreenshot($id: ID!, $at: Float) { mutation SceneGenerateScreenshot($id: ID!, $at: Float) {
sceneGenerateScreenshot(id: $id, at: $at) sceneGenerateScreenshot(id: $id, at: $at)
} }

View file

@ -105,6 +105,7 @@ type Mutation {
sceneUpdate(input: SceneUpdateInput!): Scene sceneUpdate(input: SceneUpdateInput!): Scene
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!] bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
sceneDestroy(input: SceneDestroyInput!): Boolean! sceneDestroy(input: SceneDestroyInput!): Boolean!
scenesDestroy(input: ScenesDestroyInput!): Boolean!
scenesUpdate(input: [SceneUpdateInput!]!): [Scene] scenesUpdate(input: [SceneUpdateInput!]!): [Scene]
"""Increments the o-counter for a scene. Returns the new value""" """Increments the o-counter for a scene. Returns the new value"""

View file

@ -99,6 +99,12 @@ input SceneDestroyInput {
delete_generated: Boolean delete_generated: Boolean
} }
input ScenesDestroyInput {
ids: [ID!]!
delete_file: Boolean
delete_generated: Boolean
}
type FindScenesResultType { type FindScenesResultType {
count: Int! count: Int!
scenes: [Scene!]! scenes: [Scene!]!

View file

@ -429,6 +429,47 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
return true, nil return true, nil
} }
func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.ScenesDestroyInput) (bool, error) {
qb := models.NewSceneQueryBuilder()
tx := database.DB.MustBeginTx(ctx, nil)
var scenes []*models.Scene
for _, id := range input.Ids {
sceneID, _ := strconv.Atoi(id)
scene, err := qb.Find(sceneID)
if scene != nil {
scenes = append(scenes, scene)
}
err = manager.DestroyScene(sceneID, tx)
if err != nil {
tx.Rollback()
return false, err
}
}
if err := tx.Commit(); err != nil {
return false, err
}
for _, scene := range scenes {
// if delete generated is true, then delete the generated files
// for the scene
if input.DeleteGenerated != nil && *input.DeleteGenerated {
manager.DeleteGeneratedSceneFiles(scene)
}
// if delete file is true, then delete the file as well
// if it fails, just log a message
if input.DeleteFile != nil && *input.DeleteFile {
manager.DeleteSceneFile(scene)
}
}
return true, nil
}
func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input models.SceneMarkerCreateInput) (*models.SceneMarker, error) { func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input models.SceneMarkerCreateInput) (*models.SceneMarker, error) {
primaryTagID, _ := strconv.Atoi(input.PrimaryTagID) primaryTagID, _ := strconv.Atoi(input.PrimaryTagID)
sceneID, _ := strconv.Atoi(input.SceneID) sceneID, _ := strconv.Atoi(input.SceneID)

View file

@ -3,11 +3,13 @@ import ReactMarkdown from "react-markdown";
const markup = ` const markup = `
### New Features ### New Features
* Support deleting multiple scenes.
* Add in-app help manual. * Add in-app help manual.
* Add support for custom served folders. * Add support for custom served folders.
* Add support for parent/child studios. * Add support for parent/child studios.
### 🎨 Improvements ### 🎨 Improvements
* Added multi-scene edit dialog.
* Moved images to separate tables, increasing performance. * Moved images to separate tables, increasing performance.
* Add gallery grid view. * Add gallery grid view.
* Add is-missing scene filter for gallery query. * Add is-missing scene filter for gallery query.

View file

@ -117,6 +117,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
as="select" as="select"
onChange={onChangedModifierSelect} onChange={onChangedModifierSelect}
value={criterion.modifier} value={criterion.modifier}
className="btn-secondary"
> >
{criterion.modifierOptions.map((c) => ( {criterion.modifierOptions.map((c) => (
<option key={c.value} value={c.value}> <option key={c.value} value={c.value}>
@ -170,6 +171,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
as="select" as="select"
onChange={onChangedSingleSelect} onChange={onChangedSingleSelect}
value={criterion.value.toString()} value={criterion.value.toString()}
className="btn-secondary"
> >
{criterion.options.map((c) => ( {criterion.options.map((c) => (
<option key={c.toString()} value={c.toString()}> <option key={c.toString()} value={c.toString()}>
@ -190,6 +192,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
} }
return ( return (
<Form.Control <Form.Control
className="btn-secondary"
type={criterion.inputType} type={criterion.inputType}
onChange={onChangedInput} onChange={onChangedInput}
onBlur={onBlurInput} onBlur={onBlurInput}
@ -216,6 +219,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
as="select" as="select"
onChange={onChangedCriteriaType} onChange={onChangedCriteriaType}
value={criterion.type} value={criterion.type}
className="btn-secondary"
> >
{props.filter.criterionOptions.map((c) => ( {props.filter.criterionOptions.map((c) => (
<option key={c.value} value={c.value}> <option key={c.value} value={c.value}>

View file

@ -1,4 +1,4 @@
import { debounce } from "lodash"; import _, { debounce } from "lodash";
import React, { useState } from "react"; import React, { useState } from "react";
import { SortDirectionEnum } from "src/core/generated-graphql"; import { SortDirectionEnum } from "src/core/generated-graphql";
import { import {
@ -10,6 +10,10 @@ import {
OverlayTrigger, OverlayTrigger,
Tooltip, Tooltip,
SafeAnchorProps, SafeAnchorProps,
InputGroup,
FormControl,
Col,
Row,
} from "react-bootstrap"; } from "react-bootstrap";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
@ -24,20 +28,16 @@ interface IListFilterOperation {
} }
interface IListFilterProps { interface IListFilterProps {
onChangePageSize: (pageSize: number) => void; onFilterUpdate: (newFilter: ListFilterModel) => void;
onChangeQuery: (query: string) => void;
onChangeSortDirection: (sortDirection: SortDirectionEnum) => void;
onChangeSortBy: (sortBy: string) => void;
onSortReshuffle: () => void;
onChangeDisplayMode: (displayMode: DisplayMode) => void;
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
onRemoveCriterion: (criterion: Criterion) => void;
zoomIndex?: number; zoomIndex?: number;
onChangeZoom?: (zoomIndex: number) => void; onChangeZoom?: (zoomIndex: number) => void;
onSelectAll?: () => void; onSelectAll?: () => void;
onSelectNone?: () => void; onSelectNone?: () => void;
onEdit?: () => void;
onDelete?: () => void;
otherOperations?: IListFilterOperation[]; otherOperations?: IListFilterOperation[];
filter: ListFilterModel; filter: ListFilterModel;
itemsSelected?: boolean;
} }
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"]; const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"];
@ -46,7 +46,10 @@ export const ListFilter: React.FC<IListFilterProps> = (
props: IListFilterProps props: IListFilterProps
) => { ) => {
const searchCallback = debounce((value: string) => { const searchCallback = debounce((value: string) => {
props.onChangeQuery(value); const newFilter = _.cloneDeep(props.filter);
newFilter.searchTerm = value;
newFilter.currentPage = 1;
props.onFilterUpdate(newFilter);
}, 500); }, 500);
const [editingCriterion, setEditingCriterion] = useState< const [editingCriterion, setEditingCriterion] = useState<
@ -55,7 +58,11 @@ export const ListFilter: React.FC<IListFilterProps> = (
function onChangePageSize(event: React.ChangeEvent<HTMLSelectElement>) { function onChangePageSize(event: React.ChangeEvent<HTMLSelectElement>) {
const val = event.currentTarget.value; const val = event.currentTarget.value;
props.onChangePageSize(parseInt(val, 10));
const newFilter = _.cloneDeep(props.filter);
newFilter.itemsPerPage = parseInt(val, 10);
newFilter.currentPage = 1;
props.onFilterUpdate(newFilter);
} }
function onChangeQuery(event: React.FormEvent<HTMLInputElement>) { function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {
@ -63,34 +70,75 @@ export const ListFilter: React.FC<IListFilterProps> = (
} }
function onChangeSortDirection() { function onChangeSortDirection() {
const newFilter = _.cloneDeep(props.filter);
if (props.filter.sortDirection === SortDirectionEnum.Asc) { if (props.filter.sortDirection === SortDirectionEnum.Asc) {
props.onChangeSortDirection(SortDirectionEnum.Desc); newFilter.sortDirection = SortDirectionEnum.Desc;
} else { } else {
props.onChangeSortDirection(SortDirectionEnum.Asc); newFilter.sortDirection = SortDirectionEnum.Asc;
} }
props.onFilterUpdate(newFilter);
} }
function onChangeSortBy(event: React.MouseEvent<SafeAnchorProps>) { function onChangeSortBy(event: React.MouseEvent<SafeAnchorProps>) {
const target = event.currentTarget as HTMLAnchorElement; const target = event.currentTarget as HTMLAnchorElement;
props.onChangeSortBy(target.text);
const newFilter = _.cloneDeep(props.filter);
newFilter.sortBy = target.text;
newFilter.currentPage = 1;
props.onFilterUpdate(newFilter);
} }
function onReshuffleRandomSort() { function onReshuffleRandomSort() {
props.onSortReshuffle(); const newFilter = _.cloneDeep(props.filter);
newFilter.currentPage = 1;
newFilter.randomSeed = -1;
props.onFilterUpdate(newFilter);
} }
function onChangeDisplayMode(displayMode: DisplayMode) { function onChangeDisplayMode(displayMode: DisplayMode) {
props.onChangeDisplayMode(displayMode); const newFilter = _.cloneDeep(props.filter);
newFilter.displayMode = displayMode;
props.onFilterUpdate(newFilter);
} }
function onAddCriterion(criterion: Criterion, oldId?: string) { function onAddCriterion(criterion: Criterion, oldId?: string) {
props.onAddCriterion(criterion, oldId); const newFilter = _.cloneDeep(props.filter);
// Find if we are editing an existing criteria, then modify that. Or create a new one.
const existingIndex = newFilter.criteria.findIndex((c) => {
// If we modified an existing criterion, then look for the old id.
const id = oldId || criterion.getId();
return c.getId() === id;
});
if (existingIndex === -1) {
newFilter.criteria.push(criterion);
} else {
newFilter.criteria[existingIndex] = criterion;
}
// Remove duplicate modifiers
newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => {
return arr.map((mapObj) => mapObj.getId()).indexOf(obj.getId()) === pos;
});
newFilter.currentPage = 1;
props.onFilterUpdate(newFilter);
} }
function onCancelAddCriterion() { function onCancelAddCriterion() {
setEditingCriterion(undefined); setEditingCriterion(undefined);
} }
function onRemoveCriterion(removedCriterion: Criterion) {
const newFilter = _.cloneDeep(props.filter);
newFilter.criteria = newFilter.criteria.filter(
(criterion) => criterion.getId() !== removedCriterion.getId()
);
newFilter.currentPage = 1;
props.onFilterUpdate(newFilter);
}
let removedCriterionId = ""; let removedCriterionId = "";
function onRemoveCriterionTag(criterion?: Criterion) { function onRemoveCriterionTag(criterion?: Criterion) {
if (!criterion) { if (!criterion) {
@ -98,8 +146,9 @@ export const ListFilter: React.FC<IListFilterProps> = (
} }
setEditingCriterion(undefined); setEditingCriterion(undefined);
removedCriterionId = criterion.getId(); removedCriterionId = criterion.getId();
props.onRemoveCriterion(criterion); onRemoveCriterion(criterion);
} }
function onClickCriterionTag(criterion?: Criterion) { function onClickCriterionTag(criterion?: Criterion) {
if (!criterion || removedCriterionId !== "") { if (!criterion || removedCriterionId !== "") {
return; return;
@ -140,6 +189,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
return "Wall"; return "Wall";
} }
} }
return props.filter.displayModeOptions.map((option) => ( return props.filter.displayModeOptions.map((option) => (
<OverlayTrigger <OverlayTrigger
key={option} key={option}
@ -189,6 +239,18 @@ export const ListFilter: React.FC<IListFilterProps> = (
} }
} }
function onEdit() {
if (props.onEdit) {
props.onEdit();
}
}
function onDelete() {
if (props.onDelete) {
props.onDelete();
}
}
function renderSelectAll() { function renderSelectAll() {
if (props.onSelectAll) { if (props.onSelectAll) {
return ( return (
@ -258,7 +320,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
if (props.onChangeZoom) { if (props.onChangeZoom) {
return ( return (
<Form.Control <Form.Control
className="zoom-slider col-1 d-none d-sm-block" className="zoom-slider d-none d-sm-inline-flex"
type="range" type="range"
min={0} min={0}
max={3} max={3}
@ -271,85 +333,141 @@ export const ListFilter: React.FC<IListFilterProps> = (
} }
} }
function maybeRenderSelectedButtons() {
if (props.itemsSelected) {
return (
<>
{props.onEdit ? (
<ButtonGroup className="mr-1">
<OverlayTrigger overlay={<Tooltip id="edit">Edit</Tooltip>}>
<Button variant="secondary" onClick={onEdit}>
<Icon icon="pencil-alt" />
</Button>
</OverlayTrigger>
</ButtonGroup>
) : undefined}
{props.onDelete ? (
<ButtonGroup className="mr-1">
<OverlayTrigger overlay={<Tooltip id="delete">Delete</Tooltip>}>
<Button variant="danger" onClick={onDelete}>
<Icon icon="trash" />
</Button>
</OverlayTrigger>
</ButtonGroup>
) : undefined}
</>
);
}
}
function render() { function render() {
return ( return (
<> <>
<div className="filter-container"> <div className="form-row align-items-center justify-content-center">
<Form.Control <Col sm={12} md={6} xl={4} lg={5} className="my-1">
placeholder="Search..." <Row className="justify-content-center">
defaultValue={props.filter.searchTerm} <Col xs={6} className="px-1">
onInput={onChangeQuery} <InputGroup>
className="filter-item col-5 col-sm-2 bg-secondary text-white border-secondary" <FormControl
/> placeholder="Search..."
<Form.Control defaultValue={props.filter.searchTerm}
as="select" onInput={onChangeQuery}
onChange={onChangePageSize} className="bg-secondary text-white border-secondary"
value={props.filter.itemsPerPage.toString()}
className="btn-secondary filter-item col-1 d-none d-sm-inline"
>
{PAGE_SIZE_OPTIONS.map((s) => (
<option value={s} key={s}>
{s}
</option>
))}
</Form.Control>
<ButtonGroup className="filter-item">
<Dropdown as={ButtonGroup}>
<Dropdown.Toggle split variant="secondary" id="more-menu">
{props.filter.sortBy}
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{renderSortByOptions()}
</Dropdown.Menu>
<OverlayTrigger
overlay={
<Tooltip id="sort-direction-tooltip">
{props.filter.sortDirection === SortDirectionEnum.Asc
? "Ascending"
: "Descending"}
</Tooltip>
}
>
<Button variant="secondary" onClick={onChangeSortDirection}>
<Icon
icon={
props.filter.sortDirection === SortDirectionEnum.Asc
? "caret-up"
: "caret-down"
}
/> />
</Button>
</OverlayTrigger> <InputGroup.Append>
{props.filter.sortBy === "random" && ( <AddFilter
<OverlayTrigger filter={props.filter}
overlay={ onAddCriterion={onAddCriterion}
<Tooltip id="sort-reshuffle-tooltip">Reshuffle</Tooltip> onCancel={onCancelAddCriterion}
} editingCriterion={editingCriterion}
/>
</InputGroup.Append>
</InputGroup>
</Col>
<Col xs="auto" className="px-1">
<ButtonGroup>
<Dropdown as={ButtonGroup}>
<Dropdown.Toggle split variant="secondary" id="more-menu">
{props.filter.sortBy}
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{renderSortByOptions()}
</Dropdown.Menu>
<OverlayTrigger
overlay={
<Tooltip id="sort-direction-tooltip">
{props.filter.sortDirection === SortDirectionEnum.Asc
? "Ascending"
: "Descending"}
</Tooltip>
}
>
<Button
variant="secondary"
onClick={onChangeSortDirection}
>
<Icon
icon={
props.filter.sortDirection === SortDirectionEnum.Asc
? "caret-up"
: "caret-down"
}
/>
</Button>
</OverlayTrigger>
{props.filter.sortBy === "random" && (
<OverlayTrigger
overlay={
<Tooltip id="sort-reshuffle-tooltip">
Reshuffle
</Tooltip>
}
>
<Button
variant="secondary"
onClick={onReshuffleRandomSort}
>
<Icon icon="random" />
</Button>
</OverlayTrigger>
)}
</Dropdown>
</ButtonGroup>
</Col>
<Col xs="auto" className="px-1">
<Form.Control
as="select"
onChange={onChangePageSize}
value={props.filter.itemsPerPage.toString()}
className="btn-secondary"
> >
<Button variant="secondary" onClick={onReshuffleRandomSort}> {PAGE_SIZE_OPTIONS.map((s) => (
<Icon icon="random" /> <option value={s} key={s}>
</Button> {s}
</OverlayTrigger> </option>
)} ))}
</Dropdown> </Form.Control>
</ButtonGroup> </Col>
</Row>
</Col>
<AddFilter <Col sm={12} md="auto" className="my-1">
filter={props.filter} <Row className="align-items-center justify-content-center">
onAddCriterion={onAddCriterion} {maybeRenderSelectedButtons()}
onCancel={onCancelAddCriterion}
editingCriterion={editingCriterion}
/>
<ButtonGroup className="filter-item d-none d-sm-inline-flex"> <ButtonGroup className="mr-3">{renderMore()}</ButtonGroup>
{renderDisplayModeOptions()}
</ButtonGroup>
{maybeRenderZoom()} <ButtonGroup className="mr-3">
{renderDisplayModeOptions()}
</ButtonGroup>
<ButtonGroup className="filter-item d-none d-sm-inline-flex"> <ButtonGroup>{maybeRenderZoom()}</ButtonGroup>
{renderMore()} </Row>
</ButtonGroup> </Col>
</div> </div>
<div className="d-flex justify-content-center"> <div className="d-flex justify-content-center">
{renderFilterTags()} {renderFilterTags()}

View file

@ -10,6 +10,7 @@
} }
.zoom-slider { .zoom-slider {
max-width: 60px;
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
} }

View file

@ -0,0 +1,91 @@
import React, { useState } from "react";
import { Form } from "react-bootstrap";
import { useScenesDestroy } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks";
import { FormattedMessage } from "react-intl";
interface IDeleteSceneDialogProps {
selected: GQL.SlimSceneDataFragment[];
onClose: (confirmed: boolean) => void;
}
export const DeleteScenesDialog: React.FC<IDeleteSceneDialogProps> = (
props: IDeleteSceneDialogProps
) => {
const plural = props.selected.length > 1;
const singleMessageId = "deleteSceneText";
const pluralMessageId = "deleteScenesText";
const singleMessage =
"Are you sure you want to delete this scene? Unless the file is also deleted, this scene will be re-added when scan is performed.";
const pluralMessage =
"Are you sure you want to delete these scenes? Unless the files are also deleted, these scenes will be re-added when scan is performed.";
const header = plural ? "Delete Scenes" : "Delete Scene";
const toastMessage = plural ? "Deleted scenes" : "Deleted scene";
const messageId = plural ? pluralMessageId : singleMessageId;
const message = plural ? pluralMessage : singleMessage;
const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
const Toast = useToast();
const [deleteScene] = useScenesDestroy(getScenesDeleteInput());
// Network state
const [isDeleting, setIsDeleting] = useState(false);
function getScenesDeleteInput(): GQL.ScenesDestroyInput {
return {
ids: props.selected.map((scene) => scene.id),
delete_file: deleteFile,
delete_generated: deleteGenerated,
};
}
async function onDelete() {
setIsDeleting(true);
try {
await deleteScene();
Toast.success({ content: toastMessage });
} catch (e) {
Toast.error(e);
}
setIsDeleting(false);
props.onClose(true);
}
return (
<Modal
show
icon="trash-alt"
header={header}
accept={{ variant: "danger", onClick: onDelete, text: "Delete" }}
cancel={{
onClick: () => props.onClose(false),
text: "Cancel",
variant: "secondary",
}}
isRunning={isDeleting}
>
<p>
<FormattedMessage id={messageId} defaultMessage={message} />
</p>
<Form>
<Form.Check
checked={deleteFile}
label="Delete file"
onChange={() => setDeleteFile(!deleteFile)}
/>
<Form.Check
checked={deleteGenerated}
label="Delete generated supporting files"
onChange={() => setDeleteGenerated(!deleteGenerated)}
/>
</Form>
</Modal>
);
};

View file

@ -1,36 +1,38 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, Form } from "react-bootstrap"; import { Form, Col, Row } from "react-bootstrap";
import _ from "lodash"; import _ from "lodash";
import { useBulkSceneUpdate } from "src/core/StashService"; import { useBulkSceneUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StudioSelect, LoadingIndicator } from "src/components/Shared"; import { StudioSelect, Modal } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet"; import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "./SceneDetails/RatingStars";
interface IListOperationProps { interface IListOperationProps {
selected: GQL.SlimSceneDataFragment[]; selected: GQL.SlimSceneDataFragment[];
onScenesUpdated: () => void; onClose: (applied: boolean) => void;
} }
export const SceneSelectedOptions: React.FC<IListOperationProps> = ( export const EditScenesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps props: IListOperationProps
) => { ) => {
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<string>(""); const [rating, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>(); const [studioId, setStudioId] = useState<string>();
const [performerMode, setPerformerMode] = React.useState< const [performerMode, setPerformerMode] = React.useState<
GQL.BulkUpdateIdMode GQL.BulkUpdateIdMode
>(GQL.BulkUpdateIdMode.Add); >(GQL.BulkUpdateIdMode.Set);
const [performerIds, setPerformerIds] = useState<string[]>(); const [performerIds, setPerformerIds] = useState<string[]>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>( const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add GQL.BulkUpdateIdMode.Set
); );
const [tagIds, setTagIds] = useState<string[]>(); const [tagIds, setTagIds] = useState<string[]>();
const [updateScenes] = useBulkSceneUpdate(getSceneInput()); const [updateScenes] = useBulkSceneUpdate(getSceneInput());
// Network state // Network state
const [isLoading, setIsLoading] = useState(true); const [isUpdating, setIsUpdating] = useState(false);
function makeBulkUpdateIds( function makeBulkUpdateIds(
ids: string[], ids: string[],
@ -56,7 +58,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
}; };
// if rating is undefined // if rating is undefined
if (rating === "") { if (rating === undefined) {
// and all scenes have the same rating, then we are unsetting the rating. // and all scenes have the same rating, then we are unsetting the rating.
if (aggregateRating) { if (aggregateRating) {
// an undefined rating is ignored in the server, so set it to 0 instead // an undefined rating is ignored in the server, so set it to 0 instead
@ -65,7 +67,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
// otherwise not setting the rating // otherwise not setting the rating
} else { } else {
// if rating is set, then we are setting the rating for all // if rating is set, then we are setting the rating for all
sceneInput.rating = Number.parseInt(rating, 10); sceneInput.rating = rating;
} }
// if studioId is undefined // if studioId is undefined
@ -121,15 +123,15 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
} }
async function onSave() { async function onSave() {
setIsLoading(true); setIsUpdating(true);
try { try {
await updateScenes(); await updateScenes();
Toast.success({ content: "Updated scenes" }); Toast.success({ content: "Updated scenes" });
props.onClose(true);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
setIsLoading(false); setIsUpdating(false);
props.onScenesUpdated();
} }
function getRating(state: GQL.SlimSceneDataFragment[]) { function getRating(state: GQL.SlimSceneDataFragment[]) {
@ -211,14 +213,14 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
useEffect(() => { useEffect(() => {
const state = props.selected; const state = props.selected;
let updateRating = ""; let updateRating: number | undefined;
let updateStudioID: string | undefined; let updateStudioID: string | undefined;
let updatePerformerIds: string[] = []; let updatePerformerIds: string[] = [];
let updateTagIds: string[] = []; let updateTagIds: string[] = [];
let first = true; let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => { state.forEach((scene: GQL.SlimSceneDataFragment) => {
const sceneRating = scene.rating?.toString() ?? ""; const sceneRating = scene.rating;
const sceneStudioID = scene?.studio?.id; const sceneStudioID = scene?.studio?.id;
const scenePerformerIDs = (scene.performers ?? []) const scenePerformerIDs = (scene.performers ?? [])
.map((p) => p.id) .map((p) => p.id)
@ -226,14 +228,14 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort(); const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort();
if (first) { if (first) {
updateRating = sceneRating; updateRating = sceneRating ?? undefined;
updateStudioID = sceneStudioID; updateStudioID = sceneStudioID;
updatePerformerIds = scenePerformerIDs; updatePerformerIds = scenePerformerIDs;
updateTagIds = sceneTagIDs; updateTagIds = sceneTagIDs;
first = false; first = false;
} else { } else {
if (sceneRating !== updateRating) { if (sceneRating !== updateRating) {
updateRating = ""; updateRating = undefined;
} }
if (sceneStudioID !== updateStudioID) { if (sceneStudioID !== updateStudioID) {
updateStudioID = undefined; updateStudioID = undefined;
@ -256,8 +258,6 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
if (tagMode === GQL.BulkUpdateIdMode.Set) { if (tagMode === GQL.BulkUpdateIdMode.Set) {
setTagIds(updateTagIds); setTagIds(updateTagIds);
} }
setIsLoading(false);
}, [props.selected, performerMode, tagMode]); }, [props.selected, performerMode, tagMode]);
function renderMultiSelect( function renderMultiSelect(
@ -277,6 +277,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
return ( return (
<MultiSet <MultiSet
type={type} type={type}
disabled={isUpdating}
onUpdate={(items) => { onUpdate={(items) => {
const itemIDs = items.map((i) => i.id); const itemIDs = items.map((i) => i.id);
switch (type) { switch (type) {
@ -304,54 +305,60 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
); );
} }
if (isLoading) return <LoadingIndicator />;
function render() { function render() {
return ( return (
<div className="operation-container"> <Modal
<Form.Group show
controlId="rating" icon="pencil-alt"
className="operation-item rating-operation" header="Edit Scenes"
> accept={{ onClick: onSave, text: "Apply" }}
<Form.Label>Rating</Form.Label> cancel={{
<Form.Control onClick: () => props.onClose(false),
as="select" text: "Cancel",
className="btn-secondary" variant: "secondary",
value={rating} }}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => isRunning={isUpdating}
setRating(event.currentTarget.value) >
} <Form>
> <Form.Group controlId="rating" as={Row}>
{["", "1", "2", "3", "4", "5"].map((opt) => ( {FormUtils.renderLabel({
<option key={opt} value={opt}> title: "Rating",
{opt} })}
</option> <Col xs={9}>
))} <RatingStars
</Form.Control> value={rating}
</Form.Group> onSetRating={(value) => setRating(value)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" className="operation-item"> <Form.Group controlId="studio" as={Row}>
<Form.Label>Studio</Form.Label> {FormUtils.renderLabel({
<StudioSelect title: "Studio",
onSelect={(items) => setStudioId(items[0]?.id)} })}
ids={studioId ? [studioId] : []} <Col xs={9}>
/> <StudioSelect
</Form.Group> onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
isDisabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group className="operation-item" controlId="performers"> <Form.Group controlId="performers">
<Form.Label>Performers</Form.Label> <Form.Label>Performers</Form.Label>
{renderMultiSelect("performers", performerIds)} {renderMultiSelect("performers", performerIds)}
</Form.Group> </Form.Group>
<Form.Group className="operation-item" controlId="performers"> <Form.Group controlId="performers">
<Form.Label>Tags</Form.Label> <Form.Label>Tags</Form.Label>
{renderMultiSelect("tags", tagIds)} {renderMultiSelect("tags", tagIds)}
</Form.Group> </Form.Group>
</Form>
<Button variant="primary" onClick={onSave} className="apply-operation"> </Modal>
Apply
</Button>
</div>
); );
} }

View file

@ -243,7 +243,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
> >
<Form.Control <Form.Control
type="checkbox" type="checkbox"
className="scene-card-check d-none d-sm-block" className="scene-card-check"
checked={props.selected} checked={props.selected}
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)} onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => { onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {

View file

@ -5,13 +5,14 @@ import { Icon } from "src/components/Shared";
export interface IRatingStarsProps { export interface IRatingStarsProps {
value?: number; value?: number;
onSetRating?: (value?: number) => void; onSetRating?: (value?: number) => void;
disabled?: boolean;
} }
export const RatingStars: React.FC<IRatingStarsProps> = ( export const RatingStars: React.FC<IRatingStarsProps> = (
props: IRatingStarsProps props: IRatingStarsProps
) => { ) => {
const [hoverRating, setHoverRating] = useState<number | undefined>(); const [hoverRating, setHoverRating] = useState<number | undefined>();
const disabled = !props.onSetRating; const disabled = props.disabled || !props.onSetRating;
function setRating(rating: number) { function setRating(rating: number) {
if (!props.onSetRating) { if (!props.onSetRating) {
@ -109,7 +110,7 @@ export const RatingStars: React.FC<IRatingStarsProps> = (
const maxRating = 5; const maxRating = 5;
return ( return (
<div className="rating-stars"> <div className="rating-stars align-middle">
{Array.from(Array(maxRating)).map((value, index) => {Array.from(Array(maxRating)).map((value, index) =>
renderRatingButton(index + 1) renderRatingButton(index + 1)
)} )}

View file

@ -1,4 +1,4 @@
import { Tab, Nav, Dropdown, Form } from "react-bootstrap"; import { Tab, Nav, Dropdown } from "react-bootstrap";
import queryString from "query-string"; import queryString from "query-string";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useLocation, useHistory, Link } from "react-router-dom"; import { useParams, useLocation, useHistory, Link } from "react-router-dom";
@ -9,10 +9,9 @@ import {
useSceneDecrementO, useSceneDecrementO,
useSceneResetO, useSceneResetO,
useSceneGenerateScreenshot, useSceneGenerateScreenshot,
useSceneDestroy,
} from "src/core/StashService"; } from "src/core/StashService";
import { GalleryViewer } from "src/components/Galleries/GalleryViewer"; import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
import { LoadingIndicator, Icon, Modal } from "src/components/Shared"; import { LoadingIndicator, Icon } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { ScenePlayer } from "src/components/ScenePlayer"; import { ScenePlayer } from "src/components/ScenePlayer";
import { TextUtils, JWUtils } from "src/utils"; import { TextUtils, JWUtils } from "src/utils";
@ -22,6 +21,7 @@ import { SceneEditPanel } from "./SceneEditPanel";
import { SceneDetailPanel } from "./SceneDetailPanel"; import { SceneDetailPanel } from "./SceneDetailPanel";
import { OCounterButton } from "./OCounterButton"; import { OCounterButton } from "./OCounterButton";
import { SceneMoviePanel } from "./SceneMoviePanel"; import { SceneMoviePanel } from "./SceneMoviePanel";
import { DeleteScenesDialog } from "../DeleteScenesDialog";
export const Scene: React.FC = () => { export const Scene: React.FC = () => {
const { id = "new" } = useParams(); const { id = "new" } = useParams();
@ -39,10 +39,6 @@ export const Scene: React.FC = () => {
const [resetO] = useSceneResetO(scene?.id ?? "0"); const [resetO] = useSceneResetO(scene?.id ?? "0");
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteScene] = useSceneDestroy(getSceneDeleteInput());
const queryParams = queryString.parse(location.search); const queryParams = queryString.parse(location.search);
const autoplay = queryParams?.autoplay === "true"; const autoplay = queryParams?.autoplay === "true";
@ -120,54 +116,19 @@ export const Scene: React.FC = () => {
Toast.success({ content: "Generating screenshot" }); Toast.success({ content: "Generating screenshot" });
} }
function getSceneDeleteInput(): GQL.SceneDestroyInput { function onDeleteDialogClosed(deleted: boolean) {
return {
id: scene?.id ?? "0",
delete_file: deleteFile,
delete_generated: deleteGenerated,
};
}
async function onDelete() {
setIsDeleteAlertOpen(false); setIsDeleteAlertOpen(false);
setDeleteLoading(true); if (deleted) {
try { history.push("/scenes");
await deleteScene();
Toast.success({ content: "Deleted scene" });
} catch (e) {
Toast.error(e);
} }
setDeleteLoading(false);
history.push("/scenes");
} }
function renderDeleteAlert() { function maybeRenderDeleteDialog() {
return ( if (isDeleteAlertOpen && scene) {
<Modal return (
show={isDeleteAlertOpen} <DeleteScenesDialog selected={[scene]} onClose={onDeleteDialogClosed} />
icon="trash-alt" );
header="Delete Scene?" }
accept={{ variant: "danger", onClick: onDelete, text: "Delete" }}
cancel={{ onClick: () => setIsDeleteAlertOpen(false), text: "Cancel" }}
>
<p>
Are you sure you want to delete this scene? Unless the file is also
deleted, this scene will be re-added when scan is performed.
</p>
<Form>
<Form.Check
checked={deleteFile}
label="Delete file"
onChange={() => setDeleteFile(!deleteFile)}
/>
<Form.Check
checked={deleteGenerated}
label="Delete generated supporting files"
onChange={() => setDeleteGenerated(!deleteGenerated)}
/>
</Form>
</Modal>
);
} }
function renderOperations() { function renderOperations() {
@ -294,7 +255,7 @@ export const Scene: React.FC = () => {
); );
} }
if (deleteLoading || loading || !scene || !data?.findScene) { if (loading || !scene || !data?.findScene) {
return <LoadingIndicator />; return <LoadingIndicator />;
} }
@ -302,7 +263,7 @@ export const Scene: React.FC = () => {
return ( return (
<div className="row"> <div className="row">
{renderDeleteAlert()} {maybeRenderDeleteDialog()}
<div className="scene-tabs order-xl-first order-last"> <div className="scene-tabs order-xl-first order-last">
<div className="d-none d-xl-block"> <div className="d-none d-xl-block">
{scene.studio && ( {scene.studio && (

View file

@ -12,7 +12,8 @@ import { DisplayMode } from "src/models/list-filter/types";
import { WallPanel } from "../Wall/WallPanel"; import { WallPanel } from "../Wall/WallPanel";
import { SceneCard } from "./SceneCard"; import { SceneCard } from "./SceneCard";
import { SceneListTable } from "./SceneListTable"; import { SceneListTable } from "./SceneListTable";
import { SceneSelectedOptions } from "./SceneSelectedOptions"; import { EditScenesDialog } from "./EditScenesDialog";
import { DeleteScenesDialog } from "./DeleteScenesDialog";
interface ISceneList { interface ISceneList {
subComponent?: boolean; subComponent?: boolean;
@ -35,7 +36,8 @@ export const SceneList: React.FC<ISceneList> = ({
zoomable: true, zoomable: true,
otherOperations, otherOperations,
renderContent, renderContent,
renderSelectedOptions, renderEditDialog: renderEditScenesDialog,
renderDeleteDialog: renderDeleteScenesDialog,
subComponent, subComponent,
filterHook, filterHook,
}); });
@ -66,32 +68,24 @@ export const SceneList: React.FC<ISceneList> = ({
} }
} }
function renderSelectedOptions( function renderEditScenesDialog(
result: FindScenesQueryResult, selectedScenes: SlimSceneDataFragment[],
selectedIds: Set<string> onClose: (applied: boolean) => void
) { ) {
// find the selected items from the ids
if (!result.data || !result.data.findScenes) {
return undefined;
}
const { scenes } = result.data.findScenes;
const selectedScenes: SlimSceneDataFragment[] = [];
selectedIds.forEach((id) => {
const scene = scenes.find((s) => s.id === id);
if (scene) {
selectedScenes.push(scene);
}
});
return ( return (
<> <>
<SceneSelectedOptions <EditScenesDialog selected={selectedScenes} onClose={onClose} />
selected={selectedScenes} </>
onScenesUpdated={() => {}} );
/> }
function renderDeleteScenesDialog(
selectedScenes: SlimSceneDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<>
<DeleteScenesDialog selected={selectedScenes} onClose={onClose} />
</> </>
); );
} }

View file

@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { Button, Modal } from "react-bootstrap"; import { Button, Modal, Spinner } from "react-bootstrap";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
import { IconName } from "@fortawesome/fontawesome-svg-core"; import { IconName } from "@fortawesome/fontawesome-svg-core";
interface IButton { interface IButton {
text?: string; text?: string;
variant?: "danger" | "primary"; variant?: "danger" | "primary" | "secondary";
onClick?: () => void; onClick?: () => void;
} }
@ -16,6 +16,7 @@ interface IModal {
icon?: IconName; icon?: IconName;
cancel?: IButton; cancel?: IButton;
accept?: IButton; accept?: IButton;
isRunning?: boolean;
} }
const ModalComponent: React.FC<IModal> = ({ const ModalComponent: React.FC<IModal> = ({
@ -26,6 +27,7 @@ const ModalComponent: React.FC<IModal> = ({
cancel, cancel,
accept, accept,
onHide, onHide,
isRunning,
}) => ( }) => (
<Modal keyboard={false} onHide={onHide} show={show}> <Modal keyboard={false} onHide={onHide} show={show}>
<Modal.Header> <Modal.Header>
@ -37,6 +39,7 @@ const ModalComponent: React.FC<IModal> = ({
<div> <div>
{cancel ? ( {cancel ? (
<Button <Button
disabled={isRunning}
variant={cancel.variant ?? "primary"} variant={cancel.variant ?? "primary"}
onClick={cancel.onClick} onClick={cancel.onClick}
> >
@ -46,10 +49,15 @@ const ModalComponent: React.FC<IModal> = ({
"" ""
)} )}
<Button <Button
disabled={isRunning}
variant={accept?.variant ?? "primary"} variant={accept?.variant ?? "primary"}
onClick={accept?.onClick} onClick={accept?.onClick}
> >
{accept?.text ?? "Close"} {isRunning ? (
<Spinner animation="border" role="status" size="sm" />
) : (
accept?.text ?? "Close"
)}
</Button> </Button>
</div> </div>
</Modal.Footer> </Modal.Footer>

View file

@ -14,6 +14,7 @@ interface IMultiSetProps {
type: "performers" | "studios" | "tags"; type: "performers" | "studios" | "tags";
ids?: string[]; ids?: string[];
mode: GQL.BulkUpdateIdMode; mode: GQL.BulkUpdateIdMode;
disabled?: boolean;
onUpdate: (items: ValidTypes[]) => void; onUpdate: (items: ValidTypes[]) => void;
onSetMode: (mode: GQL.BulkUpdateIdMode) => void; onSetMode: (mode: GQL.BulkUpdateIdMode) => void;
} }
@ -66,6 +67,7 @@ const MultiSet: React.FunctionComponent<IMultiSetProps> = (
variant="secondary" variant="secondary"
onClick={() => props.onSetMode(nextMode())} onClick={() => props.onSetMode(nextMode())}
title={getModeText()} title={getModeText()}
disabled={props.disabled}
> >
<Icon icon={getModeIcon()} className="fa-fw" /> <Icon icon={getModeIcon()} className="fa-fw" />
</Button> </Button>
@ -73,6 +75,7 @@ const MultiSet: React.FunctionComponent<IMultiSetProps> = (
<FilterSelect <FilterSelect
type={props.type} type={props.type}
isDisabled={props.disabled}
isMulti isMulti
isClearable={false} isClearable={false}
onSelect={onUpdate} onSelect={onUpdate}

View file

@ -255,6 +255,12 @@ export const useSceneDestroy = (input: GQL.SceneDestroyInput) =>
update: () => invalidateQueries(sceneMutationImpactedQueries), update: () => invalidateQueries(sceneMutationImpactedQueries),
}); });
export const useScenesDestroy = (input: GQL.ScenesDestroyInput) =>
GQL.useScenesDestroyMutation({
variables: input,
update: () => invalidateQueries(sceneMutationImpactedQueries),
});
export const useSceneGenerateScreenshot = () => export const useSceneGenerateScreenshot = () =>
GQL.useSceneGenerateScreenshotMutation({ GQL.useSceneGenerateScreenshotMutation({
update: () => invalidateQueries(["findScenes"]), update: () => invalidateQueries(["findScenes"]),

View file

@ -4,7 +4,6 @@ import React, { useCallback, useState, useEffect } from "react";
import { ApolloError } from "apollo-client"; import { ApolloError } from "apollo-client";
import { useHistory, useLocation } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom";
import { import {
SortDirectionEnum,
SlimSceneDataFragment, SlimSceneDataFragment,
SceneMarkerDataFragment, SceneMarkerDataFragment,
GalleryDataFragment, GalleryDataFragment,
@ -33,9 +32,8 @@ import {
useFindGalleries, useFindGalleries,
useFindPerformers, useFindPerformers,
} from "src/core/StashService"; } from "src/core/StashService";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode, FilterMode } from "src/models/list-filter/types"; import { FilterMode } from "src/models/list-filter/types";
interface IListHookData { interface IListHookData {
filter: ListFilterModel; filter: ListFilterModel;
@ -52,7 +50,7 @@ interface IListHookOperation<T> {
) => void; ) => void;
} }
interface IListHookOptions<T> { interface IListHookOptions<T, E> {
subComponent?: boolean; subComponent?: boolean;
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
zoomable?: boolean; zoomable?: boolean;
@ -63,9 +61,13 @@ interface IListHookOptions<T> {
selectedIds: Set<string>, selectedIds: Set<string>,
zoomIndex: number zoomIndex: number
) => JSX.Element | undefined; ) => JSX.Element | undefined;
renderSelectedOptions?: ( renderEditDialog?: (
result: T, selected: E[],
selectedIds: Set<string> onClose: (applied: boolean) => void
) => JSX.Element | undefined;
renderDeleteDialog?: (
selected: E[],
onClose: (confirmed: boolean) => void
) => JSX.Element | undefined; ) => JSX.Element | undefined;
} }
@ -75,17 +77,20 @@ interface IDataItem {
interface IQueryResult { interface IQueryResult {
error?: ApolloError; error?: ApolloError;
loading: boolean; loading: boolean;
refetch: () => void;
} }
interface IQuery<T extends IQueryResult, T2 extends IDataItem> { interface IQuery<T extends IQueryResult, T2 extends IDataItem> {
filterMode: FilterMode; filterMode: FilterMode;
useData: (filter: ListFilterModel) => T; useData: (filter: ListFilterModel) => T;
getData: (data: T) => T2[]; getData: (data: T) => T2[];
getSelectedData: (data: T, selectedIds: Set<string>) => T2[];
getCount: (data: T) => number; getCount: (data: T) => number;
} }
const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>( const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
options: IListHookOptions<QueryResult> & IQuery<QueryResult, QueryData> options: IListHookOptions<QueryResult, QueryData> &
IQuery<QueryResult, QueryData>
): IListHookData => { ): IListHookData => {
const [interfaceState, setInterfaceState] = useInterfaceLocalForage(); const [interfaceState, setInterfaceState] = useInterfaceLocalForage();
const [forageInitialised, setForageInitialised] = useState(false); const [forageInitialised, setForageInitialised] = useState(false);
@ -97,6 +102,8 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
options.subComponent ? undefined : queryString.parse(location.search) options.subComponent ? undefined : queryString.parse(location.search)
) )
); );
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string | undefined>(); const [lastClickedId, setLastClickedId] = useState<string | undefined>();
const [zoomIndex, setZoomIndex] = useState<number>(1); const [zoomIndex, setZoomIndex] = useState<number>(1);
@ -191,79 +198,6 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
} }
} }
function onChangePageSize(pageSize: number) {
const newFilter = _.cloneDeep(filter);
newFilter.itemsPerPage = pageSize;
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function onChangeQuery(query: string) {
const newFilter = _.cloneDeep(filter);
newFilter.searchTerm = query;
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function onChangeSortDirection(sortDirection: SortDirectionEnum) {
const newFilter = _.cloneDeep(filter);
newFilter.sortDirection = sortDirection;
updateQueryParams(newFilter);
}
function onChangeSortBy(sortBy: string) {
const newFilter = _.cloneDeep(filter);
newFilter.sortBy = sortBy;
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function onSortReshuffle() {
const newFilter = _.cloneDeep(filter);
newFilter.currentPage = 1;
newFilter.randomSeed = -1;
updateQueryParams(newFilter);
}
function onChangeDisplayMode(displayMode: DisplayMode) {
const newFilter = _.cloneDeep(filter);
newFilter.displayMode = displayMode;
updateQueryParams(newFilter);
}
function onAddCriterion(criterion: Criterion, oldId?: string) {
const newFilter = _.cloneDeep(filter);
// Find if we are editing an existing criteria, then modify that. Or create a new one.
const existingIndex = newFilter.criteria.findIndex((c) => {
// If we modified an existing criterion, then look for the old id.
const id = oldId || criterion.getId();
return c.getId() === id;
});
if (existingIndex === -1) {
newFilter.criteria.push(criterion);
} else {
newFilter.criteria[existingIndex] = criterion;
}
// Remove duplicate modifiers
newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => {
return arr.map((mapObj) => mapObj.getId()).indexOf(obj.getId()) === pos;
});
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function onRemoveCriterion(removedCriterion: Criterion) {
const newFilter = _.cloneDeep(filter);
newFilter.criteria = newFilter.criteria.filter(
(criterion) => criterion.getId() !== removedCriterion.getId()
);
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function onChangePage(page: number) { function onChangePage(page: number) {
const newFilter = _.cloneDeep(filter); const newFilter = _.cloneDeep(filter);
newFilter.currentPage = page; newFilter.currentPage = page;
@ -389,26 +323,59 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
} }
} }
function onEdit() {
setIsEditDialogOpen(true);
}
function onEditDialogClosed(applied: boolean) {
if (applied) {
onSelectNone();
}
setIsEditDialogOpen(false);
// refetch
result.refetch();
}
function onDelete() {
setIsDeleteDialogOpen(true);
}
function onDeleteDialogClosed(deleted: boolean) {
if (deleted) {
onSelectNone();
}
setIsDeleteDialogOpen(false);
// refetch
result.refetch();
}
const template = ( const template = (
<div> <div>
<ListFilter <ListFilter
onChangePageSize={onChangePageSize} onFilterUpdate={updateQueryParams}
onChangeQuery={onChangeQuery}
onChangeSortDirection={onChangeSortDirection}
onChangeSortBy={onChangeSortBy}
onSortReshuffle={onSortReshuffle}
onChangeDisplayMode={onChangeDisplayMode}
onAddCriterion={onAddCriterion}
onRemoveCriterion={onRemoveCriterion}
onSelectAll={onSelectAll} onSelectAll={onSelectAll}
onSelectNone={onSelectNone} onSelectNone={onSelectNone}
zoomIndex={options.zoomable ? zoomIndex : undefined} zoomIndex={options.zoomable ? zoomIndex : undefined}
onChangeZoom={options.zoomable ? onChangeZoom : undefined} onChangeZoom={options.zoomable ? onChangeZoom : undefined}
otherOperations={otherOperations} otherOperations={otherOperations}
itemsSelected={selectedIds.size > 0}
onEdit={options.renderEditDialog ? onEdit : undefined}
onDelete={options.renderDeleteDialog ? onDelete : undefined}
filter={filter} filter={filter}
/> />
{options.renderSelectedOptions && selectedIds.size > 0 {isEditDialogOpen && options.renderEditDialog
? options.renderSelectedOptions(result, selectedIds) ? options.renderEditDialog(
options.getSelectedData(result, selectedIds),
(applied) => onEditDialogClosed(applied)
)
: undefined}
{isDeleteDialogOpen && options.renderDeleteDialog
? options.renderDeleteDialog(
options.getSelectedData(result, selectedIds),
(deleted) => onDeleteDialogClosed(deleted)
)
: undefined} : undefined}
{(result.loading || !forageInitialised) && <LoadingIndicator />} {(result.loading || !forageInitialised) && <LoadingIndicator />}
{result.error && <h1>{result.error.message}</h1>} {result.error && <h1>{result.error.message}</h1>}
@ -422,7 +389,27 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
return { filter, template, onSelectChange }; return { filter, template, onSelectChange };
}; };
export const useScenesList = (props: IListHookOptions<FindScenesQueryResult>) => const getSelectedData = <I extends IDataItem>(
result: I[],
selectedIds: Set<string>
) => {
// find the selected items from the ids
const selectedResults: I[] = [];
selectedIds.forEach((id) => {
const item = result.find((s) => s.id === id);
if (item) {
selectedResults.push(item);
}
});
return selectedResults;
};
export const useScenesList = (
props: IListHookOptions<FindScenesQueryResult, SlimSceneDataFragment>
) =>
useList<FindScenesQueryResult, SlimSceneDataFragment>({ useList<FindScenesQueryResult, SlimSceneDataFragment>({
...props, ...props,
filterMode: FilterMode.Scenes, filterMode: FilterMode.Scenes,
@ -431,10 +418,14 @@ export const useScenesList = (props: IListHookOptions<FindScenesQueryResult>) =>
result?.data?.findScenes?.scenes ?? [], result?.data?.findScenes?.scenes ?? [],
getCount: (result: FindScenesQueryResult) => getCount: (result: FindScenesQueryResult) =>
result?.data?.findScenes?.count ?? 0, result?.data?.findScenes?.count ?? 0,
getSelectedData: (
result: FindScenesQueryResult,
selectedIds: Set<string>
) => getSelectedData(result?.data?.findScenes?.scenes ?? [], selectedIds),
}); });
export const useSceneMarkersList = ( export const useSceneMarkersList = (
props: IListHookOptions<FindSceneMarkersQueryResult> props: IListHookOptions<FindSceneMarkersQueryResult, SceneMarkerDataFragment>
) => ) =>
useList<FindSceneMarkersQueryResult, SceneMarkerDataFragment>({ useList<FindSceneMarkersQueryResult, SceneMarkerDataFragment>({
...props, ...props,
@ -444,10 +435,18 @@ export const useSceneMarkersList = (
result?.data?.findSceneMarkers?.scene_markers ?? [], result?.data?.findSceneMarkers?.scene_markers ?? [],
getCount: (result: FindSceneMarkersQueryResult) => getCount: (result: FindSceneMarkersQueryResult) =>
result?.data?.findSceneMarkers?.count ?? 0, result?.data?.findSceneMarkers?.count ?? 0,
getSelectedData: (
result: FindSceneMarkersQueryResult,
selectedIds: Set<string>
) =>
getSelectedData(
result?.data?.findSceneMarkers?.scene_markers ?? [],
selectedIds
),
}); });
export const useGalleriesList = ( export const useGalleriesList = (
props: IListHookOptions<FindGalleriesQueryResult> props: IListHookOptions<FindGalleriesQueryResult, GalleryDataFragment>
) => ) =>
useList<FindGalleriesQueryResult, GalleryDataFragment>({ useList<FindGalleriesQueryResult, GalleryDataFragment>({
...props, ...props,
@ -457,10 +456,18 @@ export const useGalleriesList = (
result?.data?.findGalleries?.galleries ?? [], result?.data?.findGalleries?.galleries ?? [],
getCount: (result: FindGalleriesQueryResult) => getCount: (result: FindGalleriesQueryResult) =>
result?.data?.findGalleries?.count ?? 0, result?.data?.findGalleries?.count ?? 0,
getSelectedData: (
result: FindGalleriesQueryResult,
selectedIds: Set<string>
) =>
getSelectedData(
result?.data?.findGalleries?.galleries ?? [],
selectedIds
),
}); });
export const useStudiosList = ( export const useStudiosList = (
props: IListHookOptions<FindStudiosQueryResult> props: IListHookOptions<FindStudiosQueryResult, StudioDataFragment>
) => ) =>
useList<FindStudiosQueryResult, StudioDataFragment>({ useList<FindStudiosQueryResult, StudioDataFragment>({
...props, ...props,
@ -470,10 +477,14 @@ export const useStudiosList = (
result?.data?.findStudios?.studios ?? [], result?.data?.findStudios?.studios ?? [],
getCount: (result: FindStudiosQueryResult) => getCount: (result: FindStudiosQueryResult) =>
result?.data?.findStudios?.count ?? 0, result?.data?.findStudios?.count ?? 0,
getSelectedData: (
result: FindStudiosQueryResult,
selectedIds: Set<string>
) => getSelectedData(result?.data?.findStudios?.studios ?? [], selectedIds),
}); });
export const usePerformersList = ( export const usePerformersList = (
props: IListHookOptions<FindPerformersQueryResult> props: IListHookOptions<FindPerformersQueryResult, PerformerDataFragment>
) => ) =>
useList<FindPerformersQueryResult, PerformerDataFragment>({ useList<FindPerformersQueryResult, PerformerDataFragment>({
...props, ...props,
@ -483,9 +494,19 @@ export const usePerformersList = (
result?.data?.findPerformers?.performers ?? [], result?.data?.findPerformers?.performers ?? [],
getCount: (result: FindPerformersQueryResult) => getCount: (result: FindPerformersQueryResult) =>
result?.data?.findPerformers?.count ?? 0, result?.data?.findPerformers?.count ?? 0,
getSelectedData: (
result: FindPerformersQueryResult,
selectedIds: Set<string>
) =>
getSelectedData(
result?.data?.findPerformers?.performers ?? [],
selectedIds
),
}); });
export const useMoviesList = (props: IListHookOptions<FindMoviesQueryResult>) => export const useMoviesList = (
props: IListHookOptions<FindMoviesQueryResult, MovieDataFragment>
) =>
useList<FindMoviesQueryResult, MovieDataFragment>({ useList<FindMoviesQueryResult, MovieDataFragment>({
...props, ...props,
filterMode: FilterMode.Movies, filterMode: FilterMode.Movies,
@ -494,4 +515,8 @@ export const useMoviesList = (props: IListHookOptions<FindMoviesQueryResult>) =>
result?.data?.findMovies?.movies ?? [], result?.data?.findMovies?.movies ?? [],
getCount: (result: FindMoviesQueryResult) => getCount: (result: FindMoviesQueryResult) =>
result?.data?.findMovies?.count ?? 0, result?.data?.findMovies?.count ?? 0,
getSelectedData: (
result: FindMoviesQueryResult,
selectedIds: Set<string>
) => getSelectedData(result?.data?.findMovies?.movies ?? [], selectedIds),
}); });

View file

@ -211,38 +211,6 @@ div.dropdown-menu {
} }
} }
/* we don't want to override this for dialogs, which are light colored */
.modal {
div.react-select__control {
background-color: #fff;
border-color: inherit;
color: $dark-text;
.react-select__single-value,
.react-select__input {
color: $dark-text;
}
.react-select__multi-value {
background-color: #fff;
color: $dark-text;
}
}
div.react-select__menu {
background-color: #fff;
color: $text-color;
.react-select__option {
color: $dark-text;
}
.react-select__option--is-focused {
background-color: rgba(167, 182, 194, 0.3);
}
}
}
/* stylelint-enable selector-class-pattern */ /* stylelint-enable selector-class-pattern */
.image-thumbnail { .image-thumbnail {

View file

@ -170,10 +170,16 @@ hr {
} }
.modal { .modal {
color: $dark-text; color: $text-color;
.close {
color: $text-color;
}
.modal-header,
.modal-body, .modal-body,
.modal-footer { .modal-footer {
background-color: rgb(235, 241, 245); background-color: #30404d;
color: $text-color;
} }
} }