Tagger UI improvements (#1605)

* Choose fields to tag
* Use check-circle for success icon
* Maintain fingerprint results
* Show scene details
* Maintain whitespace in TruncatedText
* Use undefine for img when not setting
This commit is contained in:
WithoutPants 2021-08-04 09:44:51 +10:00 committed by GitHub
parent f52bfae8ac
commit eaa23240f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 544 additions and 170 deletions

View file

@ -1,7 +1,9 @@
### ✨ New Features
* Support excluding fields and editing tags when saving from scene tagger view. ([#1605](https://github.com/stashapp/stash/pull/1605))
* Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568))
### 🎨 Improvements
* Show current scene details in tagger view. ([#1605](https://github.com/stashapp/stash/pull/1605))
* Removed stripes and added background colour to default performer images (old images can be downloaded from the PR link). ([#1609](https://github.com/stashapp/stash/pull/1609))
* Added pt-BR language option. ([#1587](https://github.com/stashapp/stash/pull/1587))
* Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578))

View file

@ -2,10 +2,13 @@ import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp, SizeProp, library } from "@fortawesome/fontawesome-svg-core";
import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons";
import { faStar as farStar } from "@fortawesome/free-regular-svg-icons";
import {
faCheckCircle as farCheckCircle,
faStar as farStar,
} from "@fortawesome/free-regular-svg-icons";
// need these to use far and fas styles of stars
library.add(fasStar, farStar);
library.add(fasStar, farStar, farCheckCircle);
interface IIcon {
icon: IconProp;

View file

@ -6,7 +6,7 @@ interface ISuccessIconProps {
}
const SuccessIcon: React.FC<ISuccessIconProps> = ({ className }) => (
<Icon icon="check" className={className} color="#0f9960" />
<Icon icon={["far", "check-circle"]} className={className} color="#0f9960" />
);
export default SuccessIcon;

View file

@ -180,9 +180,11 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active {
-webkit-box-orient: vertical;
display: -webkit-box;
overflow: hidden;
white-space: pre-line;
&-tooltip .tooltip-inner {
max-width: 300px;
white-space: pre-line;
}
}

View file

@ -11,7 +11,7 @@ import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "src/components/Shared";
import { useConfiguration } from "src/core/StashService";
import { ITaggerConfig, ParseMode } from "./constants";
import { ITaggerConfig, ParseMode, TagOperation } from "./constants";
interface IConfigProps {
show: boolean;
@ -118,7 +118,7 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setConfig({
...config,
tagOperation: e.currentTarget.value,
tagOperation: e.currentTarget.value as TagOperation,
})
}
disabled={!config.setTags}

View file

@ -0,0 +1,43 @@
import React from "react";
import { Button } from "react-bootstrap";
import { Icon } from "../Shared";
interface IIncludeExcludeButton {
exclude: boolean;
disabled?: boolean;
setExclude: (v: boolean) => void;
}
export const IncludeExcludeButton: React.FC<IIncludeExcludeButton> = ({
exclude,
disabled,
setExclude,
}) => (
<Button
onClick={() => setExclude(!exclude)}
disabled={disabled}
variant="minimal"
className={`${
exclude ? "text-danger" : "text-success"
} include-exclude-button`}
>
<Icon className="fa-fw" icon={exclude ? "times" : "check"} />
</Button>
);
interface IOptionalField {
exclude: boolean;
disabled?: boolean;
setExclude: (v: boolean) => void;
}
export const OptionalField: React.FC<IOptionalField> = ({
exclude,
setExclude,
children,
}) => (
<div className={`optional-field ${!exclude ? "included" : "excluded"}`}>
<IncludeExcludeButton exclude={exclude} setExclude={setExclude} />
{children}
</div>
);

View file

@ -3,12 +3,13 @@ import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import cx from "classnames";
import { SuccessIcon, PerformerSelect } from "src/components/Shared";
import { PerformerSelect } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql";
import { ValidTypes } from "src/components/Shared/Select";
import { IStashBoxPerformer, filterPerformer } from "./utils";
import PerformerModal from "./PerformerModal";
import { OptionalField } from "./IncludeButton";
export type PerformerOperation =
| { type: "create"; data: IStashBoxPerformer }
@ -121,12 +122,22 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
<b className="ml-2">{performer.name}</b>
</div>
<span className="ml-auto">
<SuccessIcon />
<FormattedMessage id="component_tagger.verb_matched" />:
<OptionalField
exclude={selectedSource === "skip"}
setExclude={(v) =>
v ? handlePerformerSkip() : setSelectedSource("existing")
}
>
<div>
<span className="mr-2">
<FormattedMessage id="component_tagger.verb_matched" />:
</span>
<b className="col-3 text-right">
{stashData.findPerformers.performers[0].name}
</b>
</div>
</OptionalField>
</span>
<b className="col-3 text-right">
{stashData.findPerformers.performers[0].name}
</b>
</div>
);
}

View file

@ -1,18 +1,24 @@
import React, { useState, useReducer } from "react";
import React, { useState, useReducer, useEffect, useCallback } from "react";
import cx from "classnames";
import { Button } from "react-bootstrap";
import { Badge, Button, Col, Form, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import {
Icon,
LoadingIndicator,
SuccessIcon,
TagSelect,
TruncatedText,
} from "src/components/Shared";
import { FormUtils } from "src/utils";
import { uniq } from "lodash";
import PerformerResult, { PerformerOperation } from "./PerformerResult";
import StudioResult, { StudioOperation } from "./StudioResult";
import { IStashBoxScene } from "./utils";
import { useTagScene } from "./taggerService";
import { TagOperation } from "./constants";
import { OptionalField } from "./IncludeButton";
const getDurationStatus = (
scene: IStashBoxScene,
@ -95,10 +101,13 @@ interface IStashSearchResultProps {
showMales: boolean;
setScene: (scene: GQL.SlimSceneDataFragment) => void;
setCoverImage: boolean;
tagOperation: string;
tagOperation: TagOperation;
setTags: boolean;
endpoint: string;
queueFingerprintSubmission: (sceneId: string, endpoint: string) => void;
createNewTag: (toCreate: GQL.ScrapedSceneTag) => void;
excludedFields: Record<string, boolean>;
setExcludedFields: (v: Record<string, boolean>) => void;
}
interface IPerformerReducerAction {
@ -123,9 +132,30 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
setTags,
endpoint,
queueFingerprintSubmission,
createNewTag,
excludedFields,
setExcludedFields,
}) => {
const getInitialTags = useCallback(() => {
const stashSceneTags = stashScene.tags.map((t) => t.id);
if (!setTags) {
return stashSceneTags;
}
const newTags = scene.tags.filter((t) => t.id).map((t) => t.id!);
if (tagOperation === "overwrite") {
return newTags;
}
if (tagOperation === "merge") {
return uniq(stashSceneTags.concat(newTags));
}
throw new Error("unexpected tagOperation");
}, [stashScene, tagOperation, scene, setTags]);
const [studio, setStudio] = useState<StudioOperation>();
const [performers, dispatch] = useReducer(performerReducer, {});
const [tagIDs, setTagIDs] = useState<string[]>(getInitialTags());
const [saveState, setSaveState] = useState<string>("");
const [error, setError] = useState<{ message?: string; details?: string }>(
{}
@ -133,6 +163,10 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
const intl = useIntl();
useEffect(() => {
setTagIDs(getInitialTags());
}, [setTags, tagOperation, getInitialTags]);
const tagScene = useTagScene(
{
tagOperation,
@ -143,12 +177,18 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
setError
);
function getExcludedFields() {
return Object.keys(excludedFields).filter((f) => excludedFields[f]);
}
async function handleSave() {
const updatedScene = await tagScene(
stashScene,
scene,
studio,
performers,
tagIDs,
getExcludedFields(),
endpoint
);
@ -162,6 +202,12 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
performerID: string
) => dispatch({ id: performerID, data: performerData });
const setExcludedField = (name: string, value: boolean) =>
setExcludedFields({
...excludedFields,
[name]: value,
});
const classname = cx("row mx-0 mt-2 search-result", {
"selected-result": isActive,
});
@ -190,38 +236,104 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
? `${endpointBase}scenes/${scene.stash_id}`
: "";
// constants to get around dot-notation eslint rule
const fields = {
cover_image: "cover_image",
title: "title",
date: "date",
url: "url",
details: "details",
};
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<li
className={classname}
className={`${classname} ${isActive && "active"}`}
key={scene.stash_id}
onClick={() => !isActive && setActive()}
>
<div className="col-lg-6">
<div className="row">
<a href={stashBoxURL} target="_blank" rel="noopener noreferrer">
<img
src={scene.images[0]}
alt=""
className="align-self-center scene-image"
/>
</a>
<div className="scene-image-container">
<OptionalField
exclude={excludedFields[fields.cover_image] || !setCoverImage}
disabled={!setCoverImage}
setExclude={(v) => setExcludedField(fields.cover_image, v)}
>
<a href={stashBoxURL} target="_blank" rel="noopener noreferrer">
<img
src={scene.images[0]}
alt=""
className="align-self-center scene-image"
/>
</a>
</OptionalField>
</div>
<div className="d-flex flex-column justify-content-center scene-metadata">
<h4>{sceneTitle}</h4>
<h5>
{scene?.studio?.name} {scene?.date}
</h5>
<div>
{intl.formatMessage(
{ id: "countables.performers" },
{ count: scene?.performers?.length }
)}
: {scene?.performers?.map((p) => p.name).join(", ")}
</div>
<h4>
<OptionalField
exclude={excludedFields[fields.title]}
setExclude={(v) => setExcludedField(fields.title, v)}
>
{sceneTitle}
</OptionalField>
</h4>
{!isActive && (
<>
<h5>
{scene?.studio?.name} {scene?.date}
</h5>
<div>
{intl.formatMessage(
{ id: "countables.performers" },
{ count: scene?.performers?.length }
)}
: {scene?.performers?.map((p) => p.name).join(", ")}
</div>
</>
)}
{isActive && scene.date && (
<h5>
<OptionalField
exclude={excludedFields[fields.date]}
setExclude={(v) => setExcludedField(fields.date, v)}
>
{scene.date}
</OptionalField>
</h5>
)}
{getDurationStatus(scene, stashScene.file?.duration)}
{getFingerprintStatus(scene, stashScene)}
</div>
</div>
{isActive && (
<div className="d-flex flex-column">
{scene.url && (
<div className="scene-details">
<OptionalField
exclude={excludedFields[fields.url]}
setExclude={(v) => setExcludedField(fields.url, v)}
>
<a href={scene.url} target="_blank" rel="noopener noreferrer">
{scene.url}
</a>
</OptionalField>
</div>
)}
{scene.details && (
<div className="scene-details">
<OptionalField
exclude={excludedFields[fields.details]}
setExclude={(v) => setExcludedField(fields.details, v)}
>
<TruncatedText text={scene.details ?? ""} lineCount={3} />
</OptionalField>
</div>
)}
</div>
)}
</div>
{isActive && (
<div className="col-lg-6">
@ -238,6 +350,43 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
endpoint={endpoint}
/>
))}
<div className="mt-2">
<div>
<Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({
title: `${intl.formatMessage({ id: "tags" })}:`,
})}
<Col sm={9} xl={12}>
<TagSelect
isDisabled={!setTags}
isMulti
onSelect={(items) => {
setTagIDs(items.map((i) => i.id));
}}
ids={tagIDs}
/>
</Col>
</Form.Group>
</div>
{setTags &&
scene.tags
.filter((t) => !t.id)
.map((t) => (
<Badge
className="tag-item"
variant="secondary"
key={t.name}
onClick={() => {
createNewTag(t);
}}
>
{t.name}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon="plus" />
</Button>
</Badge>
))}
</div>
<div className="row no-gutters mt-2 align-items-center justify-content-end">
{error.message && (
<strong className="mt-1 mr-2 text-danger text-right">

View file

@ -3,10 +3,11 @@ import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames";
import { SuccessIcon, Modal, StudioSelect } from "src/components/Shared";
import { Modal, StudioSelect } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql";
import { ValidTypes } from "src/components/Shared/Select";
import { IStashBoxStudio } from "./utils";
import { OptionalField } from "./IncludeButton";
export type StudioOperation =
| { type: "create"; data: IStashBoxStudio }
@ -103,12 +104,22 @@ const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
:<b className="ml-2">{studio?.name}</b>
</div>
<span className="ml-auto">
<SuccessIcon className="mr-2" />
Matched:
<OptionalField
exclude={selectedSource === "skip"}
setExclude={(v) =>
v ? handleStudioSkip() : setSelectedSource("existing")
}
>
<div>
<span className="mr-2">
<FormattedMessage id="component_tagger.verb_matched" />:
</span>
<b className="col-3 text-right">
{stashIDData.findStudios.studios[0].name}
</b>
</div>
</OptionalField>
</span>
<b className="col-3 text-right">
{stashIDData.findStudios.studios[0].name}
</b>
</div>
);
}

View file

@ -4,9 +4,10 @@ import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { LoadingIndicator } from "src/components/Shared";
import { stashBoxSceneBatchQuery } from "src/core/StashService";
import { stashBoxSceneBatchQuery, useTagCreate } from "src/core/StashService";
import { SceneQueue } from "src/models/sceneQueue";
import { useToast } from "src/hooks";
import { uniqBy } from "lodash";
import { ITaggerConfig } from "./constants";
import { selectScenes, IStashBoxScene } from "./utils";
@ -76,6 +77,9 @@ export const TaggerList: React.FC<ITaggerListProps> = ({
fingerprintQueue,
}) => {
const intl = useIntl();
const Toast = useToast();
const [createTag] = useTagCreate();
const [fingerprintError, setFingerprintError] = useState("");
const [loading, setLoading] = useState(false);
const inputForm = useRef<HTMLFormElement>(null);
@ -206,6 +210,53 @@ export const TaggerList: React.FC<ITaggerListProps> = ({
setFingerprintError("");
};
async function createNewTag(toCreate: GQL.ScrapedSceneTag) {
const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" };
try {
const result = await createTag({
variables: {
input: tagInput,
},
});
const tagID = result.data?.tagCreate?.id;
const newSearchResults = { ...searchResults };
// add the id to the existing search results
Object.keys(newSearchResults).forEach((k) => {
const searchResult = searchResults[k];
newSearchResults[k] = searchResult.map((r) => {
return {
...r,
tags: r.tags.map((t) => {
if (t.name === toCreate.name) {
return {
...t,
id: tagID,
};
}
return t;
}),
};
});
});
setSearchResults(newSearchResults);
Toast.success({
content: (
<span>
Created tag: <b>{toCreate.name}</b>
</span>
),
});
} catch (e) {
Toast.error(e);
}
}
const canFingerprintSearch = () =>
scenes.some(
(s) =>
@ -267,6 +318,7 @@ export const TaggerList: React.FC<ITaggerListProps> = ({
doSceneQuery={(queryString) => doSceneQuery(scene.id, queryString)}
tagScene={handleTaggedScene}
searchResult={searchResult}
createNewTag={createNewTag}
/>
);
});
@ -274,6 +326,7 @@ export const TaggerList: React.FC<ITaggerListProps> = ({
return (
<Card className="tagger-table">
<div className="tagger-table-header d-flex flex-nowrap align-items-center">
{/* TODO - sources select goes here */}
<b className="ml-auto mr-2 text-danger">{fingerprintError}</b>
<div className="mr-2">
{(getFingerprintCount() > 0 || hideUnmatched) && (

View file

@ -1,11 +1,12 @@
import React, { useRef, useState } from "react";
import { Button, Form, InputGroup } from "react-bootstrap";
import { Button, Collapse, Form, InputGroup } from "react-bootstrap";
import { Link } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { ScenePreview } from "src/components/Scenes/SceneCard";
import * as GQL from "src/core/generated-graphql";
import { TruncatedText } from "src/components/Shared";
import { Icon, TagLink, TruncatedText } from "src/components/Shared";
import { sortPerformers } from "src/core/performers";
import StashSearchResult from "./StashSearchResult";
import { ITaggerConfig } from "./constants";
import {
@ -15,6 +16,68 @@ import {
prepareQueryString,
} from "./utils";
interface ITaggerSceneDetails {
scene: GQL.SlimSceneDataFragment;
}
const TaggerSceneDetails: React.FC<ITaggerSceneDetails> = ({ scene }) => {
const [open, setOpen] = useState(false);
const sorted = sortPerformers(scene.performers);
return (
<div className="scene-details">
<Collapse in={open}>
<div className="row">
<div className="col col-lg-6">
<h4>{scene.title}</h4>
<h5>
{scene.studio?.name}
{scene.studio?.name && scene.date && ``}
{scene.date}
</h5>
<TruncatedText text={scene.details ?? ""} lineCount={3} />
</div>
<div className="col col-lg-6">
<div>
{sorted.map((performer) => (
<div className="performer-tag-container row" key={performer.id}>
<Link
to={`/performers/${performer.id}`}
className="performer-tag col m-auto zoom-2"
>
<img
className="image-thumbnail"
alt={performer.name ?? ""}
src={performer.image_path ?? ""}
/>
</Link>
<TagLink
key={performer.id}
performer={performer}
className="d-block"
/>
</div>
))}
</div>
<div>
{scene.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} />
))}
</div>
</div>
</div>
</Collapse>
<Button
onClick={() => setOpen(!open)}
className="minimal collapse-button"
size="lg"
>
<Icon icon={open ? "chevron-up" : "chevron-down"} />
</Button>
</div>
);
};
export interface ISearchResult {
results?: IStashBoxScene[];
error?: string;
@ -32,6 +95,7 @@ export interface ITaggerScene {
tagScene: (scene: Partial<GQL.SlimSceneDataFragment>) => void;
endpoint: string;
queueFingerprintSubmission: (sceneId: string, endpoint: string) => void;
createNewTag: (toCreate: GQL.ScrapedSceneTag) => void;
}
export const TaggerScene: React.FC<ITaggerScene> = ({
@ -46,8 +110,10 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
tagScene,
endpoint,
queueFingerprintSubmission,
createNewTag,
}) => {
const [selectedResult, setSelectedResult] = useState<number>(0);
const [excluded, setExcluded] = useState<Record<string, boolean>>({});
const queryString = useRef<string>("");
@ -193,6 +259,9 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
setScene={tagScene}
endpoint={endpoint}
queueFingerprintSubmission={queueFingerprintSubmission}
createNewTag={createNewTag}
excludedFields={excluded}
setExcludedFields={(v) => setExcluded(v)}
/>
)
)}
@ -226,6 +295,7 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
{renderMainContent()}
<div className="sub-content text-right">{renderSubContent()}</div>
</div>
<TaggerSceneDetails scene={scene} />
</div>
{renderSearchResult()}
</div>

View file

@ -24,13 +24,14 @@ export const initialConfig: ITaggerConfig = {
};
export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata";
export type TagOperation = "merge" | "overwrite";
export interface ITaggerConfig {
blacklist: string[];
showMales: boolean;
mode: ParseMode;
setCoverImage: boolean;
setTags: boolean;
tagOperation: string;
tagOperation: TagOperation;
selectedEndpoint?: string;
fingerprintQueue: Record<string, string[]>;
excludedPerformerFields?: string[];

View file

@ -26,6 +26,13 @@
background-color: #495b68;
border-radius: 3px;
padding: 1rem;
.scene-details {
align-items: center;
display: flex;
flex-direction: column;
width: 100%;
}
}
.search-result {
@ -58,12 +65,11 @@
max-width: 14rem;
min-width: 168px;
object-fit: contain;
padding: 0 1rem;
padding-right: 1rem;
}
.scene-metadata {
margin-right: 1rem;
width: calc(100% - 17rem);
}
.select-existing {
@ -204,3 +210,48 @@
width: 12px;
}
}
.include-exclude-button {
display: inline-block;
margin-right: 0.38em;
padding: 0.2em;
}
li:not(.active) {
.include-exclude-button {
// visibility: hidden;
display: none;
}
.scene-image {
padding-left: 1rem;
}
}
.optional-field {
align-items: center;
display: flex;
flex-direction: row;
}
li.active .optional-field.excluded,
li.active .optional-field.excluded .scene-link {
color: #bfccd6;
text-decoration: line-through;
img {
opacity: 0.5;
}
}
li.active .scene-image-container {
margin-left: 1rem;
}
.scene-details {
margin-top: 0.5rem;
> .row {
width: 100%;
}
}

View file

@ -1,10 +1,8 @@
import * as GQL from "src/core/generated-graphql";
import { blobToBase64 } from "base64-blob";
import { uniq } from "lodash";
import {
useCreatePerformer,
useCreateStudio,
useCreateTag,
useUpdatePerformerStashID,
useUpdateStudioStashID,
} from "./queries";
@ -25,7 +23,6 @@ export function useTagScene(
) {
const createStudio = useCreateStudio();
const createPerformer = useCreatePerformer();
const createTag = useCreateTag();
const updatePerformerStashID = useUpdatePerformerStashID();
const updateStudioStashID = useUpdateStudioStashID();
const [updateScene] = GQL.useSceneUpdateMutation({
@ -41,67 +38,76 @@ export function useTagScene(
},
});
const { data: allTags } = GQL.useAllTagsForFilterQuery();
const handleSave = async (
stashScene: GQL.SlimSceneDataFragment,
scene: IStashBoxScene,
studio: StudioOperation | undefined,
performers: IPerformerOperations,
tagIDs: string[],
excludedFields: string[],
endpoint: string
) => {
function resolveField<T>(field: string, stashField: T, remoteField: T) {
if (excludedFields.includes(field)) {
return stashField;
}
return remoteField;
}
setError({});
let performerIDs = [];
let studioID = null;
if (!studio) return;
if (studio) {
if (studio.type === "create") {
setSaveState("Creating studio");
const newStudio = {
name: studio.data.name,
stash_ids: [
{
endpoint,
stash_id: scene.studio.stash_id,
},
],
url: studio.data.url,
};
const studioCreateResult = await createStudio(
newStudio,
scene.studio.stash_id
);
if (studio.type === "create") {
setSaveState("Creating studio");
const newStudio = {
name: studio.data.name,
stash_ids: [
{
endpoint,
stash_id: scene.studio.stash_id,
},
],
url: studio.data.url,
};
const studioCreateResult = await createStudio(
newStudio,
scene.studio.stash_id
);
if (!studioCreateResult?.data?.studioCreate) {
setError({
message: `Failed to save studio "${newStudio.name}"`,
details: studioCreateResult?.errors?.[0].message,
});
return setSaveState("");
if (!studioCreateResult?.data?.studioCreate) {
setError({
message: `Failed to save studio "${newStudio.name}"`,
details: studioCreateResult?.errors?.[0].message,
});
return setSaveState("");
}
studioID = studioCreateResult.data.studioCreate.id;
} else if (studio.type === "update") {
setSaveState("Saving studio stashID");
const res = await updateStudioStashID(studio.data, [
...studio.data.stash_ids,
{ stash_id: scene.studio.stash_id, endpoint },
]);
if (!res?.data?.studioUpdate) {
setError({
message: `Failed to save stashID to studio "${studio.data.name}"`,
details: res?.errors?.[0].message,
});
return setSaveState("");
}
studioID = res.data.studioUpdate.id;
} else if (studio.type === "existing") {
studioID = studio.data.id;
} else if (studio.type === "skip") {
studioID = stashScene.studio?.id;
}
studioID = studioCreateResult.data.studioCreate.id;
} else if (studio.type === "update") {
setSaveState("Saving studio stashID");
const res = await updateStudioStashID(studio.data, [
...studio.data.stash_ids,
{ stash_id: scene.studio.stash_id, endpoint },
]);
if (!res?.data?.studioUpdate) {
setError({
message: `Failed to save stashID to studio "${studio.data.name}"`,
details: res?.errors?.[0].message,
});
return setSaveState("");
}
studioID = res.data.studioUpdate.id;
} else if (studio.type === "existing") {
studioID = studio.data.id;
} else if (studio.type === "skip") {
studioID = stashScene.studio?.id;
}
setSaveState("Saving performers");
let failed = false;
performerIDs = await Promise.all(
Object.keys(performers).map(async (stashID) => {
const performer = performers[stashID];
@ -157,6 +163,7 @@ export function useTagScene(
message: `Failed to save performer "${performerInput.name}"`,
details: res?.errors?.[0].message,
});
failed = true;
return null;
}
performerID = res.data?.performerCreate.id;
@ -174,86 +181,57 @@ export function useTagScene(
})
);
if (!performerIDs.some((id) => !id)) {
setSaveState("Updating scene");
const imgurl = scene.images[0];
let imgData = null;
if (imgurl && options.setCoverImage) {
const img = await fetch(imgurl, {
mode: "cors",
cache: "no-store",
});
if (img.status === 200) {
const blob = await img.blob();
// Sanity check on image size since bad images will fail
if (blob.size > 10000) imgData = await blobToBase64(blob);
}
}
let updatedTags = stashScene?.tags?.map((t) => t.id) ?? [];
if (options.setTags) {
const newTagIDs = options.tagOperation === "merge" ? updatedTags : [];
const tags = scene.tags ?? [];
if (tags.length > 0) {
const tagDict: Record<string, string> = (allTags?.allTags ?? [])
.filter((t) => t.name)
.reduce(
(dict, t) => ({ ...dict, [t.name.toLowerCase()]: t.id }),
{}
);
const newTags: string[] = [];
tags.forEach((tag) => {
if (tagDict[tag.name.toLowerCase()])
newTagIDs.push(tagDict[tag.name.toLowerCase()]);
else newTags.push(tag.name);
});
const createdTags = await Promise.all(
newTags.map((tag) => createTag(tag))
);
createdTags.forEach((createdTag) => {
if (createdTag?.data?.tagCreate?.id)
newTagIDs.push(createdTag.data.tagCreate.id);
});
}
updatedTags = uniq(newTagIDs);
}
const performer_ids = performerIDs.filter(
(id) => id !== "Skip"
) as string[];
const sceneUpdateResult = await updateScene({
variables: {
input: {
id: stashScene.id ?? "",
title: scene.title,
details: scene.details,
date: scene.date,
performer_ids:
performer_ids.length === 0
? stashScene.performers.map((p) => p.id)
: performer_ids,
studio_id: studioID,
cover_image: imgData,
url: scene.url,
tag_ids: updatedTags,
stash_ids: [
...(stashScene?.stash_ids ?? []),
{
endpoint,
stash_id: scene.stash_id,
},
],
},
},
});
setSaveState("");
return sceneUpdateResult?.data?.sceneUpdate;
if (failed) {
return setSaveState("");
}
setSaveState("Updating scene");
const imgurl = scene.images[0];
let imgData;
if (imgurl && options.setCoverImage) {
const img = await fetch(imgurl, {
mode: "cors",
cache: "no-store",
});
if (img.status === 200) {
const blob = await img.blob();
// Sanity check on image size since bad images will fail
if (blob.size > 10000) imgData = await blobToBase64(blob);
}
}
const performer_ids = performerIDs.filter(
(id) => id !== "Skip"
) as string[];
const sceneUpdateResult = await updateScene({
variables: {
input: {
id: stashScene.id ?? "",
title: resolveField("title", stashScene.title, scene.title),
details: resolveField("details", stashScene.details, scene.details),
date: resolveField("date", stashScene.date, scene.date),
performer_ids:
performer_ids.length === 0
? stashScene.performers.map((p) => p.id)
: performer_ids,
studio_id: studioID,
cover_image: resolveField("cover_image", undefined, imgData),
url: resolveField("url", stashScene.url, scene.url),
tag_ids: tagIDs,
stash_ids: [
...(stashScene?.stash_ids ?? []),
{
endpoint,
stash_id: scene.stash_id,
},
],
},
},
});
setSaveState("");
return sceneUpdateResult?.data?.sceneUpdate;
};
return handleSave;