mirror of
https://github.com/stashapp/stash.git
synced 2026-04-11 17:40:57 +02:00
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:
parent
f52bfae8ac
commit
eaa23240f7
14 changed files with 544 additions and 170 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
43
ui/v2.5/src/components/Tagger/IncludeButton.tsx
Normal file
43
ui/v2.5/src/components/Tagger/IncludeButton.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue