mirror of https://github.com/stashapp/stash.git
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:
parent
83f8bc0832
commit
455e16ece9
|
@ -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})
|
||||
}
|
||||
|
||||
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) {
|
||||
sceneGenerateScreenshot(id: $id, at: $at)
|
||||
}
|
||||
|
|
|
@ -105,6 +105,7 @@ type Mutation {
|
|||
sceneUpdate(input: SceneUpdateInput!): Scene
|
||||
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
|
||||
sceneDestroy(input: SceneDestroyInput!): Boolean!
|
||||
scenesDestroy(input: ScenesDestroyInput!): Boolean!
|
||||
scenesUpdate(input: [SceneUpdateInput!]!): [Scene]
|
||||
|
||||
"""Increments the o-counter for a scene. Returns the new value"""
|
||||
|
|
|
@ -99,6 +99,12 @@ input SceneDestroyInput {
|
|||
delete_generated: Boolean
|
||||
}
|
||||
|
||||
input ScenesDestroyInput {
|
||||
ids: [ID!]!
|
||||
delete_file: Boolean
|
||||
delete_generated: Boolean
|
||||
}
|
||||
|
||||
type FindScenesResultType {
|
||||
count: Int!
|
||||
scenes: [Scene!]!
|
||||
|
|
|
@ -429,6 +429,47 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
|
|||
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) {
|
||||
primaryTagID, _ := strconv.Atoi(input.PrimaryTagID)
|
||||
sceneID, _ := strconv.Atoi(input.SceneID)
|
||||
|
|
|
@ -3,11 +3,13 @@ import ReactMarkdown from "react-markdown";
|
|||
|
||||
const markup = `
|
||||
### ✨ New Features
|
||||
* Support deleting multiple scenes.
|
||||
* Add in-app help manual.
|
||||
* Add support for custom served folders.
|
||||
* Add support for parent/child studios.
|
||||
|
||||
### 🎨 Improvements
|
||||
* Added multi-scene edit dialog.
|
||||
* Moved images to separate tables, increasing performance.
|
||||
* Add gallery grid view.
|
||||
* Add is-missing scene filter for gallery query.
|
||||
|
|
|
@ -117,6 +117,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
|
|||
as="select"
|
||||
onChange={onChangedModifierSelect}
|
||||
value={criterion.modifier}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{criterion.modifierOptions.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
|
@ -170,6 +171,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
|
|||
as="select"
|
||||
onChange={onChangedSingleSelect}
|
||||
value={criterion.value.toString()}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{criterion.options.map((c) => (
|
||||
<option key={c.toString()} value={c.toString()}>
|
||||
|
@ -190,6 +192,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
|
|||
}
|
||||
return (
|
||||
<Form.Control
|
||||
className="btn-secondary"
|
||||
type={criterion.inputType}
|
||||
onChange={onChangedInput}
|
||||
onBlur={onBlurInput}
|
||||
|
@ -216,6 +219,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
|
|||
as="select"
|
||||
onChange={onChangedCriteriaType}
|
||||
value={criterion.type}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{props.filter.criterionOptions.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { debounce } from "lodash";
|
||||
import _, { debounce } from "lodash";
|
||||
import React, { useState } from "react";
|
||||
import { SortDirectionEnum } from "src/core/generated-graphql";
|
||||
import {
|
||||
|
@ -10,6 +10,10 @@ import {
|
|||
OverlayTrigger,
|
||||
Tooltip,
|
||||
SafeAnchorProps,
|
||||
InputGroup,
|
||||
FormControl,
|
||||
Col,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
|
||||
import { Icon } from "src/components/Shared";
|
||||
|
@ -24,20 +28,16 @@ interface IListFilterOperation {
|
|||
}
|
||||
|
||||
interface IListFilterProps {
|
||||
onChangePageSize: (pageSize: number) => 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;
|
||||
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
||||
zoomIndex?: number;
|
||||
onChangeZoom?: (zoomIndex: number) => void;
|
||||
onSelectAll?: () => void;
|
||||
onSelectNone?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
otherOperations?: IListFilterOperation[];
|
||||
filter: ListFilterModel;
|
||||
itemsSelected?: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"];
|
||||
|
@ -46,7 +46,10 @@ export const ListFilter: React.FC<IListFilterProps> = (
|
|||
props: IListFilterProps
|
||||
) => {
|
||||
const searchCallback = debounce((value: string) => {
|
||||
props.onChangeQuery(value);
|
||||
const newFilter = _.cloneDeep(props.filter);
|
||||
newFilter.searchTerm = value;
|
||||
newFilter.currentPage = 1;
|
||||
props.onFilterUpdate(newFilter);
|
||||
}, 500);
|
||||
|
||||
const [editingCriterion, setEditingCriterion] = useState<
|
||||
|
@ -55,7 +58,11 @@ export const ListFilter: React.FC<IListFilterProps> = (
|
|||
|
||||
function onChangePageSize(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
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>) {
|
||||
|
@ -63,34 +70,75 @@ export const ListFilter: React.FC<IListFilterProps> = (
|
|||
}
|
||||
|
||||
function onChangeSortDirection() {
|
||||
const newFilter = _.cloneDeep(props.filter);
|
||||
if (props.filter.sortDirection === SortDirectionEnum.Asc) {
|
||||
props.onChangeSortDirection(SortDirectionEnum.Desc);
|
||||
newFilter.sortDirection = SortDirectionEnum.Desc;
|
||||
} else {
|
||||
props.onChangeSortDirection(SortDirectionEnum.Asc);
|
||||
newFilter.sortDirection = SortDirectionEnum.Asc;
|
||||
}
|
||||
|
||||
props.onFilterUpdate(newFilter);
|
||||
}
|
||||
|
||||
function onChangeSortBy(event: React.MouseEvent<SafeAnchorProps>) {
|
||||
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() {
|
||||
props.onSortReshuffle();
|
||||
const newFilter = _.cloneDeep(props.filter);
|
||||
newFilter.currentPage = 1;
|
||||
newFilter.randomSeed = -1;
|
||||
props.onFilterUpdate(newFilter);
|
||||
}
|
||||
|
||||
function onChangeDisplayMode(displayMode: DisplayMode) {
|
||||
props.onChangeDisplayMode(displayMode);
|
||||
const newFilter = _.cloneDeep(props.filter);
|
||||
newFilter.displayMode = displayMode;
|
||||
props.onFilterUpdate(newFilter);
|
||||
}
|
||||
|
||||
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() {
|
||||
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 = "";
|
||||
function onRemoveCriterionTag(criterion?: Criterion) {
|
||||
if (!criterion) {
|
||||
|
@ -98,8 +146,9 @@ export const ListFilter: React.FC<IListFilterProps> = (
|
|||
}
|
||||
setEditingCriterion(undefined);
|
||||
removedCriterionId = criterion.getId();
|
||||
props.onRemoveCriterion(criterion);
|
||||
onRemoveCriterion(criterion);
|
||||
}
|
||||
|
||||
function onClickCriterionTag(criterion?: Criterion) {
|
||||
if (!criterion || removedCriterionId !== "") {
|
||||
return;
|
||||
|
@ -140,6 +189,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
|
|||
return "Wall";
|
||||
}
|
||||
}
|
||||
|
||||
return props.filter.displayModeOptions.map((option) => (
|
||||
<OverlayTrigger
|
||||
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() {
|
||||
if (props.onSelectAll) {
|
||||
return (
|
||||
|
@ -258,7 +320,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
|
|||
if (props.onChangeZoom) {
|
||||
return (
|
||||
<Form.Control
|
||||
className="zoom-slider col-1 d-none d-sm-block"
|
||||
className="zoom-slider d-none d-sm-inline-flex"
|
||||
type="range"
|
||||
min={0}
|
||||
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() {
|
||||
return (
|
||||
<>
|
||||
<div className="filter-container">
|
||||
<Form.Control
|
||||
placeholder="Search..."
|
||||
defaultValue={props.filter.searchTerm}
|
||||
onInput={onChangeQuery}
|
||||
className="filter-item col-5 col-sm-2 bg-secondary text-white border-secondary"
|
||||
/>
|
||||
<Form.Control
|
||||
as="select"
|
||||
onChange={onChangePageSize}
|
||||
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"
|
||||
}
|
||||
<div className="form-row align-items-center justify-content-center">
|
||||
<Col sm={12} md={6} xl={4} lg={5} className="my-1">
|
||||
<Row className="justify-content-center">
|
||||
<Col xs={6} className="px-1">
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
placeholder="Search..."
|
||||
defaultValue={props.filter.searchTerm}
|
||||
onInput={onChangeQuery}
|
||||
className="bg-secondary text-white border-secondary"
|
||||
/>
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
{props.filter.sortBy === "random" && (
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<Tooltip id="sort-reshuffle-tooltip">Reshuffle</Tooltip>
|
||||
}
|
||||
|
||||
<InputGroup.Append>
|
||||
<AddFilter
|
||||
filter={props.filter}
|
||||
onAddCriterion={onAddCriterion}
|
||||
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}>
|
||||
<Icon icon="random" />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
{PAGE_SIZE_OPTIONS.map((s) => (
|
||||
<option value={s} key={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
<AddFilter
|
||||
filter={props.filter}
|
||||
onAddCriterion={onAddCriterion}
|
||||
onCancel={onCancelAddCriterion}
|
||||
editingCriterion={editingCriterion}
|
||||
/>
|
||||
<Col sm={12} md="auto" className="my-1">
|
||||
<Row className="align-items-center justify-content-center">
|
||||
{maybeRenderSelectedButtons()}
|
||||
|
||||
<ButtonGroup className="filter-item d-none d-sm-inline-flex">
|
||||
{renderDisplayModeOptions()}
|
||||
</ButtonGroup>
|
||||
<ButtonGroup className="mr-3">{renderMore()}</ButtonGroup>
|
||||
|
||||
{maybeRenderZoom()}
|
||||
<ButtonGroup className="mr-3">
|
||||
{renderDisplayModeOptions()}
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup className="filter-item d-none d-sm-inline-flex">
|
||||
{renderMore()}
|
||||
</ButtonGroup>
|
||||
<ButtonGroup>{maybeRenderZoom()}</ButtonGroup>
|
||||
</Row>
|
||||
</Col>
|
||||
</div>
|
||||
<div className="d-flex justify-content-center">
|
||||
{renderFilterTags()}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
}
|
||||
|
||||
.zoom-slider {
|
||||
max-width: 60px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1,36 +1,38 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { Form, Col, Row } from "react-bootstrap";
|
||||
import _ from "lodash";
|
||||
import { useBulkSceneUpdate } from "src/core/StashService";
|
||||
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 { FormUtils } from "src/utils";
|
||||
import MultiSet from "../Shared/MultiSet";
|
||||
import { RatingStars } from "./SceneDetails/RatingStars";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.SlimSceneDataFragment[];
|
||||
onScenesUpdated: () => void;
|
||||
onClose: (applied: boolean) => void;
|
||||
}
|
||||
|
||||
export const SceneSelectedOptions: React.FC<IListOperationProps> = (
|
||||
export const EditScenesDialog: React.FC<IListOperationProps> = (
|
||||
props: IListOperationProps
|
||||
) => {
|
||||
const Toast = useToast();
|
||||
const [rating, setRating] = useState<string>("");
|
||||
const [rating, setRating] = useState<number>();
|
||||
const [studioId, setStudioId] = useState<string>();
|
||||
const [performerMode, setPerformerMode] = React.useState<
|
||||
GQL.BulkUpdateIdMode
|
||||
>(GQL.BulkUpdateIdMode.Add);
|
||||
>(GQL.BulkUpdateIdMode.Set);
|
||||
const [performerIds, setPerformerIds] = useState<string[]>();
|
||||
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
|
||||
GQL.BulkUpdateIdMode.Add
|
||||
GQL.BulkUpdateIdMode.Set
|
||||
);
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
|
||||
const [updateScenes] = useBulkSceneUpdate(getSceneInput());
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
function makeBulkUpdateIds(
|
||||
ids: string[],
|
||||
|
@ -56,7 +58,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
|
|||
};
|
||||
|
||||
// if rating is undefined
|
||||
if (rating === "") {
|
||||
if (rating === undefined) {
|
||||
// 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
|
||||
|
@ -65,7 +67,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
|
|||
// otherwise not setting the rating
|
||||
} else {
|
||||
// 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
|
||||
|
@ -121,15 +123,15 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
|
|||
}
|
||||
|
||||
async function onSave() {
|
||||
setIsLoading(true);
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateScenes();
|
||||
Toast.success({ content: "Updated scenes" });
|
||||
props.onClose(true);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
props.onScenesUpdated();
|
||||
setIsUpdating(false);
|
||||
}
|
||||
|
||||
function getRating(state: GQL.SlimSceneDataFragment[]) {
|
||||
|
@ -211,14 +213,14 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
|
|||
|
||||
useEffect(() => {
|
||||
const state = props.selected;
|
||||
let updateRating = "";
|
||||
let updateRating: number | undefined;
|
||||
let updateStudioID: string | undefined;
|
||||
let updatePerformerIds: string[] = [];
|
||||
let updateTagIds: string[] = [];
|
||||
let first = true;
|
||||
|
||||
state.forEach((scene: GQL.SlimSceneDataFragment) => {
|
||||
const sceneRating = scene.rating?.toString() ?? "";
|
||||
const sceneRating = scene.rating;
|
||||
const sceneStudioID = scene?.studio?.id;
|
||||
const scenePerformerIDs = (scene.performers ?? [])
|
||||
.map((p) => p.id)
|
||||
|
@ -226,14 +228,14 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
|
|||
const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort();
|
||||
|
||||
if (first) {
|
||||
updateRating = sceneRating;
|
||||
updateRating = sceneRating ?? undefined;
|
||||
updateStudioID = sceneStudioID;
|
||||
updatePerformerIds = scenePerformerIDs;
|
||||
updateTagIds = sceneTagIDs;
|
||||
first = false;
|
||||
} else {
|
||||
if (sceneRating !== updateRating) {
|
||||
updateRating = "";
|
||||
updateRating = undefined;
|
||||
}
|
||||
if (sceneStudioID !== updateStudioID) {
|
||||
updateStudioID = undefined;
|
||||
|
@ -256,8 +258,6 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
|
|||
if (tagMode === GQL.BulkUpdateIdMode.Set) {
|
||||
setTagIds(updateTagIds);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [props.selected, performerMode, tagMode]);
|
||||
|
||||
function renderMultiSelect(
|
||||
|
@ -277,6 +277,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
|
|||
return (
|
||||
<MultiSet
|
||||
type={type}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(items) => {
|
||||
const itemIDs = items.map((i) => i.id);
|
||||
switch (type) {
|
||||
|
@ -304,54 +305,60 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <LoadingIndicator />;
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<div className="operation-container">
|
||||
<Form.Group
|
||||
controlId="rating"
|
||||
className="operation-item rating-operation"
|
||||
>
|
||||
<Form.Label>Rating</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
className="btn-secondary"
|
||||
value={rating}
|
||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setRating(event.currentTarget.value)
|
||||
}
|
||||
>
|
||||
{["", "1", "2", "3", "4", "5"].map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
<Modal
|
||||
show
|
||||
icon="pencil-alt"
|
||||
header="Edit Scenes"
|
||||
accept={{ onClick: onSave, text: "Apply" }}
|
||||
cancel={{
|
||||
onClick: () => props.onClose(false),
|
||||
text: "Cancel",
|
||||
variant: "secondary",
|
||||
}}
|
||||
isRunning={isUpdating}
|
||||
>
|
||||
<Form>
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "Rating",
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<RatingStars
|
||||
value={rating}
|
||||
onSetRating={(value) => setRating(value)}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="studio" className="operation-item">
|
||||
<Form.Label>Studio</Form.Label>
|
||||
<StudioSelect
|
||||
onSelect={(items) => setStudioId(items[0]?.id)}
|
||||
ids={studioId ? [studioId] : []}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="studio" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "Studio",
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<StudioSelect
|
||||
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.Label>Performers</Form.Label>
|
||||
{renderMultiSelect("performers", performerIds)}
|
||||
</Form.Group>
|
||||
<Form.Group controlId="performers">
|
||||
<Form.Label>Performers</Form.Label>
|
||||
{renderMultiSelect("performers", performerIds)}
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="operation-item" controlId="performers">
|
||||
<Form.Label>Tags</Form.Label>
|
||||
{renderMultiSelect("tags", tagIds)}
|
||||
</Form.Group>
|
||||
|
||||
<Button variant="primary" onClick={onSave} className="apply-operation">
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
<Form.Group controlId="performers">
|
||||
<Form.Label>Tags</Form.Label>
|
||||
{renderMultiSelect("tags", tagIds)}
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -243,7 +243,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||
>
|
||||
<Form.Control
|
||||
type="checkbox"
|
||||
className="scene-card-check d-none d-sm-block"
|
||||
className="scene-card-check"
|
||||
checked={props.selected}
|
||||
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
|
||||
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
|
||||
|
|
|
@ -5,13 +5,14 @@ import { Icon } from "src/components/Shared";
|
|||
export interface IRatingStarsProps {
|
||||
value?: number;
|
||||
onSetRating?: (value?: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const RatingStars: React.FC<IRatingStarsProps> = (
|
||||
props: IRatingStarsProps
|
||||
) => {
|
||||
const [hoverRating, setHoverRating] = useState<number | undefined>();
|
||||
const disabled = !props.onSetRating;
|
||||
const disabled = props.disabled || !props.onSetRating;
|
||||
|
||||
function setRating(rating: number) {
|
||||
if (!props.onSetRating) {
|
||||
|
@ -109,7 +110,7 @@ export const RatingStars: React.FC<IRatingStarsProps> = (
|
|||
const maxRating = 5;
|
||||
|
||||
return (
|
||||
<div className="rating-stars">
|
||||
<div className="rating-stars align-middle">
|
||||
{Array.from(Array(maxRating)).map((value, index) =>
|
||||
renderRatingButton(index + 1)
|
||||
)}
|
||||
|
|
|
@ -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 React, { useEffect, useState } from "react";
|
||||
import { useParams, useLocation, useHistory, Link } from "react-router-dom";
|
||||
|
@ -9,10 +9,9 @@ import {
|
|||
useSceneDecrementO,
|
||||
useSceneResetO,
|
||||
useSceneGenerateScreenshot,
|
||||
useSceneDestroy,
|
||||
} from "src/core/StashService";
|
||||
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 { ScenePlayer } from "src/components/ScenePlayer";
|
||||
import { TextUtils, JWUtils } from "src/utils";
|
||||
|
@ -22,6 +21,7 @@ import { SceneEditPanel } from "./SceneEditPanel";
|
|||
import { SceneDetailPanel } from "./SceneDetailPanel";
|
||||
import { OCounterButton } from "./OCounterButton";
|
||||
import { SceneMoviePanel } from "./SceneMoviePanel";
|
||||
import { DeleteScenesDialog } from "../DeleteScenesDialog";
|
||||
|
||||
export const Scene: React.FC = () => {
|
||||
const { id = "new" } = useParams();
|
||||
|
@ -39,10 +39,6 @@ export const Scene: React.FC = () => {
|
|||
const [resetO] = useSceneResetO(scene?.id ?? "0");
|
||||
|
||||
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 autoplay = queryParams?.autoplay === "true";
|
||||
|
@ -120,54 +116,19 @@ export const Scene: React.FC = () => {
|
|||
Toast.success({ content: "Generating screenshot" });
|
||||
}
|
||||
|
||||
function getSceneDeleteInput(): GQL.SceneDestroyInput {
|
||||
return {
|
||||
id: scene?.id ?? "0",
|
||||
delete_file: deleteFile,
|
||||
delete_generated: deleteGenerated,
|
||||
};
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await deleteScene();
|
||||
Toast.success({ content: "Deleted scene" });
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
if (deleted) {
|
||||
history.push("/scenes");
|
||||
}
|
||||
setDeleteLoading(false);
|
||||
history.push("/scenes");
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<Modal
|
||||
show={isDeleteAlertOpen}
|
||||
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 maybeRenderDeleteDialog() {
|
||||
if (isDeleteAlertOpen && scene) {
|
||||
return (
|
||||
<DeleteScenesDialog selected={[scene]} onClose={onDeleteDialogClosed} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderOperations() {
|
||||
|
@ -294,7 +255,7 @@ export const Scene: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (deleteLoading || loading || !scene || !data?.findScene) {
|
||||
if (loading || !scene || !data?.findScene) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
|
@ -302,7 +263,7 @@ export const Scene: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div className="row">
|
||||
{renderDeleteAlert()}
|
||||
{maybeRenderDeleteDialog()}
|
||||
<div className="scene-tabs order-xl-first order-last">
|
||||
<div className="d-none d-xl-block">
|
||||
{scene.studio && (
|
||||
|
|
|
@ -12,7 +12,8 @@ import { DisplayMode } from "src/models/list-filter/types";
|
|||
import { WallPanel } from "../Wall/WallPanel";
|
||||
import { SceneCard } from "./SceneCard";
|
||||
import { SceneListTable } from "./SceneListTable";
|
||||
import { SceneSelectedOptions } from "./SceneSelectedOptions";
|
||||
import { EditScenesDialog } from "./EditScenesDialog";
|
||||
import { DeleteScenesDialog } from "./DeleteScenesDialog";
|
||||
|
||||
interface ISceneList {
|
||||
subComponent?: boolean;
|
||||
|
@ -35,7 +36,8 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
zoomable: true,
|
||||
otherOperations,
|
||||
renderContent,
|
||||
renderSelectedOptions,
|
||||
renderEditDialog: renderEditScenesDialog,
|
||||
renderDeleteDialog: renderDeleteScenesDialog,
|
||||
subComponent,
|
||||
filterHook,
|
||||
});
|
||||
|
@ -66,32 +68,24 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
}
|
||||
}
|
||||
|
||||
function renderSelectedOptions(
|
||||
result: FindScenesQueryResult,
|
||||
selectedIds: Set<string>
|
||||
function renderEditScenesDialog(
|
||||
selectedScenes: SlimSceneDataFragment[],
|
||||
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 (
|
||||
<>
|
||||
<SceneSelectedOptions
|
||||
selected={selectedScenes}
|
||||
onScenesUpdated={() => {}}
|
||||
/>
|
||||
<EditScenesDialog selected={selectedScenes} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDeleteScenesDialog(
|
||||
selectedScenes: SlimSceneDataFragment[],
|
||||
onClose: (confirmed: boolean) => void
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<DeleteScenesDialog selected={selectedScenes} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React from "react";
|
||||
import { Button, Modal } from "react-bootstrap";
|
||||
import { Button, Modal, Spinner } from "react-bootstrap";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import { IconName } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
interface IButton {
|
||||
text?: string;
|
||||
variant?: "danger" | "primary";
|
||||
variant?: "danger" | "primary" | "secondary";
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
|
@ -16,6 +16,7 @@ interface IModal {
|
|||
icon?: IconName;
|
||||
cancel?: IButton;
|
||||
accept?: IButton;
|
||||
isRunning?: boolean;
|
||||
}
|
||||
|
||||
const ModalComponent: React.FC<IModal> = ({
|
||||
|
@ -26,6 +27,7 @@ const ModalComponent: React.FC<IModal> = ({
|
|||
cancel,
|
||||
accept,
|
||||
onHide,
|
||||
isRunning,
|
||||
}) => (
|
||||
<Modal keyboard={false} onHide={onHide} show={show}>
|
||||
<Modal.Header>
|
||||
|
@ -37,6 +39,7 @@ const ModalComponent: React.FC<IModal> = ({
|
|||
<div>
|
||||
{cancel ? (
|
||||
<Button
|
||||
disabled={isRunning}
|
||||
variant={cancel.variant ?? "primary"}
|
||||
onClick={cancel.onClick}
|
||||
>
|
||||
|
@ -46,10 +49,15 @@ const ModalComponent: React.FC<IModal> = ({
|
|||
""
|
||||
)}
|
||||
<Button
|
||||
disabled={isRunning}
|
||||
variant={accept?.variant ?? "primary"}
|
||||
onClick={accept?.onClick}
|
||||
>
|
||||
{accept?.text ?? "Close"}
|
||||
{isRunning ? (
|
||||
<Spinner animation="border" role="status" size="sm" />
|
||||
) : (
|
||||
accept?.text ?? "Close"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
|
|
|
@ -14,6 +14,7 @@ interface IMultiSetProps {
|
|||
type: "performers" | "studios" | "tags";
|
||||
ids?: string[];
|
||||
mode: GQL.BulkUpdateIdMode;
|
||||
disabled?: boolean;
|
||||
onUpdate: (items: ValidTypes[]) => void;
|
||||
onSetMode: (mode: GQL.BulkUpdateIdMode) => void;
|
||||
}
|
||||
|
@ -66,6 +67,7 @@ const MultiSet: React.FunctionComponent<IMultiSetProps> = (
|
|||
variant="secondary"
|
||||
onClick={() => props.onSetMode(nextMode())}
|
||||
title={getModeText()}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<Icon icon={getModeIcon()} className="fa-fw" />
|
||||
</Button>
|
||||
|
@ -73,6 +75,7 @@ const MultiSet: React.FunctionComponent<IMultiSetProps> = (
|
|||
|
||||
<FilterSelect
|
||||
type={props.type}
|
||||
isDisabled={props.disabled}
|
||||
isMulti
|
||||
isClearable={false}
|
||||
onSelect={onUpdate}
|
||||
|
|
|
@ -255,6 +255,12 @@ export const useSceneDestroy = (input: GQL.SceneDestroyInput) =>
|
|||
update: () => invalidateQueries(sceneMutationImpactedQueries),
|
||||
});
|
||||
|
||||
export const useScenesDestroy = (input: GQL.ScenesDestroyInput) =>
|
||||
GQL.useScenesDestroyMutation({
|
||||
variables: input,
|
||||
update: () => invalidateQueries(sceneMutationImpactedQueries),
|
||||
});
|
||||
|
||||
export const useSceneGenerateScreenshot = () =>
|
||||
GQL.useSceneGenerateScreenshotMutation({
|
||||
update: () => invalidateQueries(["findScenes"]),
|
||||
|
|
|
@ -4,7 +4,6 @@ import React, { useCallback, useState, useEffect } from "react";
|
|||
import { ApolloError } from "apollo-client";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import {
|
||||
SortDirectionEnum,
|
||||
SlimSceneDataFragment,
|
||||
SceneMarkerDataFragment,
|
||||
GalleryDataFragment,
|
||||
|
@ -33,9 +32,8 @@ import {
|
|||
useFindGalleries,
|
||||
useFindPerformers,
|
||||
} from "src/core/StashService";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
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 {
|
||||
filter: ListFilterModel;
|
||||
|
@ -52,7 +50,7 @@ interface IListHookOperation<T> {
|
|||
) => void;
|
||||
}
|
||||
|
||||
interface IListHookOptions<T> {
|
||||
interface IListHookOptions<T, E> {
|
||||
subComponent?: boolean;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
zoomable?: boolean;
|
||||
|
@ -63,9 +61,13 @@ interface IListHookOptions<T> {
|
|||
selectedIds: Set<string>,
|
||||
zoomIndex: number
|
||||
) => JSX.Element | undefined;
|
||||
renderSelectedOptions?: (
|
||||
result: T,
|
||||
selectedIds: Set<string>
|
||||
renderEditDialog?: (
|
||||
selected: E[],
|
||||
onClose: (applied: boolean) => void
|
||||
) => JSX.Element | undefined;
|
||||
renderDeleteDialog?: (
|
||||
selected: E[],
|
||||
onClose: (confirmed: boolean) => void
|
||||
) => JSX.Element | undefined;
|
||||
}
|
||||
|
||||
|
@ -75,17 +77,20 @@ interface IDataItem {
|
|||
interface IQueryResult {
|
||||
error?: ApolloError;
|
||||
loading: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface IQuery<T extends IQueryResult, T2 extends IDataItem> {
|
||||
filterMode: FilterMode;
|
||||
useData: (filter: ListFilterModel) => T;
|
||||
getData: (data: T) => T2[];
|
||||
getSelectedData: (data: T, selectedIds: Set<string>) => T2[];
|
||||
getCount: (data: T) => number;
|
||||
}
|
||||
|
||||
const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
|
||||
options: IListHookOptions<QueryResult> & IQuery<QueryResult, QueryData>
|
||||
options: IListHookOptions<QueryResult, QueryData> &
|
||||
IQuery<QueryResult, QueryData>
|
||||
): IListHookData => {
|
||||
const [interfaceState, setInterfaceState] = useInterfaceLocalForage();
|
||||
const [forageInitialised, setForageInitialised] = useState(false);
|
||||
|
@ -97,6 +102,8 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
|
|||
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 [lastClickedId, setLastClickedId] = useState<string | undefined>();
|
||||
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) {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
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 = (
|
||||
<div>
|
||||
<ListFilter
|
||||
onChangePageSize={onChangePageSize}
|
||||
onChangeQuery={onChangeQuery}
|
||||
onChangeSortDirection={onChangeSortDirection}
|
||||
onChangeSortBy={onChangeSortBy}
|
||||
onSortReshuffle={onSortReshuffle}
|
||||
onChangeDisplayMode={onChangeDisplayMode}
|
||||
onAddCriterion={onAddCriterion}
|
||||
onRemoveCriterion={onRemoveCriterion}
|
||||
onFilterUpdate={updateQueryParams}
|
||||
onSelectAll={onSelectAll}
|
||||
onSelectNone={onSelectNone}
|
||||
zoomIndex={options.zoomable ? zoomIndex : undefined}
|
||||
onChangeZoom={options.zoomable ? onChangeZoom : undefined}
|
||||
otherOperations={otherOperations}
|
||||
itemsSelected={selectedIds.size > 0}
|
||||
onEdit={options.renderEditDialog ? onEdit : undefined}
|
||||
onDelete={options.renderDeleteDialog ? onDelete : undefined}
|
||||
filter={filter}
|
||||
/>
|
||||
{options.renderSelectedOptions && selectedIds.size > 0
|
||||
? options.renderSelectedOptions(result, selectedIds)
|
||||
{isEditDialogOpen && options.renderEditDialog
|
||||
? options.renderEditDialog(
|
||||
options.getSelectedData(result, selectedIds),
|
||||
(applied) => onEditDialogClosed(applied)
|
||||
)
|
||||
: undefined}
|
||||
{isDeleteDialogOpen && options.renderDeleteDialog
|
||||
? options.renderDeleteDialog(
|
||||
options.getSelectedData(result, selectedIds),
|
||||
(deleted) => onDeleteDialogClosed(deleted)
|
||||
)
|
||||
: undefined}
|
||||
{(result.loading || !forageInitialised) && <LoadingIndicator />}
|
||||
{result.error && <h1>{result.error.message}</h1>}
|
||||
|
@ -422,7 +389,27 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
|
|||
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>({
|
||||
...props,
|
||||
filterMode: FilterMode.Scenes,
|
||||
|
@ -431,10 +418,14 @@ export const useScenesList = (props: IListHookOptions<FindScenesQueryResult>) =>
|
|||
result?.data?.findScenes?.scenes ?? [],
|
||||
getCount: (result: FindScenesQueryResult) =>
|
||||
result?.data?.findScenes?.count ?? 0,
|
||||
getSelectedData: (
|
||||
result: FindScenesQueryResult,
|
||||
selectedIds: Set<string>
|
||||
) => getSelectedData(result?.data?.findScenes?.scenes ?? [], selectedIds),
|
||||
});
|
||||
|
||||
export const useSceneMarkersList = (
|
||||
props: IListHookOptions<FindSceneMarkersQueryResult>
|
||||
props: IListHookOptions<FindSceneMarkersQueryResult, SceneMarkerDataFragment>
|
||||
) =>
|
||||
useList<FindSceneMarkersQueryResult, SceneMarkerDataFragment>({
|
||||
...props,
|
||||
|
@ -444,10 +435,18 @@ export const useSceneMarkersList = (
|
|||
result?.data?.findSceneMarkers?.scene_markers ?? [],
|
||||
getCount: (result: FindSceneMarkersQueryResult) =>
|
||||
result?.data?.findSceneMarkers?.count ?? 0,
|
||||
getSelectedData: (
|
||||
result: FindSceneMarkersQueryResult,
|
||||
selectedIds: Set<string>
|
||||
) =>
|
||||
getSelectedData(
|
||||
result?.data?.findSceneMarkers?.scene_markers ?? [],
|
||||
selectedIds
|
||||
),
|
||||
});
|
||||
|
||||
export const useGalleriesList = (
|
||||
props: IListHookOptions<FindGalleriesQueryResult>
|
||||
props: IListHookOptions<FindGalleriesQueryResult, GalleryDataFragment>
|
||||
) =>
|
||||
useList<FindGalleriesQueryResult, GalleryDataFragment>({
|
||||
...props,
|
||||
|
@ -457,10 +456,18 @@ export const useGalleriesList = (
|
|||
result?.data?.findGalleries?.galleries ?? [],
|
||||
getCount: (result: FindGalleriesQueryResult) =>
|
||||
result?.data?.findGalleries?.count ?? 0,
|
||||
getSelectedData: (
|
||||
result: FindGalleriesQueryResult,
|
||||
selectedIds: Set<string>
|
||||
) =>
|
||||
getSelectedData(
|
||||
result?.data?.findGalleries?.galleries ?? [],
|
||||
selectedIds
|
||||
),
|
||||
});
|
||||
|
||||
export const useStudiosList = (
|
||||
props: IListHookOptions<FindStudiosQueryResult>
|
||||
props: IListHookOptions<FindStudiosQueryResult, StudioDataFragment>
|
||||
) =>
|
||||
useList<FindStudiosQueryResult, StudioDataFragment>({
|
||||
...props,
|
||||
|
@ -470,10 +477,14 @@ export const useStudiosList = (
|
|||
result?.data?.findStudios?.studios ?? [],
|
||||
getCount: (result: FindStudiosQueryResult) =>
|
||||
result?.data?.findStudios?.count ?? 0,
|
||||
getSelectedData: (
|
||||
result: FindStudiosQueryResult,
|
||||
selectedIds: Set<string>
|
||||
) => getSelectedData(result?.data?.findStudios?.studios ?? [], selectedIds),
|
||||
});
|
||||
|
||||
export const usePerformersList = (
|
||||
props: IListHookOptions<FindPerformersQueryResult>
|
||||
props: IListHookOptions<FindPerformersQueryResult, PerformerDataFragment>
|
||||
) =>
|
||||
useList<FindPerformersQueryResult, PerformerDataFragment>({
|
||||
...props,
|
||||
|
@ -483,9 +494,19 @@ export const usePerformersList = (
|
|||
result?.data?.findPerformers?.performers ?? [],
|
||||
getCount: (result: FindPerformersQueryResult) =>
|
||||
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>({
|
||||
...props,
|
||||
filterMode: FilterMode.Movies,
|
||||
|
@ -494,4 +515,8 @@ export const useMoviesList = (props: IListHookOptions<FindMoviesQueryResult>) =>
|
|||
result?.data?.findMovies?.movies ?? [],
|
||||
getCount: (result: FindMoviesQueryResult) =>
|
||||
result?.data?.findMovies?.count ?? 0,
|
||||
getSelectedData: (
|
||||
result: FindMoviesQueryResult,
|
||||
selectedIds: Set<string>
|
||||
) => getSelectedData(result?.data?.findMovies?.movies ?? [], selectedIds),
|
||||
});
|
||||
|
|
|
@ -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 */
|
||||
|
||||
.image-thumbnail {
|
||||
|
|
|
@ -170,10 +170,16 @@ hr {
|
|||
}
|
||||
|
||||
.modal {
|
||||
color: $dark-text;
|
||||
color: $text-color;
|
||||
|
||||
.close {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-body,
|
||||
.modal-footer {
|
||||
background-color: rgb(235, 241, 245);
|
||||
background-color: #30404d;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue