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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@
}
.zoom-slider {
max-width: 60px;
padding-left: 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 { 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>
);
}

View File

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

View File

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

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 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 && (

View File

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

View File

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

View File

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

View File

@ -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"]),

View File

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

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 */
.image-thumbnail {

View File

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