Use formik for scene edit (#1429)

* Use formik for scene edit panel
* Fix unsetting rating
* Disable save if not dirty
* Movie image fixes
This commit is contained in:
WithoutPants
2021-06-02 08:33:38 +10:00
committed by GitHub
parent 975343d2e9
commit 1bb5de12e3
9 changed files with 484 additions and 409 deletions

View File

@@ -7,6 +7,7 @@
* Added [DLNA server](/settings?tab=dlna). ([#1364](https://github.com/stashapp/stash/pull/1364)) * Added [DLNA server](/settings?tab=dlna). ([#1364](https://github.com/stashapp/stash/pull/1364))
### 🎨 Improvements ### 🎨 Improvements
* Prompt when leaving scene edit page with unsaved changed. ([#1429](https://github.com/stashapp/stash/pull/1429))
* Make multi-set mode buttons more obvious in multi-edit dialog. ([#1435](https://github.com/stashapp/stash/pull/1435)) * Make multi-set mode buttons more obvious in multi-edit dialog. ([#1435](https://github.com/stashapp/stash/pull/1435))
* Filter modifiers and sort by options are now sorted alphabetically. ([#1406](https://github.com/stashapp/stash/pull/1406)) * Filter modifiers and sort by options are now sorted alphabetically. ([#1406](https://github.com/stashapp/stash/pull/1406))
* Add `CreatedAt` and `UpdatedAt` (and `FileModTime` where applicable) to API objects. ([#1421](https://github.com/stashapp/stash/pull/1421)) * Add `CreatedAt` and `UpdatedAt` (and `FileModTime` where applicable) to API objects. ([#1421](https://github.com/stashapp/stash/pull/1421))
@@ -16,6 +17,7 @@
* Add button to remove studio stash ID. ([#1378](https://github.com/stashapp/stash/pull/1378)) * Add button to remove studio stash ID. ([#1378](https://github.com/stashapp/stash/pull/1378))
### 🐛 Bug fixes ### 🐛 Bug fixes
* Fix clearing Performer and Movie ratings not working. ([#1429](https://github.com/stashapp/stash/pull/1429))
* Fix scraper date parser failing when parsing time. ([#1431](https://github.com/stashapp/stash/pull/1431)) * Fix scraper date parser failing when parsing time. ([#1431](https://github.com/stashapp/stash/pull/1431))
* Fix quotes in filter labels causing UI errors. ([#1425](https://github.com/stashapp/stash/pull/1425)) * Fix quotes in filter labels causing UI errors. ([#1425](https://github.com/stashapp/stash/pull/1425))
* Fix post-processing not running when scraping by performer fragment. ([#1387](https://github.com/stashapp/stash/pull/1387)) * Fix post-processing not running when scraping by performer fragment. ([#1387](https://github.com/stashapp/stash/pull/1387))

View File

@@ -15,8 +15,6 @@ import {
Modal, Modal,
} from "src/components/Shared"; } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { Modal as BSModal, Button } from "react-bootstrap";
import { ImageUtils } from "src/utils";
import { MovieScenesPanel } from "./MovieScenesPanel"; import { MovieScenesPanel } from "./MovieScenesPanel";
import { MovieDetailsPanel } from "./MovieDetailsPanel"; import { MovieDetailsPanel } from "./MovieDetailsPanel";
import { MovieEditPanel } from "./MovieEditPanel"; import { MovieEditPanel } from "./MovieEditPanel";
@@ -34,7 +32,6 @@ export const Movie: React.FC = () => {
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew); const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [isImageAlertOpen, setIsImageAlertOpen] = useState<boolean>(false);
// Editing movie state // Editing movie state
const [frontImage, setFrontImage] = useState<string | undefined | null>( const [frontImage, setFrontImage] = useState<string | undefined | null>(
@@ -43,11 +40,7 @@ export const Movie: React.FC = () => {
const [backImage, setBackImage] = useState<string | undefined | null>( const [backImage, setBackImage] = useState<string | undefined | null>(
undefined undefined
); );
const [encodingImage, setEncodingImage] = useState<boolean>(false);
// Movie state
const [imageClipboard, setImageClipboard] = useState<string | undefined>(
undefined
);
// Network state // Network state
const { data, error, loading } = useFindMovie(id); const { data, error, loading } = useFindMovie(id);
@@ -69,23 +62,7 @@ export const Movie: React.FC = () => {
}; };
}); });
function showImageAlert(imageData: string) { const onImageEncoding = (isEncoding = false) => setEncodingImage(isEncoding);
setImageClipboard(imageData);
setIsImageAlertOpen(true);
}
function setImageFromClipboard(isFrontImage: boolean) {
if (isFrontImage) {
setFrontImage(imageClipboard);
} else {
setBackImage(imageClipboard);
}
setImageClipboard(undefined);
setIsImageAlertOpen(false);
}
const encodingImage = ImageUtils.usePasteImage(showImageAlert, isEditing);
if (!isNew && !isEditing) { if (!isNew && !isEditing) {
if (!data || !data.findMovie || loading) return <LoadingIndicator />; if (!data || !data.findMovie || loading) return <LoadingIndicator />;
@@ -99,8 +76,6 @@ export const Movie: React.FC = () => {
) { ) {
const ret: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = { const ret: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
...input, ...input,
front_image: frontImage,
back_image: backImage,
}; };
if (!isNew) { if (!isNew) {
@@ -174,43 +149,6 @@ export const Movie: React.FC = () => {
); );
} }
function renderImageAlert() {
return (
<BSModal
show={isImageAlertOpen}
onHide={() => setIsImageAlertOpen(false)}
>
<BSModal.Body>
<p>Select image to set</p>
</BSModal.Body>
<BSModal.Footer>
<div>
<Button
className="mr-2"
variant="secondary"
onClick={() => setIsImageAlertOpen(false)}
>
Cancel
</Button>
<Button
className="mr-2"
onClick={() => setImageFromClipboard(false)}
>
Back Image
</Button>
<Button
className="mr-2"
onClick={() => setImageFromClipboard(true)}
>
Front Image
</Button>
</div>
</BSModal.Footer>
</BSModal>
);
}
function renderFrontImage() { function renderFrontImage() {
let image = movie?.front_image_path; let image = movie?.front_image_path;
if (isEditing) { if (isEditing) {
@@ -292,6 +230,7 @@ export const Movie: React.FC = () => {
onDelete={onDelete} onDelete={onDelete}
setFrontImage={setFrontImage} setFrontImage={setFrontImage}
setBackImage={setBackImage} setBackImage={setBackImage}
onImageEncoding={onImageEncoding}
/> />
)} )}
</div> </div>
@@ -302,7 +241,6 @@ export const Movie: React.FC = () => {
</div> </div>
)} )}
{renderDeleteAlert()} {renderDeleteAlert()}
{renderImageAlert()}
</div> </div>
); );
}; };

View File

@@ -14,7 +14,14 @@ import {
DurationInput, DurationInput,
} from "src/components/Shared"; } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { Form, Button, Col, Row, InputGroup } from "react-bootstrap"; import {
Modal as BSModal,
Form,
Button,
Col,
Row,
InputGroup,
} from "react-bootstrap";
import { DurationUtils, ImageUtils } from "src/utils"; import { DurationUtils, ImageUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { useFormik } from "formik"; import { useFormik } from "formik";
@@ -30,6 +37,7 @@ interface IMovieEditPanel {
onDelete: () => void; onDelete: () => void;
setFrontImage: (image?: string | null) => void; setFrontImage: (image?: string | null) => void;
setBackImage: (image?: string | null) => void; setBackImage: (image?: string | null) => void;
onImageEncoding: (loading?: boolean) => void;
} }
export const MovieEditPanel: React.FC<IMovieEditPanel> = ({ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
@@ -39,12 +47,18 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
onDelete, onDelete,
setFrontImage, setFrontImage,
setBackImage, setBackImage,
onImageEncoding,
}) => { }) => {
const Toast = useToast(); const Toast = useToast();
const isNew = movie === undefined; const isNew = movie === undefined;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isImageAlertOpen, setIsImageAlertOpen] = useState<boolean>(false);
const [imageClipboard, setImageClipboard] = useState<string | undefined>(
undefined
);
const Scrapers = useListMovieScrapers(); const Scrapers = useListMovieScrapers();
const [scrapedMovie, setScrapedMovie] = useState< const [scrapedMovie, setScrapedMovie] = useState<
@@ -70,6 +84,8 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
director: yup.string().optional().nullable(), director: yup.string().optional().nullable(),
synopsis: yup.string().optional().nullable(), synopsis: yup.string().optional().nullable(),
url: yup.string().optional().nullable(), url: yup.string().optional().nullable(),
front_image: yup.string().optional().nullable(),
back_image: yup.string().optional().nullable(),
}); });
const initialValues = { const initialValues = {
@@ -77,11 +93,13 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
aliases: movie?.aliases, aliases: movie?.aliases,
duration: movie?.duration, duration: movie?.duration,
date: movie?.date, date: movie?.date,
rating: movie?.rating, rating: movie?.rating ?? null,
studio_id: movie?.studio?.id, studio_id: movie?.studio?.id,
director: movie?.director, director: movie?.director,
synopsis: movie?.synopsis, synopsis: movie?.synopsis,
url: movie?.url, url: movie?.url,
front_image: undefined,
back_image: undefined,
}; };
type InputValues = typeof initialValues; type InputValues = typeof initialValues;
@@ -92,6 +110,21 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
onSubmit: (values) => onSubmit(getMovieInput(values)), onSubmit: (values) => onSubmit(getMovieInput(values)),
}); });
const encodingImage = ImageUtils.usePasteImage(showImageAlert);
useEffect(() => {
setFrontImage(formik.values.front_image);
}, [formik.values.front_image, setFrontImage]);
useEffect(() => {
setBackImage(formik.values.back_image);
}, [formik.values.back_image, setBackImage]);
useEffect(() => onImageEncoding(encodingImage), [
onImageEncoding,
encodingImage,
]);
function setRating(v: number) { function setRating(v: number) {
formik.setFieldValue("rating", v); formik.setFieldValue("rating", v);
} }
@@ -122,6 +155,22 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
}; };
}); });
function showImageAlert(imageData: string) {
setImageClipboard(imageData);
setIsImageAlertOpen(true);
}
function setImageFromClipboard(isFrontImage: boolean) {
if (isFrontImage) {
formik.setFieldValue("front_image", imageClipboard);
} else {
formik.setFieldValue("back_image", imageClipboard);
}
setImageClipboard(undefined);
setIsImageAlertOpen(false);
}
function getMovieInput(values: InputValues) { function getMovieInput(values: InputValues) {
const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = { const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
...values, ...values,
@@ -172,10 +221,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
} }
const imageStr = (state as GQL.ScrapedMovieDataFragment).front_image; const imageStr = (state as GQL.ScrapedMovieDataFragment).front_image;
setFrontImage(imageStr ?? undefined); formik.setFieldValue("front_image", imageStr ?? undefined);
const backImageStr = (state as GQL.ScrapedMovieDataFragment).back_image; const backImageStr = (state as GQL.ScrapedMovieDataFragment).back_image;
setBackImage(backImageStr ?? undefined); formik.setFieldValue("back_image", backImageStr ?? undefined);
} }
async function onScrapeMovieURL() { async function onScrapeMovieURL() {
@@ -256,11 +305,52 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
} }
function onFrontImageChange(event: React.FormEvent<HTMLInputElement>) { function onFrontImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, setFrontImage); ImageUtils.onImageChange(event, (data) =>
formik.setFieldValue("front_image", data)
);
} }
function onBackImageChange(event: React.FormEvent<HTMLInputElement>) { function onBackImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, setBackImage); ImageUtils.onImageChange(event, (data) =>
formik.setFieldValue("back_image", data)
);
}
function renderImageAlert() {
return (
<BSModal
show={isImageAlertOpen}
onHide={() => setIsImageAlertOpen(false)}
>
<BSModal.Body>
<p>Select image to set</p>
</BSModal.Body>
<BSModal.Footer>
<div>
<Button
className="mr-2"
variant="secondary"
onClick={() => setIsImageAlertOpen(false)}
>
Cancel
</Button>
<Button
className="mr-2"
onClick={() => setImageFromClipboard(false)}
>
Back Image
</Button>
<Button
className="mr-2"
onClick={() => setImageFromClipboard(true)}
>
Front Image
</Button>
</div>
</BSModal.Footer>
</BSModal>
);
} }
if (isLoading) return <LoadingIndicator />; if (isLoading) return <LoadingIndicator />;
@@ -357,7 +447,9 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
<Col sm={fieldXS} xl={fieldXL}> <Col sm={fieldXS} xl={fieldXL}>
<RatingStars <RatingStars
value={formik.values.rating ?? undefined} value={formik.values.rating ?? undefined}
onSetRating={(value) => formik.setFieldValue("rating", value)} onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null)
}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
@@ -399,20 +491,22 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={onCancel} onToggleEdit={onCancel}
onSave={() => formik.handleSubmit()} onSave={() => formik.handleSubmit()}
saveDisabled={!formik.dirty}
onImageChange={onFrontImageChange} onImageChange={onFrontImageChange}
onImageChangeURL={setFrontImage} onImageChangeURL={(i) => formik.setFieldValue("front_image", i)}
onClearImage={() => { onClearImage={() => {
setFrontImage(null); formik.setFieldValue("front_image", null);
}} }}
onBackImageChange={onBackImageChange} onBackImageChange={onBackImageChange}
onBackImageChangeURL={setBackImage} onBackImageChangeURL={(i) => formik.setFieldValue("back_image", i)}
onClearBackImage={() => { onClearBackImage={() => {
setBackImage(null); formik.setFieldValue("back_image", null);
}} }}
onDelete={onDelete} onDelete={onDelete}
/> />
{maybeRenderScrapeDialog()} {maybeRenderScrapeDialog()}
{renderImageAlert()}
</div> </div>
); );
}; };

View File

@@ -144,7 +144,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
tag_ids: (performer.tags ?? []).map((t) => t.id), tag_ids: (performer.tags ?? []).map((t) => t.id),
stash_ids: performer.stash_ids ?? undefined, stash_ids: performer.stash_ids ?? undefined,
image: undefined, image: undefined,
rating: performer.rating ?? undefined, rating: performer.rating ?? null,
details: performer.details ?? "", details: performer.details ?? "",
death_date: performer.death_date ?? "", death_date: performer.death_date ?? "",
hair_color: performer.hair_color ?? "", hair_color: performer.hair_color ?? "",
@@ -691,6 +691,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<Button <Button
className="mr-2" className="mr-2"
variant="primary" variant="primary"
disabled={!formik.dirty}
onClick={() => formik.submitForm()} onClick={() => formik.submitForm()}
> >
Save Save
@@ -994,7 +995,9 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<RatingStars <RatingStars
value={formik.values.rating ?? undefined} value={formik.values.rating ?? undefined}
onSetRating={(value) => formik.setFieldValue("rating", value)} onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null)
}
/> />
</Col> </Col>
</Form.Group> </Form.Group>

View File

@@ -50,7 +50,7 @@ export const Scene: React.FC = () => {
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp()); const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const { data, error, loading } = useFindScene(id); const { data, error, loading, refetch } = useFindScene(id);
const scene = data?.findScene; const scene = data?.findScene;
const { const {
data: sceneStreams, data: sceneStreams,
@@ -505,6 +505,7 @@ export const Scene: React.FC = () => {
isVisible={activeTabKey === "scene-edit-panel"} isVisible={activeTabKey === "scene-edit-panel"}
scene={scene} scene={scene}
onDelete={() => setIsDeleteAlertOpen(true)} onDelete={() => setIsDeleteAlertOpen(true)}
onUpdate={() => refetch()}
/> />
</Tab.Pane> </Tab.Pane>
</Tab.Content> </Tab.Content>

View File

@@ -9,6 +9,7 @@ import {
} from "react-bootstrap"; } from "react-bootstrap";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import { import {
queryScrapeScene, queryScrapeScene,
queryScrapeSceneURL, queryScrapeSceneURL,
@@ -28,9 +29,11 @@ import {
ImageInput, ImageInput,
} from "src/components/Shared"; } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { ImageUtils, FormUtils, EditableTextUtils, TextUtils } from "src/utils"; import { ImageUtils, FormUtils, TextUtils } from "src/utils";
import { MovieSelect } from "src/components/Shared/Select"; import { MovieSelect } from "src/components/Shared/Select";
import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable"; import { useFormik } from "formik";
import { Prompt } from "react-router";
import { SceneMovieTable } from "./SceneMovieTable";
import { RatingStars } from "./RatingStars"; import { RatingStars } from "./RatingStars";
import { SceneScrapeDialog } from "./SceneScrapeDialog"; import { SceneScrapeDialog } from "./SceneScrapeDialog";
@@ -38,6 +41,7 @@ interface IProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
isVisible: boolean; isVisible: boolean;
onDelete: () => void; onDelete: () => void;
onUpdate?: () => void;
} }
export const SceneEditPanel: React.FC<IProps> = ({ export const SceneEditPanel: React.FC<IProps> = ({
@@ -46,37 +50,12 @@ export const SceneEditPanel: React.FC<IProps> = ({
onDelete, onDelete,
}) => { }) => {
const Toast = useToast(); const Toast = useToast();
const [title, setTitle] = useState<string>(scene.title ?? "");
const [details, setDetails] = useState<string>(scene.details ?? "");
const [url, setUrl] = useState<string>(scene.url ?? "");
const [date, setDate] = useState<string>(scene.date ?? "");
const [rating, setRating] = useState<number | undefined>(
scene.rating ?? undefined
);
const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( const [galleries, setGalleries] = useState<{ id: string; title: string }[]>(
scene.galleries.map((g) => ({ scene.galleries.map((g) => ({
id: g.id, id: g.id,
title: g.title ?? TextUtils.fileNameFromPath(g.path ?? ""), title: g.title ?? TextUtils.fileNameFromPath(g.path ?? ""),
})) }))
); );
const [studioId, setStudioId] = useState<string | undefined>(
scene.studio?.id
);
const [performerIds, setPerformerIds] = useState<string[]>(
scene.performers.map((p) => p.id)
);
const [movieIds, setMovieIds] = useState<string[]>(
scene.movies.map((m) => m.movie.id)
);
const [
movieSceneIndexes,
setMovieSceneIndexes,
] = useState<MovieSceneIndexMap>(
new Map(scene.movies.map((m) => [m.movie.id, m.scene_index ?? undefined]))
);
const [tagIds, setTagIds] = useState<string[]>(scene.tags.map((t) => t.id));
const [coverImage, setCoverImage] = useState<string>();
const [stashIDs, setStashIDs] = useState<GQL.StashIdInput[]>(scene.stash_ids);
const Scrapers = useListSceneScrapers(); const Scrapers = useListSceneScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]); const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
@@ -94,10 +73,60 @@ export const SceneEditPanel: React.FC<IProps> = ({
const [updateScene] = useSceneUpdate(); const [updateScene] = useSceneUpdate();
const schema = yup.object({
title: yup.string().optional().nullable(),
details: yup.string().optional().nullable(),
url: yup.string().optional().nullable(),
date: yup.string().optional().nullable(),
rating: yup.number().optional().nullable(),
gallery_ids: yup.array(yup.string().required()).optional().nullable(),
studio_id: yup.string().optional().nullable(),
performer_ids: yup.array(yup.string().required()).optional().nullable(),
movies: yup
.object({
movie_id: yup.string().required(),
scene_index: yup.string().optional().nullable(),
})
.optional()
.nullable(),
tag_ids: yup.array(yup.string().required()).optional().nullable(),
cover_image: yup.string().optional().nullable(),
stash_ids: yup.mixed<GQL.StashIdInput>().optional().nullable(),
});
const initialValues = {
title: scene.title ?? "",
details: scene.details ?? "",
url: scene.url ?? "",
date: scene.date ?? "",
rating: scene.rating ?? null,
gallery_ids: (scene.galleries ?? []).map((g) => g.id),
studio_id: scene.studio?.id,
performer_ids: (scene.performers ?? []).map((p) => p.id),
movies: (scene.movies ?? []).map((m) => {
return { movie_id: m.movie.id, scene_index: m.scene_index };
}),
tag_ids: (scene.tags ?? []).map((t) => t.id),
cover_image: undefined,
stash_ids: scene.stash_ids ?? undefined,
};
type InputValues = typeof initialValues;
const formik = useFormik({
initialValues,
validationSchema: schema,
onSubmit: (values) => onSave(getSceneInput(values)),
});
function setRating(v: number) {
formik.setFieldValue("rating", v);
}
useEffect(() => { useEffect(() => {
if (isVisible) { if (isVisible) {
Mousetrap.bind("s s", () => { Mousetrap.bind("s s", () => {
onSave(); formik.handleSubmit();
}); });
Mousetrap.bind("d d", () => { Mousetrap.bind("d d", () => {
onDelete(); onDelete();
@@ -146,86 +175,47 @@ export const SceneEditPanel: React.FC<IProps> = ({
setQueryableScrapers(newQueryableScrapers); setQueryableScrapers(newQueryableScrapers);
}, [Scrapers, stashConfig]); }, [Scrapers, stashConfig]);
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]);
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
function getSceneInput(): GQL.SceneUpdateInput { function getSceneInput(input: InputValues): GQL.SceneUpdateInput {
return { return {
id: scene.id, id: scene.id,
title, ...input,
details,
url,
date,
rating: rating ?? null,
gallery_ids: galleries.map((g) => g.id),
studio_id: studioId ?? null,
performer_ids: performerIds,
movies: makeMovieInputs(),
tag_ids: tagIds,
cover_image: coverImage,
stash_ids: stashIDs.map((s) => ({
stash_id: s.stash_id,
endpoint: s.endpoint,
})),
}; };
} }
function makeMovieInputs(): GQL.SceneMovieInput[] | undefined { function setMovieIds(movieIds: string[]) {
if (!movieIds) { const existingMovies = formik.values.movies;
return undefined;
}
let ret = movieIds.map((id) => { const newMovies = movieIds.map((m) => {
const r: GQL.SceneMovieInput = { const existing = existingMovies.find((mm) => mm.movie_id === m);
movie_id: id, if (existing) {
return existing;
}
return {
movie_id: m,
}; };
return r;
}); });
ret = ret.map((r) => { formik.setFieldValue("movies", newMovies);
return { scene_index: movieSceneIndexes.get(r.movie_id), ...r };
});
return ret;
} }
async function onSave() { async function onSave(input: GQL.SceneUpdateInput) {
setIsLoading(true); setIsLoading(true);
try { try {
const result = await updateScene({ const result = await updateScene({
variables: { variables: {
input: getSceneInput(), input: {
...input,
rating: input.rating ?? null,
},
}, },
}); });
if (result.data?.sceneUpdate) { if (result.data?.sceneUpdate) {
Toast.success({ content: "Updated scene" }); Toast.success({ content: "Updated scene" });
// clear the cover image so that it doesn't appear dirty
formik.resetForm({ values: formik.values });
} }
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -234,8 +224,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
} }
const removeStashID = (stashID: GQL.StashIdInput) => { const removeStashID = (stashID: GQL.StashIdInput) => {
setStashIDs( formik.setFieldValue(
stashIDs.filter( "stash_ids",
formik.values.stash_ids.filter(
(s) => (s) =>
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id) !(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
) )
@@ -245,9 +236,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
function renderTableMovies() { function renderTableMovies() {
return ( return (
<SceneMovieTable <SceneMovieTable
movieSceneIndexes={movieSceneIndexes} movieScenes={formik.values.movies}
onUpdate={(items) => { onUpdate={(items) => {
setMovieSceneIndexes(items); formik.setFieldValue("movies", items);
}} }}
/> />
); );
@@ -255,7 +246,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
function onImageLoad(imageData: string) { function onImageLoad(imageData: string) {
setCoverImagePreview(imageData); setCoverImagePreview(imageData);
setCoverImage(imageData); formik.setFieldValue("cover_image", imageData);
} }
function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) { function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) {
@@ -287,7 +278,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
async function onScrapeClicked(scraper: GQL.Scraper) { async function onScrapeClicked(scraper: GQL.Scraper) {
setIsLoading(true); setIsLoading(true);
try { try {
const result = await queryScrapeScene(scraper.id, getSceneInput()); const result = await queryScrapeScene(
scraper.id,
getSceneInput(formik.values)
);
if (!result.data || !result.data.scrapeScene) { if (!result.data || !result.data.scrapeScene) {
Toast.success({ Toast.success({
content: "No scenes found", content: "No scenes found",
@@ -328,7 +322,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
return; return;
} }
const currentScene = getSceneInput(); const currentScene = getSceneInput(formik.values);
if (!currentScene.cover_image) { if (!currentScene.cover_image) {
currentScene.cover_image = scene.paths.screenshot; currentScene.cover_image = scene.paths.screenshot;
} }
@@ -423,23 +417,23 @@ export const SceneEditPanel: React.FC<IProps> = ({
updatedScene: GQL.ScrapedSceneDataFragment updatedScene: GQL.ScrapedSceneDataFragment
) { ) {
if (updatedScene.title) { if (updatedScene.title) {
setTitle(updatedScene.title); formik.setFieldValue("title", updatedScene.title);
} }
if (updatedScene.details) { if (updatedScene.details) {
setDetails(updatedScene.details); formik.setFieldValue("details", updatedScene.details);
} }
if (updatedScene.date) { if (updatedScene.date) {
setDate(updatedScene.date); formik.setFieldValue("date", updatedScene.date);
} }
if (updatedScene.url) { if (updatedScene.url) {
setUrl(updatedScene.url); formik.setFieldValue("url", updatedScene.url);
} }
if (updatedScene.studio && updatedScene.studio.stored_id) { if (updatedScene.studio && updatedScene.studio.stored_id) {
setStudioId(updatedScene.studio.stored_id); formik.setFieldValue("studio_id", updatedScene.studio.stored_id);
} }
if (updatedScene.performers && updatedScene.performers.length > 0) { if (updatedScene.performers && updatedScene.performers.length > 0) {
@@ -449,7 +443,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
if (idPerfs.length > 0) { if (idPerfs.length > 0) {
const newIds = idPerfs.map((p) => p.stored_id); const newIds = idPerfs.map((p) => p.stored_id);
setPerformerIds(newIds as string[]); formik.setFieldValue("performer_ids", newIds as string[]);
} }
} }
@@ -471,24 +465,24 @@ export const SceneEditPanel: React.FC<IProps> = ({
if (idTags.length > 0) { if (idTags.length > 0) {
const newIds = idTags.map((p) => p.stored_id); const newIds = idTags.map((p) => p.stored_id);
setTagIds(newIds as string[]); formik.setFieldValue("tag_ids", newIds as string[]);
} }
} }
if (updatedScene.image) { if (updatedScene.image) {
// image is a base64 string // image is a base64 string
setCoverImage(updatedScene.image); formik.setFieldValue("cover_image", updatedScene.image);
setCoverImagePreview(updatedScene.image); setCoverImagePreview(updatedScene.image);
} }
} }
async function onScrapeSceneURL() { async function onScrapeSceneURL() {
if (!url) { if (!formik.values.url) {
return; return;
} }
setIsLoading(true); setIsLoading(true);
try { try {
const result = await queryScrapeSceneURL(url); const result = await queryScrapeSceneURL(formik.values.url);
if (!result.data || !result.data.scrapeSceneURL) { if (!result.data || !result.data.scrapeSceneURL) {
return; return;
} }
@@ -501,7 +495,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
} }
function maybeRenderScrapeButton() { function maybeRenderScrapeButton() {
if (!url || !urlScrapable(url)) { if (!formik.values.url || !urlScrapable(formik.values.url)) {
return undefined; return undefined;
} }
return ( return (
@@ -515,219 +509,253 @@ export const SceneEditPanel: React.FC<IProps> = ({
); );
} }
function renderTextField(field: string, title: string, placeholder?: string) {
return (
<Form.Group controlId={title} as={Row}>
{FormUtils.renderLabel({
title,
})}
<Col xs={9}>
<Form.Control
className="text-input"
placeholder={placeholder ?? title}
{...formik.getFieldProps(field)}
isInvalid={!!formik.getFieldMeta(field).error}
/>
</Col>
</Form.Group>
);
}
if (isLoading) return <LoadingIndicator />; if (isLoading) return <LoadingIndicator />;
return ( return (
<div id="scene-edit-details"> <div id="scene-edit-details">
<Prompt
when={formik.dirty}
message="Unsaved changes. Are you sure you want to leave?"
/>
{maybeRenderScrapeDialog()} {maybeRenderScrapeDialog()}
<div className="form-container row px-3 pt-3"> <Form noValidate onSubmit={formik.handleSubmit}>
<div className="col-6 edit-buttons mb-3 pl-0"> <div className="form-container row px-3 pt-3">
<Button className="edit-button" variant="primary" onClick={onSave}> <div className="col-6 edit-buttons mb-3 pl-0">
Save <Button
</Button> className="edit-button"
<Button variant="primary"
className="edit-button" disabled={!formik.dirty}
variant="danger" onClick={() => formik.submitForm()}
onClick={() => onDelete()} >
> Save
Delete </Button>
</Button> <Button
className="edit-button"
variant="danger"
onClick={() => onDelete()}
>
Delete
</Button>
</div>
<Col xs={6} className="text-right">
{maybeRenderStashboxQueryButton()}
{renderScraperMenu()}
</Col>
</div> </div>
<Col xs={6} className="text-right"> <div className="form-container row px-3">
{maybeRenderStashboxQueryButton()} <div className="col-12 col-lg-6 col-xl-12">
{renderScraperMenu()} {renderTextField("title", "Title")}
</Col> <Form.Group controlId="url" as={Row}>
</div> <Col xs={3} className="pr-0 url-label">
<div className="form-container row px-3"> <Form.Label className="col-form-label">URL</Form.Label>
<div className="col-12 col-lg-6 col-xl-12"> <div className="float-right scrape-button-container">
{FormUtils.renderInputGroup({ {maybeRenderScrapeButton()}
title: "Title", </div>
value: title, </Col>
onChange: setTitle, <Col xs={9}>
isEditing: true, <Form.Control
})} className="text-input"
<Form.Group controlId="url" as={Row}> placeholder="URL"
<Col xs={3} className="pr-0 url-label"> {...formik.getFieldProps("url")}
<Form.Label className="col-form-label">URL</Form.Label> isInvalid={!!formik.getFieldMeta("url").error}
<div className="float-right scrape-button-container">
{maybeRenderScrapeButton()}
</div>
</Col>
<Col xs={9}>
{EditableTextUtils.renderInputGroup({
title: "URL",
value: url,
onChange: setUrl,
isEditing: true,
})}
</Col>
</Form.Group>
{FormUtils.renderInputGroup({
title: "Date",
value: date,
isEditing: true,
onChange: setDate,
placeholder: "YYYY-MM-DD",
})}
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: "Rating",
})}
<Col xs={9}>
<RatingStars
value={rating}
onSetRating={(value) => setRating(value)}
/>
</Col>
</Form.Group>
<Form.Group controlId="galleries" as={Row}>
{FormUtils.renderLabel({
title: "Galleries",
})}
<Col xs={9}>
<GallerySelect
galleries={galleries}
onSelect={(items) => setGalleries(items)}
/>
</Col>
</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] : []}
/>
</Col>
</Form.Group>
<Form.Group controlId="performers" as={Row}>
{FormUtils.renderLabel({
title: "Performers",
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<PerformerSelect
isMulti
onSelect={(items) =>
setPerformerIds(items.map((item) => item.id))
}
ids={performerIds}
/>
</Col>
</Form.Group>
<Form.Group controlId="moviesScenes" as={Row}>
{FormUtils.renderLabel({
title: "Movies/Scenes",
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<MovieSelect
isMulti
onSelect={(items) => setMovieIds(items.map((item) => item.id))}
ids={movieIds}
/>
{renderTableMovies()}
</Col>
</Form.Group>
<Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({
title: "Tags",
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect
isMulti
onSelect={(items) => setTagIds(items.map((item) => item.id))}
ids={tagIds}
/>
</Col>
</Form.Group>
<Form.Group controlId="details">
<Form.Label>StashIDs</Form.Label>
<ul className="pl-0">
{stashIDs.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
const link = base ? (
<a
href={`${base}scenes/${stashID.stash_id}`}
target="_blank"
rel="noopener noreferrer"
>
{stashID.stash_id}
</a>
) : (
stashID.stash_id
);
return (
<li key={stashID.stash_id} className="row no-gutters">
<Button
variant="danger"
className="mr-2 py-0"
title="Delete StashID"
onClick={() => removeStashID(stashID)}
>
<Icon icon="trash-alt" />
</Button>
{link}
</li>
);
})}
</ul>
</Form.Group>
</div>
<div className="col-12 col-lg-6 col-xl-12">
<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 controlId="cover">
<Form.Label>Cover Image</Form.Label>
{imageEncoding ? (
<LoadingIndicator message="Encoding image..." />
) : (
<img
className="scene-cover"
src={coverImagePreview}
alt="Scene cover"
/> />
)} </Col>
<ImageInput </Form.Group>
isEditing {renderTextField("date", "Date", "YYYY-MM-DD")}
onImageChange={onCoverImageChange} <Form.Group controlId="rating" as={Row}>
onImageURL={onImageLoad} {FormUtils.renderLabel({
/> title: "Rating",
})}
<Col xs={9}>
<RatingStars
value={formik.values.rating ?? undefined}
onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null)
}
/>
</Col>
</Form.Group>
<Form.Group controlId="galleries" as={Row}>
{FormUtils.renderLabel({
title: "Galleries",
})}
<Col xs={9}>
<GallerySelect
galleries={galleries}
onSelect={(items) => setGalleries(items)}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: "Studio",
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : undefined
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
/>
</Col>
</Form.Group>
<Form.Group controlId="performers" as={Row}>
{FormUtils.renderLabel({
title: "Performers",
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<PerformerSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"performer_ids",
items.map((item) => item.id)
)
}
ids={formik.values.performer_ids}
/>
</Col>
</Form.Group>
<Form.Group controlId="moviesScenes" as={Row}>
{FormUtils.renderLabel({
title: "Movies/Scenes",
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<MovieSelect
isMulti
onSelect={(items) =>
setMovieIds(items.map((item) => item.id))
}
ids={formik.values.movies.map((m) => m.movie_id)}
/>
{renderTableMovies()}
</Col>
</Form.Group>
<Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({
title: "Tags",
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
/>
</Col>
</Form.Group>
<Form.Group controlId="details">
<Form.Label>StashIDs</Form.Label>
<ul className="pl-0">
{formik.values.stash_ids.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
const link = base ? (
<a
href={`${base}scenes/${stashID.stash_id}`}
target="_blank"
rel="noopener noreferrer"
>
{stashID.stash_id}
</a>
) : (
stashID.stash_id
);
return (
<li key={stashID.stash_id} className="row no-gutters">
<Button
variant="danger"
className="mr-2 py-0"
title="Delete StashID"
onClick={() => removeStashID(stashID)}
>
<Icon icon="trash-alt" />
</Button>
{link}
</li>
);
})}
</ul>
</Form.Group> </Form.Group>
</div> </div>
<div className="col-12 col-lg-6 col-xl-12">
<Form.Group controlId="details">
<Form.Label>Details</Form.Label>
<Form.Control
as="textarea"
className="scene-description text-input"
onChange={(newValue: React.ChangeEvent<HTMLTextAreaElement>) =>
formik.setFieldValue("details", newValue.currentTarget.value)
}
value={formik.values.details}
/>
</Form.Group>
<div>
<Form.Group 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}
onImageURL={onImageLoad}
/>
</Form.Group>
</div>
</div>
</div> </div>
</div> </Form>
</div> </div>
); );
}; };

View File

@@ -6,8 +6,8 @@ import { Form, Row, Col } from "react-bootstrap";
export type MovieSceneIndexMap = Map<string, number | undefined>; export type MovieSceneIndexMap = Map<string, number | undefined>;
export interface IProps { export interface IProps {
movieSceneIndexes: MovieSceneIndexMap; movieScenes: GQL.SceneMovieInput[];
onUpdate: (value: MovieSceneIndexMap) => void; onUpdate: (value: GQL.SceneMovieInput[]) => void;
} }
export const SceneMovieTable: React.FunctionComponent<IProps> = ( export const SceneMovieTable: React.FunctionComponent<IProps> = (
@@ -16,40 +16,43 @@ export const SceneMovieTable: React.FunctionComponent<IProps> = (
const { data } = useAllMoviesForFilter(); const { data } = useAllMoviesForFilter();
const items = !!data && !!data.allMovies ? data.allMovies : []; const items = !!data && !!data.allMovies ? data.allMovies : [];
let itemsFilter: GQL.SlimMovieDataFragment[] = [];
if (!!props.movieSceneIndexes && !!items) { const movieEntries = props.movieScenes.map((m) => {
props.movieSceneIndexes.forEach((_index, movieId) => { return {
itemsFilter = itemsFilter.concat(items.filter((x) => x.id === movieId)); movie: items.find((mm) => m.movie_id === mm.id),
}); ...m,
} };
const storeIdx = itemsFilter.map((movie) => {
return props.movieSceneIndexes.get(movie.id);
}); });
const updateFieldChanged = (movieId: string, value: number) => { const updateFieldChanged = (movieId: string, value: number) => {
const newMap = new Map(props.movieSceneIndexes); const newValues = props.movieScenes.map((ms) => {
newMap.set(movieId, value); if (ms.movie_id === movieId) {
props.onUpdate(newMap); return {
movie_id: movieId,
scene_index: value,
};
}
return ms;
});
props.onUpdate(newValues);
}; };
function renderTableData() { function renderTableData() {
return ( return (
<> <>
{itemsFilter!.map((item, index: number) => ( {movieEntries.map((m) => (
<Row key={item.toString()}> <Row key={m.movie_id}>
<Form.Label column xs={9}> <Form.Label column xs={9}>
{item.name} {m.movie?.name ?? ""}
</Form.Label> </Form.Label>
<Col xs={3}> <Col xs={3}>
<Form.Control <Form.Control
className="text-input" className="text-input"
type="number" type="number"
value={storeIdx[index] ? storeIdx[index]?.toString() : ""} value={m.scene_index ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
updateFieldChanged( updateFieldChanged(
item.id, m.movie_id,
Number.parseInt( Number.parseInt(
e.currentTarget.value ? e.currentTarget.value : "0", e.currentTarget.value ? e.currentTarget.value : "0",
10 10
@@ -64,7 +67,7 @@ export const SceneMovieTable: React.FunctionComponent<IProps> = (
); );
} }
if (props.movieSceneIndexes.size > 0) { if (props.movieScenes.length > 0) {
return ( return (
<div className="movie-table"> <div className="movie-table">
<Row> <Row>

View File

@@ -480,7 +480,7 @@ export const SettingsDLNAPanel: React.FC = () => {
<hr /> <hr />
<Button variant="primary" type="submit"> <Button variant="primary" type="submit" disabled={!dirty}>
Save Save
</Button> </Button>
</Form> </Form>

View File

@@ -8,6 +8,7 @@ interface IProps {
isEditing: boolean; isEditing: boolean;
onToggleEdit: () => void; onToggleEdit: () => void;
onSave: () => void; onSave: () => void;
saveDisabled?: boolean;
onDelete: () => void; onDelete: () => void;
onAutoTag?: () => void; onAutoTag?: () => void;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void; onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
@@ -39,7 +40,12 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
if (!props.isEditing) return; if (!props.isEditing) return;
return ( return (
<Button variant="success" className="save" onClick={() => props.onSave()}> <Button
variant="success"
className="save"
disabled={props.saveDisabled}
onClick={() => props.onSave()}
>
Save Save
</Button> </Button>
); );