mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
* Add reload scraper option to performer details * Add scraper reload to scene edit page * Show scene scraper menu when no queryable scrapers * Add 0.3 changelog
557 lines
15 KiB
TypeScript
557 lines
15 KiB
TypeScript
/* eslint-disable react/no-this-in-sfc */
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import { Button, Dropdown, DropdownButton, Form, Table } from "react-bootstrap";
|
|
import * as GQL from "src/core/generated-graphql";
|
|
import {
|
|
queryScrapeScene,
|
|
queryScrapeSceneURL,
|
|
useListSceneScrapers,
|
|
useSceneUpdate,
|
|
useSceneDestroy,
|
|
mutateReloadScrapers,
|
|
} from "src/core/StashService";
|
|
import {
|
|
PerformerSelect,
|
|
TagSelect,
|
|
StudioSelect,
|
|
SceneGallerySelect,
|
|
Modal,
|
|
Icon,
|
|
LoadingIndicator,
|
|
ImageInput,
|
|
} from "src/components/Shared";
|
|
import { useToast } from "src/hooks";
|
|
import { ImageUtils, TableUtils } from "src/utils";
|
|
import { MovieSelect } from "src/components/Shared/Select";
|
|
import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable";
|
|
|
|
interface IProps {
|
|
scene: GQL.SceneDataFragment;
|
|
onUpdate: (scene: GQL.SceneDataFragment) => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|
const Toast = useToast();
|
|
const [title, setTitle] = useState<string>();
|
|
const [details, setDetails] = useState<string>();
|
|
const [url, setUrl] = useState<string>();
|
|
const [date, setDate] = useState<string>();
|
|
const [rating, setRating] = useState<number>();
|
|
const [galleryId, setGalleryId] = useState<string>();
|
|
const [studioId, setStudioId] = useState<string>();
|
|
const [performerIds, setPerformerIds] = useState<string[]>();
|
|
const [movieIds, setMovieIds] = useState<string[] | undefined>(undefined);
|
|
const [movieSceneIndexes, setMovieSceneIndexes] = useState<
|
|
MovieSceneIndexMap
|
|
>(new Map());
|
|
const [tagIds, setTagIds] = useState<string[]>();
|
|
const [coverImage, setCoverImage] = useState<string>();
|
|
|
|
const Scrapers = useListSceneScrapers();
|
|
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
|
|
|
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
|
const [deleteFile, setDeleteFile] = useState<boolean>(false);
|
|
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
|
|
|
|
const [coverImagePreview, setCoverImagePreview] = useState<string>();
|
|
|
|
// Network state
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const [updateScene] = useSceneUpdate(getSceneInput());
|
|
const [deleteScene] = useSceneDestroy(getSceneDeleteInput());
|
|
|
|
useEffect(() => {
|
|
const newQueryableScrapers = (
|
|
Scrapers?.data?.listSceneScrapers ?? []
|
|
).filter((s) =>
|
|
s.scene?.supported_scrapes.includes(GQL.ScrapeType.Fragment)
|
|
);
|
|
|
|
setQueryableScrapers(newQueryableScrapers);
|
|
}, [Scrapers]);
|
|
|
|
useEffect(() => {
|
|
let changed = false;
|
|
const newMap: MovieSceneIndexMap = new Map();
|
|
if (movieIds) {
|
|
movieIds.forEach((id) => {
|
|
if (!movieSceneIndexes.has(id)) {
|
|
changed = true;
|
|
newMap.set(id, undefined);
|
|
} else {
|
|
newMap.set(id, movieSceneIndexes.get(id));
|
|
}
|
|
});
|
|
|
|
if (!changed) {
|
|
movieSceneIndexes.forEach((v, id) => {
|
|
if (!newMap.has(id)) {
|
|
// id was removed
|
|
changed = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (changed) {
|
|
setMovieSceneIndexes(newMap);
|
|
}
|
|
}
|
|
}, [movieIds, movieSceneIndexes]);
|
|
|
|
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
|
|
const perfIds = state.performers?.map((performer) => performer.id);
|
|
const tIds = state.tags ? state.tags.map((tag) => tag.id) : undefined;
|
|
const moviIds = state.movies
|
|
? state.movies.map((sceneMovie) => sceneMovie.movie.id)
|
|
: undefined;
|
|
const movieSceneIdx: MovieSceneIndexMap = new Map();
|
|
if (state.movies) {
|
|
state.movies.forEach((m) => {
|
|
movieSceneIdx.set(m.movie.id, m.scene_index ?? undefined);
|
|
});
|
|
}
|
|
|
|
setTitle(state.title ?? undefined);
|
|
setDetails(state.details ?? undefined);
|
|
setUrl(state.url ?? undefined);
|
|
setDate(state.date ?? undefined);
|
|
setRating(state.rating === null ? NaN : state.rating);
|
|
setGalleryId(state?.gallery?.id ?? undefined);
|
|
setStudioId(state?.studio?.id ?? undefined);
|
|
setMovieIds(moviIds);
|
|
setMovieSceneIndexes(movieSceneIdx);
|
|
setPerformerIds(perfIds);
|
|
setTagIds(tIds);
|
|
}
|
|
|
|
useEffect(() => {
|
|
updateSceneEditState(props.scene);
|
|
setCoverImagePreview(props.scene?.paths?.screenshot ?? undefined);
|
|
setIsLoading(false);
|
|
}, [props.scene]);
|
|
|
|
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
|
|
|
|
function getSceneInput(): GQL.SceneUpdateInput {
|
|
return {
|
|
id: props.scene.id,
|
|
title,
|
|
details,
|
|
url,
|
|
date,
|
|
rating,
|
|
gallery_id: galleryId,
|
|
studio_id: studioId,
|
|
performer_ids: performerIds,
|
|
movies: makeMovieInputs(),
|
|
tag_ids: tagIds,
|
|
cover_image: coverImage,
|
|
};
|
|
}
|
|
|
|
function makeMovieInputs(): GQL.SceneMovieInput[] | undefined {
|
|
if (!movieIds) {
|
|
return undefined;
|
|
}
|
|
|
|
let ret = movieIds.map((id) => {
|
|
const r: GQL.SceneMovieInput = {
|
|
movie_id: id,
|
|
};
|
|
return r;
|
|
});
|
|
|
|
ret = ret.map((r) => {
|
|
return { scene_index: movieSceneIndexes.get(r.movie_id), ...r };
|
|
});
|
|
|
|
return ret;
|
|
}
|
|
|
|
async function onSave() {
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await updateScene();
|
|
if (result.data?.sceneUpdate) {
|
|
props.onUpdate(result.data.sceneUpdate);
|
|
Toast.success({ content: "Updated scene" });
|
|
}
|
|
} catch (e) {
|
|
Toast.error(e);
|
|
}
|
|
setIsLoading(false);
|
|
}
|
|
|
|
function getSceneDeleteInput(): GQL.SceneDestroyInput {
|
|
return {
|
|
id: props.scene.id,
|
|
delete_file: deleteFile,
|
|
delete_generated: deleteGenerated,
|
|
};
|
|
}
|
|
|
|
async function onDelete() {
|
|
setIsDeleteAlertOpen(false);
|
|
setIsLoading(true);
|
|
try {
|
|
await deleteScene();
|
|
Toast.success({ content: "Deleted scene" });
|
|
} catch (e) {
|
|
Toast.error(e);
|
|
}
|
|
setIsLoading(false);
|
|
props.onDelete();
|
|
}
|
|
|
|
function renderTableMovies() {
|
|
return (
|
|
<SceneMovieTable
|
|
movieSceneIndexes={movieSceneIndexes}
|
|
onUpdate={(items) => {
|
|
setMovieSceneIndexes(items);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 onImageLoad(imageData: string) {
|
|
setCoverImagePreview(imageData);
|
|
setCoverImage(imageData);
|
|
}
|
|
|
|
function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) {
|
|
ImageUtils.onImageChange(event, onImageLoad);
|
|
}
|
|
|
|
async function onScrapeClicked(scraper: GQL.Scraper) {
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await queryScrapeScene(scraper.id, getSceneInput());
|
|
if (!result.data || !result.data.scrapeScene) {
|
|
return;
|
|
}
|
|
updateSceneFromScrapedScene(result.data.scrapeScene);
|
|
} catch (e) {
|
|
Toast.error(e);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
async function onReloadScrapers() {
|
|
setIsLoading(true);
|
|
try {
|
|
await mutateReloadScrapers();
|
|
|
|
// reload the performer scrapers
|
|
await Scrapers.refetch();
|
|
} catch (e) {
|
|
Toast.error(e);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
function renderScraperMenu() {
|
|
return (
|
|
<DropdownButton id="scene-scrape" title="Scrape with...">
|
|
{queryableScrapers.map((s) => (
|
|
<Dropdown.Item key={s.name} onClick={() => onScrapeClicked(s)}>
|
|
{s.name}
|
|
</Dropdown.Item>
|
|
))}
|
|
<Dropdown.Item onClick={() => onReloadScrapers()}>
|
|
<span className="fa-icon">
|
|
<Icon icon="sync-alt" />
|
|
</span>
|
|
<span>Reload scrapers</span>
|
|
</Dropdown.Item>
|
|
</DropdownButton>
|
|
);
|
|
}
|
|
|
|
function urlScrapable(scrapedUrl: string): boolean {
|
|
return (Scrapers?.data?.listSceneScrapers ?? []).some((s) =>
|
|
(s?.scene?.urls ?? []).some((u) => scrapedUrl.includes(u))
|
|
);
|
|
}
|
|
|
|
function updateSceneFromScrapedScene(scene: GQL.ScrapedSceneDataFragment) {
|
|
if (!title && scene.title) {
|
|
setTitle(scene.title);
|
|
}
|
|
|
|
if (!details && scene.details) {
|
|
setDetails(scene.details);
|
|
}
|
|
|
|
if (!date && scene.date) {
|
|
setDate(scene.date);
|
|
}
|
|
|
|
if (!url && scene.url) {
|
|
setUrl(scene.url);
|
|
}
|
|
|
|
if (!studioId && scene.studio && scene.studio.id) {
|
|
setStudioId(scene.studio.id);
|
|
}
|
|
|
|
if (
|
|
(!performerIds || performerIds.length === 0) &&
|
|
scene.performers &&
|
|
scene.performers.length > 0
|
|
) {
|
|
const idPerfs = scene.performers.filter((p) => {
|
|
return p.id !== undefined && p.id !== null;
|
|
});
|
|
|
|
if (idPerfs.length > 0) {
|
|
const newIds = idPerfs.map((p) => p.id);
|
|
setPerformerIds(newIds as string[]);
|
|
}
|
|
}
|
|
|
|
if (
|
|
(!movieIds || movieIds.length === 0) &&
|
|
scene.movies &&
|
|
scene.movies.length > 0
|
|
) {
|
|
const idMovis = scene.movies.filter((p) => {
|
|
return p.id !== undefined && p.id !== null;
|
|
});
|
|
|
|
if (idMovis.length > 0) {
|
|
const newIds = idMovis.map((p) => p.id);
|
|
setMovieIds(newIds as string[]);
|
|
}
|
|
}
|
|
|
|
if (!tagIds?.length && scene?.tags?.length) {
|
|
const idTags = scene.tags.filter((p) => {
|
|
return p.id !== undefined && p.id !== null;
|
|
});
|
|
|
|
if (idTags.length > 0) {
|
|
const newIds = idTags.map((p) => p.id);
|
|
setTagIds(newIds as string[]);
|
|
}
|
|
}
|
|
|
|
if (scene.image) {
|
|
// image is a base64 string
|
|
setCoverImage(scene.image);
|
|
setCoverImagePreview(scene.image);
|
|
}
|
|
}
|
|
|
|
async function onScrapeSceneURL() {
|
|
if (!url) {
|
|
return;
|
|
}
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await queryScrapeSceneURL(url);
|
|
if (!result.data || !result.data.scrapeSceneURL) {
|
|
return;
|
|
}
|
|
updateSceneFromScrapedScene(result.data.scrapeSceneURL);
|
|
} catch (e) {
|
|
Toast.error(e);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
function maybeRenderScrapeButton() {
|
|
if (!url || !urlScrapable(url)) {
|
|
return undefined;
|
|
}
|
|
return (
|
|
<Button id="scrape-url-button" onClick={onScrapeSceneURL}>
|
|
<Icon icon="file-download" />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
if (isLoading) return <LoadingIndicator />;
|
|
|
|
return (
|
|
<div className="form-container row">
|
|
<div className="col-12 col-lg-6">
|
|
<Table id="scene-edit-details">
|
|
<tbody>
|
|
{TableUtils.renderInputGroup({
|
|
title: "Title",
|
|
value: title,
|
|
onChange: setTitle,
|
|
isEditing: true,
|
|
})}
|
|
<tr>
|
|
<td>URL</td>
|
|
<td>
|
|
<Form.Control
|
|
onChange={(newValue: React.ChangeEvent<HTMLInputElement>) =>
|
|
setUrl(newValue.currentTarget.value)
|
|
}
|
|
value={url}
|
|
placeholder="URL"
|
|
className="text-input"
|
|
/>
|
|
{maybeRenderScrapeButton()}
|
|
</td>
|
|
</tr>
|
|
{TableUtils.renderInputGroup({
|
|
title: "Date",
|
|
value: date,
|
|
isEditing: true,
|
|
onChange: setDate,
|
|
placeholder: "YYYY-MM-DD",
|
|
})}
|
|
{TableUtils.renderHtmlSelect({
|
|
title: "Rating",
|
|
value: rating,
|
|
isEditing: true,
|
|
onChange: (value: string) =>
|
|
setRating(Number.parseInt(value, 10)),
|
|
selectOptions: ["", 1, 2, 3, 4, 5],
|
|
})}
|
|
<tr>
|
|
<td>Gallery</td>
|
|
<td>
|
|
<SceneGallerySelect
|
|
sceneId={props.scene.id}
|
|
initialId={galleryId}
|
|
onSelect={(item) => setGalleryId(item ? item.id : undefined)}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Studio</td>
|
|
<td>
|
|
<StudioSelect
|
|
onSelect={(items) =>
|
|
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
|
}
|
|
ids={studioId ? [studioId] : []}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Performers</td>
|
|
<td>
|
|
<PerformerSelect
|
|
isMulti
|
|
onSelect={(items) =>
|
|
setPerformerIds(items.map((item) => item.id))
|
|
}
|
|
ids={performerIds}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Movies/Scenes</td>
|
|
<td>
|
|
<MovieSelect
|
|
isMulti
|
|
onSelect={(items) =>
|
|
setMovieIds(items.map((item) => item.id))
|
|
}
|
|
ids={movieIds}
|
|
/>
|
|
{renderTableMovies()}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Tags</td>
|
|
<td>
|
|
<TagSelect
|
|
isMulti
|
|
onSelect={(items) => setTagIds(items.map((item) => item.id))}
|
|
ids={tagIds}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</Table>
|
|
</div>
|
|
<div className="col-12 col-lg-6">
|
|
<Form.Group controlId="details">
|
|
<Form.Label>Details</Form.Label>
|
|
<Form.Control
|
|
as="textarea"
|
|
className="scene-description text-input"
|
|
onChange={(newValue: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
setDetails(newValue.currentTarget.value)
|
|
}
|
|
value={details}
|
|
/>
|
|
</Form.Group>
|
|
|
|
<div>
|
|
<Form.Group className="test" controlId="cover">
|
|
<Form.Label>Cover Image</Form.Label>
|
|
{imageEncoding ? (
|
|
<LoadingIndicator message="Encoding image..." />
|
|
) : (
|
|
<img
|
|
className="scene-cover"
|
|
src={coverImagePreview}
|
|
alt="Scene cover"
|
|
/>
|
|
)}
|
|
<ImageInput isEditing onImageChange={onCoverImageChange} />
|
|
</Form.Group>
|
|
</div>
|
|
</div>
|
|
<div className="col edit-buttons">
|
|
<Button className="edit-button" variant="primary" onClick={onSave}>
|
|
Save
|
|
</Button>
|
|
<Button
|
|
className="edit-button"
|
|
variant="danger"
|
|
onClick={() => setIsDeleteAlertOpen(true)}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
{renderScraperMenu()}
|
|
{renderDeleteAlert()}
|
|
</div>
|
|
);
|
|
};
|