mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Movie UI refresh (#1227)
* Improve movie UI * Return nil when no back image set
This commit is contained in:
@@ -89,6 +89,24 @@ func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
|
func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
|
||||||
|
// don't return any thing if there is no back image
|
||||||
|
var img []byte
|
||||||
|
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
||||||
|
var err error
|
||||||
|
img, err = repo.Movie().GetBackImage(obj.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if img == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||||
backimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL()
|
backimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL()
|
||||||
return &backimagePath, nil
|
return &backimagePath, nil
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Added scene queue.
|
* Added scene queue.
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Improve Movie UI.
|
||||||
* Change performer text query to search by name and alias only.
|
* Change performer text query to search by name and alias only.
|
||||||
|
|
||||||
### 🐛 Bug fixes
|
### 🐛 Bug fixes
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useIntl } from "react-intl";
|
import cx from "classnames";
|
||||||
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 {
|
import {
|
||||||
@@ -7,29 +7,19 @@ import {
|
|||||||
useMovieUpdate,
|
useMovieUpdate,
|
||||||
useMovieCreate,
|
useMovieCreate,
|
||||||
useMovieDestroy,
|
useMovieDestroy,
|
||||||
queryScrapeMovieURL,
|
|
||||||
useListMovieScrapers,
|
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import { useParams, useHistory } from "react-router-dom";
|
import { useParams, useHistory } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
DetailsEditNavbar,
|
DetailsEditNavbar,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
Modal,
|
Modal,
|
||||||
StudioSelect,
|
|
||||||
Icon,
|
|
||||||
} from "src/components/Shared";
|
} from "src/components/Shared";
|
||||||
import { useToast } from "src/hooks";
|
import { useToast } from "src/hooks";
|
||||||
import { Table, Form, Modal as BSModal, Button } from "react-bootstrap";
|
import { Modal as BSModal, Button } from "react-bootstrap";
|
||||||
import {
|
import { ImageUtils } from "src/utils";
|
||||||
TableUtils,
|
|
||||||
ImageUtils,
|
|
||||||
EditableTextUtils,
|
|
||||||
TextUtils,
|
|
||||||
DurationUtils,
|
|
||||||
} from "src/utils";
|
|
||||||
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
|
||||||
import { MovieScenesPanel } from "./MovieScenesPanel";
|
import { MovieScenesPanel } from "./MovieScenesPanel";
|
||||||
import { MovieScrapeDialog } from "./MovieScrapeDialog";
|
import { MovieDetailsPanel } from "./MovieDetailsPanel";
|
||||||
|
import { MovieEditPanel } from "./MovieEditPanel";
|
||||||
|
|
||||||
interface IMovieParams {
|
interface IMovieParams {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -53,111 +43,32 @@ export const Movie: React.FC = () => {
|
|||||||
const [backImage, setBackImage] = useState<string | undefined | null>(
|
const [backImage, setBackImage] = useState<string | undefined | null>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
const [name, setName] = useState<string | undefined>(undefined);
|
|
||||||
const [aliases, setAliases] = useState<string | undefined>(undefined);
|
|
||||||
const [duration, setDuration] = useState<number | undefined>(undefined);
|
|
||||||
const [date, setDate] = useState<string | undefined>(undefined);
|
|
||||||
const [rating, setRating] = useState<number | undefined>(undefined);
|
|
||||||
const [studioId, setStudioId] = useState<string>();
|
|
||||||
const [director, setDirector] = useState<string | undefined>(undefined);
|
|
||||||
const [synopsis, setSynopsis] = useState<string | undefined>(undefined);
|
|
||||||
const [url, setUrl] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
// Movie state
|
// Movie state
|
||||||
const [movie, setMovie] = useState<Partial<GQL.MovieDataFragment>>({});
|
|
||||||
const [imagePreview, setImagePreview] = useState<string | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
const [backimagePreview, setBackImagePreview] = useState<string | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const [imageClipboard, setImageClipboard] = useState<string | undefined>(
|
const [imageClipboard, setImageClipboard] = useState<string | undefined>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
// Network state
|
// Network state
|
||||||
const { data, error, loading } = useFindMovie(id);
|
const { data, error, loading } = useFindMovie(id);
|
||||||
|
const movie = data?.findMovie;
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [updateMovie] = useMovieUpdate();
|
const [updateMovie] = useMovieUpdate();
|
||||||
const [createMovie] = useMovieCreate(getMovieInput() as GQL.MovieCreateInput);
|
const [createMovie] = useMovieCreate();
|
||||||
const [deleteMovie] = useMovieDestroy(
|
const [deleteMovie] = useMovieDestroy({ id });
|
||||||
getMovieInput() as GQL.MovieDestroyInput
|
|
||||||
);
|
|
||||||
|
|
||||||
const Scrapers = useListMovieScrapers();
|
|
||||||
const [scrapedMovie, setScrapedMovie] = useState<
|
|
||||||
GQL.ScrapedMovie | undefined
|
|
||||||
>();
|
|
||||||
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
// set up hotkeys
|
// set up hotkeys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditing) {
|
|
||||||
Mousetrap.bind("r 0", () => setRating(NaN));
|
|
||||||
Mousetrap.bind("r 1", () => setRating(1));
|
|
||||||
Mousetrap.bind("r 2", () => setRating(2));
|
|
||||||
Mousetrap.bind("r 3", () => setRating(3));
|
|
||||||
Mousetrap.bind("r 4", () => setRating(4));
|
|
||||||
Mousetrap.bind("r 5", () => setRating(5));
|
|
||||||
// Mousetrap.bind("u", (e) => {
|
|
||||||
// setStudioFocus()
|
|
||||||
// e.preventDefault();
|
|
||||||
// });
|
|
||||||
Mousetrap.bind("s s", () => onSave());
|
|
||||||
}
|
|
||||||
|
|
||||||
Mousetrap.bind("e", () => setIsEditing(true));
|
Mousetrap.bind("e", () => setIsEditing(true));
|
||||||
Mousetrap.bind("d d", () => onDelete());
|
Mousetrap.bind("d d", () => onDelete());
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (isEditing) {
|
|
||||||
Mousetrap.unbind("r 0");
|
|
||||||
Mousetrap.unbind("r 1");
|
|
||||||
Mousetrap.unbind("r 2");
|
|
||||||
Mousetrap.unbind("r 3");
|
|
||||||
Mousetrap.unbind("r 4");
|
|
||||||
Mousetrap.unbind("r 5");
|
|
||||||
// Mousetrap.unbind("u");
|
|
||||||
Mousetrap.unbind("s s");
|
|
||||||
}
|
|
||||||
|
|
||||||
Mousetrap.unbind("e");
|
Mousetrap.unbind("e");
|
||||||
Mousetrap.unbind("d d");
|
Mousetrap.unbind("d d");
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateMovieEditState(state: Partial<GQL.MovieDataFragment>) {
|
|
||||||
setName(state.name ?? undefined);
|
|
||||||
setAliases(state.aliases ?? undefined);
|
|
||||||
setDuration(state.duration ?? undefined);
|
|
||||||
setDate(state.date ?? undefined);
|
|
||||||
setRating(state.rating ?? undefined);
|
|
||||||
setStudioId(state?.studio?.id ?? undefined);
|
|
||||||
setDirector(state.director ?? undefined);
|
|
||||||
setSynopsis(state.synopsis ?? undefined);
|
|
||||||
setUrl(state.url ?? undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateMovieData = useCallback(
|
|
||||||
(movieData: Partial<GQL.MovieDataFragment>) => {
|
|
||||||
setFrontImage(undefined);
|
|
||||||
setBackImage(undefined);
|
|
||||||
updateMovieEditState(movieData);
|
|
||||||
setImagePreview(movieData.front_image_path ?? undefined);
|
|
||||||
setBackImagePreview(movieData.back_image_path ?? undefined);
|
|
||||||
setMovie(movieData);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data && data.findMovie) {
|
|
||||||
updateMovieData(data.findMovie);
|
|
||||||
}
|
|
||||||
}, [data, updateMovieData]);
|
|
||||||
|
|
||||||
function showImageAlert(imageData: string) {
|
function showImageAlert(imageData: string) {
|
||||||
setImageClipboard(imageData);
|
setImageClipboard(imageData);
|
||||||
setIsImageAlertOpen(true);
|
setIsImageAlertOpen(true);
|
||||||
@@ -165,10 +76,8 @@ export const Movie: React.FC = () => {
|
|||||||
|
|
||||||
function setImageFromClipboard(isFrontImage: boolean) {
|
function setImageFromClipboard(isFrontImage: boolean) {
|
||||||
if (isFrontImage) {
|
if (isFrontImage) {
|
||||||
setImagePreview(imageClipboard);
|
|
||||||
setFrontImage(imageClipboard);
|
setFrontImage(imageClipboard);
|
||||||
} else {
|
} else {
|
||||||
setBackImagePreview(imageClipboard);
|
|
||||||
setBackImage(imageClipboard);
|
setBackImage(imageClipboard);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,16 +85,6 @@ export const Movie: React.FC = () => {
|
|||||||
setIsImageAlertOpen(false);
|
setIsImageAlertOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBackImageLoad(imageData: string) {
|
|
||||||
setBackImagePreview(imageData);
|
|
||||||
setBackImage(imageData);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFrontImageLoad(imageData: string) {
|
|
||||||
setImagePreview(imageData);
|
|
||||||
setFrontImage(imageData);
|
|
||||||
}
|
|
||||||
|
|
||||||
const encodingImage = ImageUtils.usePasteImage(showImageAlert, isEditing);
|
const encodingImage = ImageUtils.usePasteImage(showImageAlert, isEditing);
|
||||||
|
|
||||||
if (!isNew && !isEditing) {
|
if (!isNew && !isEditing) {
|
||||||
@@ -195,41 +94,41 @@ export const Movie: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMovieInput() {
|
function getMovieInput(
|
||||||
const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
|
input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
|
||||||
name,
|
) {
|
||||||
aliases,
|
const ret: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
|
||||||
duration,
|
...input,
|
||||||
date,
|
|
||||||
rating: rating ?? null,
|
|
||||||
studio_id: studioId ?? null,
|
|
||||||
director,
|
|
||||||
synopsis,
|
|
||||||
url,
|
|
||||||
front_image: frontImage,
|
front_image: frontImage,
|
||||||
back_image: backImage,
|
back_image: backImage,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
(input as GQL.MovieUpdateInput).id = id;
|
(ret as GQL.MovieUpdateInput).id = id;
|
||||||
}
|
}
|
||||||
return input;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSave() {
|
async function onSave(
|
||||||
|
input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
const result = await updateMovie({
|
const result = await updateMovie({
|
||||||
variables: {
|
variables: {
|
||||||
input: getMovieInput() as GQL.MovieUpdateInput,
|
input: getMovieInput(input) as GQL.MovieUpdateInput,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result.data?.movieUpdate) {
|
if (result.data?.movieUpdate) {
|
||||||
updateMovieData(result.data.movieUpdate);
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
history.push(`/movies/${result.data.movieUpdate.id}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = await createMovie();
|
const result = await createMovie({
|
||||||
|
variables: getMovieInput(input) as GQL.MovieCreateInput,
|
||||||
|
});
|
||||||
if (result.data?.movieCreate?.id) {
|
if (result.data?.movieCreate?.id) {
|
||||||
history.push(`/movies/${result.data.movieCreate.id}`);
|
history.push(`/movies/${result.data.movieCreate.id}`);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -237,31 +136,29 @@ export const Movie: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.error(e);
|
Toast.error(e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
await deleteMovie();
|
await deleteMovie();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.error(e);
|
Toast.error(e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirect to movies page
|
// redirect to movies page
|
||||||
history.push(`/movies`);
|
history.push(`/movies`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFrontImageChange(event: React.FormEvent<HTMLInputElement>) {
|
|
||||||
ImageUtils.onImageChange(event, onFrontImageLoad);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBackImageChange(event: React.FormEvent<HTMLInputElement>) {
|
|
||||||
ImageUtils.onImageChange(event, onBackImageLoad);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onToggleEdit() {
|
function onToggleEdit() {
|
||||||
setIsEditing(!isEditing);
|
setIsEditing(!isEditing);
|
||||||
updateMovieData(movie);
|
setFrontImage(undefined);
|
||||||
|
setBackImage(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDeleteAlert() {
|
function renderDeleteAlert() {
|
||||||
@@ -272,7 +169,7 @@ export const Movie: React.FC = () => {
|
|||||||
accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
|
accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
|
||||||
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
|
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
|
||||||
>
|
>
|
||||||
<p>Are you sure you want to delete {name ?? "movie"}?</p>
|
<p>Are you sure you want to delete {movie?.name ?? "movie"}?</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -314,153 +211,42 @@ export const Movie: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMovieEditStateFromScraper(
|
function renderFrontImage() {
|
||||||
state: Partial<GQL.ScrapedMovieDataFragment>
|
let image = movie?.front_image_path;
|
||||||
) {
|
if (isEditing) {
|
||||||
if (state.name) {
|
if (frontImage === null) {
|
||||||
setName(state.name);
|
image = `${image}&default=true`;
|
||||||
}
|
} else if (frontImage) {
|
||||||
|
image = frontImage;
|
||||||
if (state.aliases) {
|
|
||||||
setAliases(state.aliases ?? undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.duration) {
|
|
||||||
setDuration(DurationUtils.stringToSeconds(state.duration) ?? undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.date) {
|
|
||||||
setDate(state.date ?? undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.studio && state.studio.id) {
|
|
||||||
setStudioId(state.studio.id ?? undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.director) {
|
|
||||||
setDirector(state.director ?? undefined);
|
|
||||||
}
|
|
||||||
if (state.synopsis) {
|
|
||||||
setSynopsis(state.synopsis ?? undefined);
|
|
||||||
}
|
|
||||||
if (state.url) {
|
|
||||||
setUrl(state.url ?? undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// image is a base64 string
|
|
||||||
// #404: don't overwrite image if it has been modified by the user
|
|
||||||
// overwrite if not new since it came from a dialog
|
|
||||||
// otherwise follow existing behaviour
|
|
||||||
if (
|
|
||||||
(!isNew || frontImage === undefined) &&
|
|
||||||
(state as GQL.ScrapedMovieDataFragment).front_image !== undefined
|
|
||||||
) {
|
|
||||||
const imageStr = (state as GQL.ScrapedMovieDataFragment).front_image;
|
|
||||||
setFrontImage(imageStr ?? undefined);
|
|
||||||
setImagePreview(imageStr ?? undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(!isNew || backImage === undefined) &&
|
|
||||||
(state as GQL.ScrapedMovieDataFragment).back_image !== undefined
|
|
||||||
) {
|
|
||||||
const imageStr = (state as GQL.ScrapedMovieDataFragment).back_image;
|
|
||||||
setBackImage(imageStr ?? undefined);
|
|
||||||
setBackImagePreview(imageStr ?? undefined);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onScrapeMovieURL() {
|
if (image) {
|
||||||
if (!url) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await queryScrapeMovieURL(url);
|
|
||||||
if (!result.data || !result.data.scrapeMovieURL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if this is a new movie, just dump the data
|
|
||||||
if (isNew) {
|
|
||||||
updateMovieEditStateFromScraper(result.data.scrapeMovieURL);
|
|
||||||
} else {
|
|
||||||
setScrapedMovie(result.data.scrapeMovieURL);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Toast.error(e);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlScrapable(scrapedUrl: string) {
|
|
||||||
return (
|
return (
|
||||||
!!scrapedUrl &&
|
<div className="movie-image-container">
|
||||||
(Scrapers?.data?.listMovieScrapers ?? []).some((s) =>
|
<img alt="Front Cover" src={image} />
|
||||||
(s?.movie?.urls ?? []).some((u) => scrapedUrl.includes(u))
|
</div>
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeRenderScrapeButton() {
|
|
||||||
if (!url || !isEditing || !urlScrapable(url)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBackImage() {
|
||||||
|
let image = movie?.back_image_path;
|
||||||
|
if (isEditing) {
|
||||||
|
if (backImage === null) {
|
||||||
|
image = undefined;
|
||||||
|
} else if (backImage) {
|
||||||
|
image = backImage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<div className="movie-image-container">
|
||||||
className="minimal scrape-url-button"
|
<img alt="Back Cover" src={image} />
|
||||||
onClick={() => onScrapeMovieURL()}
|
</div>
|
||||||
>
|
|
||||||
<Icon icon="file-upload" />
|
|
||||||
</Button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeRenderScrapeDialog() {
|
|
||||||
if (!scrapedMovie) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentMovie = getMovieInput();
|
|
||||||
|
|
||||||
// Get image paths for scrape gui
|
|
||||||
currentMovie.front_image = movie.front_image_path;
|
|
||||||
currentMovie.back_image = movie.back_image_path;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MovieScrapeDialog
|
|
||||||
movie={currentMovie}
|
|
||||||
scraped={scrapedMovie}
|
|
||||||
onClose={(m) => {
|
|
||||||
onScrapeDialogClosed(m);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScrapeDialogClosed(p?: GQL.ScrapedMovieDataFragment) {
|
|
||||||
if (p) {
|
|
||||||
updateMovieEditStateFromScraper(p);
|
|
||||||
}
|
|
||||||
setScrapedMovie(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClearFrontImage() {
|
|
||||||
setFrontImage(null);
|
|
||||||
setImagePreview(
|
|
||||||
movie.front_image_path
|
|
||||||
? `${movie.front_image_path}?default=true`
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClearBackImage() {
|
|
||||||
setBackImage(null);
|
|
||||||
setBackImagePreview(
|
|
||||||
movie.back_image_path
|
|
||||||
? `${movie.back_image_path}?default=true`
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <LoadingIndicator />;
|
if (isLoading) return <LoadingIndicator />;
|
||||||
@@ -468,125 +254,55 @@ export const Movie: React.FC = () => {
|
|||||||
// TODO: CSS class
|
// TODO: CSS class
|
||||||
return (
|
return (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="movie-details col">
|
<div
|
||||||
{isNew && <h2>Add Movie</h2>}
|
className={cx("movie-details mb-3 col", {
|
||||||
|
"col-xl-4 col-lg-6": !isNew,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div className="logo w-100">
|
<div className="logo w-100">
|
||||||
{encodingImage ? (
|
{encodingImage ? (
|
||||||
<LoadingIndicator message="Encoding image..." />
|
<LoadingIndicator message="Encoding image..." />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="movie-images">
|
||||||
<img alt={name} className="logo w-50" src={imagePreview} />
|
{renderFrontImage()}
|
||||||
<img alt={name} className="logo w-50" src={backimagePreview} />
|
{renderBackImage()}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table>
|
{!isEditing && movie ? (
|
||||||
<tbody>
|
<>
|
||||||
{TableUtils.renderInputGroup({
|
<MovieDetailsPanel movie={movie} />
|
||||||
title: "Name",
|
{/* HACK - this is also rendered in the MovieEditPanel */}
|
||||||
value: name ?? "",
|
|
||||||
isEditing: !!isEditing,
|
|
||||||
onChange: setName,
|
|
||||||
})}
|
|
||||||
{TableUtils.renderInputGroup({
|
|
||||||
title: "Aliases",
|
|
||||||
value: aliases,
|
|
||||||
isEditing,
|
|
||||||
onChange: setAliases,
|
|
||||||
})}
|
|
||||||
{TableUtils.renderDurationInput({
|
|
||||||
title: "Duration",
|
|
||||||
value: duration ? duration.toString() : "",
|
|
||||||
isEditing,
|
|
||||||
onChange: (value: string | undefined) =>
|
|
||||||
setDuration(value ? Number.parseInt(value, 10) : undefined),
|
|
||||||
})}
|
|
||||||
{TableUtils.renderInputGroup({
|
|
||||||
title: `Date ${isEditing ? "(YYYY-MM-DD)" : ""}`,
|
|
||||||
value: isEditing ? date : TextUtils.formatDate(intl, date),
|
|
||||||
isEditing,
|
|
||||||
onChange: setDate,
|
|
||||||
})}
|
|
||||||
<tr>
|
|
||||||
<td>Studio</td>
|
|
||||||
<td>
|
|
||||||
<StudioSelect
|
|
||||||
isDisabled={!isEditing}
|
|
||||||
onSelect={(items) =>
|
|
||||||
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
|
||||||
}
|
|
||||||
ids={studioId ? [studioId] : []}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{TableUtils.renderInputGroup({
|
|
||||||
title: "Director",
|
|
||||||
value: director,
|
|
||||||
isEditing,
|
|
||||||
onChange: setDirector,
|
|
||||||
})}
|
|
||||||
<tr>
|
|
||||||
<td>Rating</td>
|
|
||||||
<td>
|
|
||||||
<RatingStars
|
|
||||||
value={rating}
|
|
||||||
disabled={!isEditing}
|
|
||||||
onSetRating={(value) => setRating(value)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<Form.Group controlId="url">
|
|
||||||
<Form.Label>URL {maybeRenderScrapeButton()}</Form.Label>
|
|
||||||
<div>
|
|
||||||
{EditableTextUtils.renderInputGroup({
|
|
||||||
isEditing,
|
|
||||||
onChange: setUrl,
|
|
||||||
value: url,
|
|
||||||
url: TextUtils.sanitiseURL(url),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group controlId="synopsis">
|
|
||||||
<Form.Label>Synopsis</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
as="textarea"
|
|
||||||
readOnly={!isEditing}
|
|
||||||
className="movie-synopsis text-input"
|
|
||||||
onChange={(newValue: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
||||||
setSynopsis(newValue.currentTarget.value)
|
|
||||||
}
|
|
||||||
value={synopsis}
|
|
||||||
/>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<DetailsEditNavbar
|
<DetailsEditNavbar
|
||||||
objectName={name ?? "movie"}
|
objectName={movie?.name ?? "movie"}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
onToggleEdit={onToggleEdit}
|
onToggleEdit={onToggleEdit}
|
||||||
onSave={onSave}
|
onSave={() => {}}
|
||||||
onImageChange={onFrontImageChange}
|
onImageChange={() => {}}
|
||||||
onImageChangeURL={onFrontImageLoad}
|
|
||||||
onClearImage={onClearFrontImage}
|
|
||||||
onBackImageChange={onBackImageChange}
|
|
||||||
onBackImageChangeURL={onBackImageLoad}
|
|
||||||
onClearBackImage={onClearBackImage}
|
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<MovieEditPanel
|
||||||
|
movie={movie ?? undefined}
|
||||||
|
onSubmit={onSave}
|
||||||
|
onCancel={onToggleEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
setFrontImage={setFrontImage}
|
||||||
|
setBackImage={setBackImage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isNew && (
|
|
||||||
<div className="col-lg-8 col-md-7">
|
{!isNew && movie && (
|
||||||
|
<div className="col-xl-8 col-lg-6">
|
||||||
<MovieScenesPanel movie={movie} />
|
<MovieScenesPanel movie={movie} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderDeleteAlert()}
|
{renderDeleteAlert()}
|
||||||
{renderImageAlert()}
|
{renderImageAlert()}
|
||||||
{maybeRenderScrapeDialog()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { DurationUtils, TextUtils } from "src/utils";
|
||||||
|
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
||||||
|
import { TextField, URLField } from "src/utils/field";
|
||||||
|
|
||||||
|
interface IMovieDetailsPanel {
|
||||||
|
movie: Partial<GQL.MovieDataFragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
|
||||||
|
// Network state
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
function maybeRenderAliases() {
|
||||||
|
if (movie.aliases) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="alias-head">Also known as </span>
|
||||||
|
<span className="alias">{movie.aliases}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRatingField() {
|
||||||
|
if (!movie.rating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className="row">
|
||||||
|
<dt className="col-3 col-xl-2">Rating</dt>
|
||||||
|
<dd className="col-9 col-xl-10">
|
||||||
|
<RatingStars value={movie.rating} disabled />
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: CSS class
|
||||||
|
return (
|
||||||
|
<div className="movie-details">
|
||||||
|
<div>
|
||||||
|
<h2>{movie.name}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{maybeRenderAliases()}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<TextField
|
||||||
|
name="Duration"
|
||||||
|
value={
|
||||||
|
movie.duration ? DurationUtils.secondsToString(movie.duration) : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
name="Date"
|
||||||
|
value={movie.date ? TextUtils.formatDate(intl, movie.date) : ""}
|
||||||
|
/>
|
||||||
|
<URLField
|
||||||
|
name="Studio"
|
||||||
|
value={movie.studio?.name}
|
||||||
|
url={`/studios/${movie.studio?.id}`}
|
||||||
|
/>
|
||||||
|
<TextField name="Director" value={movie.director} />
|
||||||
|
|
||||||
|
{renderRatingField()}
|
||||||
|
|
||||||
|
<URLField
|
||||||
|
name="URL"
|
||||||
|
value={movie.url}
|
||||||
|
url={TextUtils.sanitiseURL(movie.url ?? "")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField name="Synopsis" value={movie.synopsis} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
418
ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx
Normal file
418
ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import * as yup from "yup";
|
||||||
|
import Mousetrap from "mousetrap";
|
||||||
|
import {
|
||||||
|
queryScrapeMovieURL,
|
||||||
|
useListMovieScrapers,
|
||||||
|
} from "src/core/StashService";
|
||||||
|
import {
|
||||||
|
LoadingIndicator,
|
||||||
|
StudioSelect,
|
||||||
|
Icon,
|
||||||
|
DetailsEditNavbar,
|
||||||
|
DurationInput,
|
||||||
|
} from "src/components/Shared";
|
||||||
|
import { useToast } from "src/hooks";
|
||||||
|
import { Form, Button, Col, Row, InputGroup } from "react-bootstrap";
|
||||||
|
import { DurationUtils, ImageUtils } from "src/utils";
|
||||||
|
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
||||||
|
import { useFormik } from "formik";
|
||||||
|
import { Prompt } from "react-router-dom";
|
||||||
|
import { MovieScrapeDialog } from "./MovieScrapeDialog";
|
||||||
|
|
||||||
|
interface IMovieEditPanel {
|
||||||
|
movie?: Partial<GQL.MovieDataFragment>;
|
||||||
|
onSubmit: (
|
||||||
|
movie: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
|
||||||
|
) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
setFrontImage: (image?: string | null) => void;
|
||||||
|
setBackImage: (image?: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
||||||
|
movie,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
setFrontImage,
|
||||||
|
setBackImage,
|
||||||
|
}) => {
|
||||||
|
const Toast = useToast();
|
||||||
|
|
||||||
|
const isNew = movie === undefined;
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const Scrapers = useListMovieScrapers();
|
||||||
|
const [scrapedMovie, setScrapedMovie] = useState<
|
||||||
|
GQL.ScrapedMovie | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
const labelXS = 3;
|
||||||
|
const labelXL = 3;
|
||||||
|
const fieldXS = 9;
|
||||||
|
const fieldXL = 9;
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
name: yup.string().required(),
|
||||||
|
aliases: yup.string().optional().nullable(),
|
||||||
|
duration: yup.string().optional().nullable(),
|
||||||
|
date: yup
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.matches(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
rating: yup.number().optional().nullable(),
|
||||||
|
studio_id: yup.string().optional().nullable(),
|
||||||
|
director: yup.string().optional().nullable(),
|
||||||
|
synopsis: yup.string().optional().nullable(),
|
||||||
|
url: yup.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
name: movie?.name,
|
||||||
|
aliases: movie?.aliases,
|
||||||
|
duration: movie?.duration,
|
||||||
|
date: movie?.date,
|
||||||
|
rating: movie?.rating,
|
||||||
|
studio_id: movie?.studio?.id,
|
||||||
|
director: movie?.director,
|
||||||
|
synopsis: movie?.synopsis,
|
||||||
|
url: movie?.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
type InputValues = typeof initialValues;
|
||||||
|
|
||||||
|
const formik = useFormik({
|
||||||
|
initialValues,
|
||||||
|
validationSchema: schema,
|
||||||
|
onSubmit: (values) => onSubmit(getMovieInput(values)),
|
||||||
|
});
|
||||||
|
|
||||||
|
function setRating(v: number) {
|
||||||
|
formik.setFieldValue("rating", v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// set up hotkeys
|
||||||
|
useEffect(() => {
|
||||||
|
Mousetrap.bind("r 0", () => setRating(NaN));
|
||||||
|
Mousetrap.bind("r 1", () => setRating(1));
|
||||||
|
Mousetrap.bind("r 2", () => setRating(2));
|
||||||
|
Mousetrap.bind("r 3", () => setRating(3));
|
||||||
|
Mousetrap.bind("r 4", () => setRating(4));
|
||||||
|
Mousetrap.bind("r 5", () => setRating(5));
|
||||||
|
// Mousetrap.bind("u", (e) => {
|
||||||
|
// setStudioFocus()
|
||||||
|
// e.preventDefault();
|
||||||
|
// });
|
||||||
|
Mousetrap.bind("s s", () => formik.handleSubmit());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Mousetrap.unbind("r 0");
|
||||||
|
Mousetrap.unbind("r 1");
|
||||||
|
Mousetrap.unbind("r 2");
|
||||||
|
Mousetrap.unbind("r 3");
|
||||||
|
Mousetrap.unbind("r 4");
|
||||||
|
Mousetrap.unbind("r 5");
|
||||||
|
// Mousetrap.unbind("u");
|
||||||
|
Mousetrap.unbind("s s");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function getMovieInput(values: InputValues) {
|
||||||
|
const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
|
||||||
|
...values,
|
||||||
|
rating: values.rating ?? null,
|
||||||
|
studio_id: values.studio_id ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (movie && movie.id) {
|
||||||
|
(input as GQL.MovieUpdateInput).id = movie.id;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMovieEditStateFromScraper(
|
||||||
|
state: Partial<GQL.ScrapedMovieDataFragment>
|
||||||
|
) {
|
||||||
|
if (state.name) {
|
||||||
|
formik.setFieldValue("name", state.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.aliases) {
|
||||||
|
formik.setFieldValue("aliases", state.aliases ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.duration) {
|
||||||
|
formik.setFieldValue(
|
||||||
|
"duration",
|
||||||
|
DurationUtils.stringToSeconds(state.duration) ?? undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.date) {
|
||||||
|
formik.setFieldValue("date", state.date ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.studio && state.studio.id) {
|
||||||
|
formik.setFieldValue("studio_id", state.studio.id ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.director) {
|
||||||
|
formik.setFieldValue("director", state.director ?? undefined);
|
||||||
|
}
|
||||||
|
if (state.synopsis) {
|
||||||
|
formik.setFieldValue("synopsis", state.synopsis ?? undefined);
|
||||||
|
}
|
||||||
|
if (state.url) {
|
||||||
|
formik.setFieldValue("url", state.url ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageStr = (state as GQL.ScrapedMovieDataFragment).front_image;
|
||||||
|
setFrontImage(imageStr ?? undefined);
|
||||||
|
|
||||||
|
const backImageStr = (state as GQL.ScrapedMovieDataFragment).back_image;
|
||||||
|
setBackImage(backImageStr ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onScrapeMovieURL() {
|
||||||
|
const { url } = formik.values;
|
||||||
|
if (!url) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await queryScrapeMovieURL(url);
|
||||||
|
if (!result.data || !result.data.scrapeMovieURL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if this is a new movie, just dump the data
|
||||||
|
if (isNew) {
|
||||||
|
updateMovieEditStateFromScraper(result.data.scrapeMovieURL);
|
||||||
|
} else {
|
||||||
|
setScrapedMovie(result.data.scrapeMovieURL);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlScrapable(scrapedUrl: string) {
|
||||||
|
return (
|
||||||
|
!!scrapedUrl &&
|
||||||
|
(Scrapers?.data?.listMovieScrapers ?? []).some((s) =>
|
||||||
|
(s?.movie?.urls ?? []).some((u) => scrapedUrl.includes(u))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderScrapeButton() {
|
||||||
|
const { url } = formik.values;
|
||||||
|
if (!url || !urlScrapable(url)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="minimal scrape-url-button"
|
||||||
|
onClick={() => onScrapeMovieURL()}
|
||||||
|
>
|
||||||
|
<Icon icon="file-upload" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderScrapeDialog() {
|
||||||
|
if (!scrapedMovie) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMovie = getMovieInput(formik.values);
|
||||||
|
|
||||||
|
// Get image paths for scrape gui
|
||||||
|
currentMovie.front_image = movie?.front_image_path;
|
||||||
|
currentMovie.back_image = movie?.back_image_path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MovieScrapeDialog
|
||||||
|
movie={currentMovie}
|
||||||
|
scraped={scrapedMovie}
|
||||||
|
onClose={(m) => {
|
||||||
|
onScrapeDialogClosed(m);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScrapeDialogClosed(p?: GQL.ScrapedMovieDataFragment) {
|
||||||
|
if (p) {
|
||||||
|
updateMovieEditStateFromScraper(p);
|
||||||
|
}
|
||||||
|
setScrapedMovie(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFrontImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||||
|
ImageUtils.onImageChange(event, setFrontImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||||
|
ImageUtils.onImageChange(event, setBackImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingIndicator />;
|
||||||
|
|
||||||
|
const isEditing = true;
|
||||||
|
|
||||||
|
function renderTextField(field: string, title: string) {
|
||||||
|
return (
|
||||||
|
<Form.Group controlId={field} as={Row}>
|
||||||
|
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||||
|
{title}
|
||||||
|
</Form.Label>
|
||||||
|
<Col xs={fieldXS} xl={fieldXL}>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
placeholder={title}
|
||||||
|
{...formik.getFieldProps(field)}
|
||||||
|
isInvalid={!!formik.getFieldMeta(field).error}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: CSS class
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isNew && <h2>Add Movie</h2>}
|
||||||
|
|
||||||
|
<Prompt
|
||||||
|
when={formik.dirty}
|
||||||
|
message="Unsaved changes. Are you sure you want to leave?"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form noValidate onSubmit={formik.handleSubmit} id="movie-edit">
|
||||||
|
<Form.Group controlId="name" as={Row}>
|
||||||
|
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||||
|
Name
|
||||||
|
</Form.Label>
|
||||||
|
<Col xs={fieldXS} xl={fieldXL}>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
placeholder="Name"
|
||||||
|
{...formik.getFieldProps("name")}
|
||||||
|
isInvalid={!!formik.errors.name}
|
||||||
|
/>
|
||||||
|
<Form.Control.Feedback type="invalid">
|
||||||
|
{formik.errors.name}
|
||||||
|
</Form.Control.Feedback>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
{renderTextField("aliases", "Aliases")}
|
||||||
|
|
||||||
|
<Form.Group controlId="duration" as={Row}>
|
||||||
|
<Form.Label column sm={labelXS} xl={labelXL}>
|
||||||
|
Duration
|
||||||
|
</Form.Label>
|
||||||
|
<Col sm={fieldXS} xl={fieldXL}>
|
||||||
|
<DurationInput
|
||||||
|
numericValue={formik.values.duration ?? undefined}
|
||||||
|
onValueChange={(valueAsNumber: number) => {
|
||||||
|
formik.setFieldValue("duration", valueAsNumber);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
{renderTextField("date", "Date (YYYY-MM-DD)")}
|
||||||
|
|
||||||
|
<Form.Group controlId="studio" as={Row}>
|
||||||
|
<Form.Label column sm={labelXS} xl={labelXL}>
|
||||||
|
Studio
|
||||||
|
</Form.Label>
|
||||||
|
<Col sm={fieldXS} xl={fieldXL}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{renderTextField("director", "Director")}
|
||||||
|
|
||||||
|
<Form.Group controlId="rating" as={Row}>
|
||||||
|
<Form.Label column sm={labelXS} xl={labelXL}>
|
||||||
|
Rating
|
||||||
|
</Form.Label>
|
||||||
|
<Col sm={fieldXS} xl={fieldXL}>
|
||||||
|
<RatingStars
|
||||||
|
value={formik.values.rating ?? undefined}
|
||||||
|
onSetRating={(value) => formik.setFieldValue("rating", value)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group controlId="url" as={Row}>
|
||||||
|
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||||
|
URL
|
||||||
|
</Form.Label>
|
||||||
|
<Col xs={fieldXS} xl={fieldXL}>
|
||||||
|
<InputGroup>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
placeholder="URL"
|
||||||
|
{...formik.getFieldProps("url")}
|
||||||
|
/>
|
||||||
|
<InputGroup.Append>{maybeRenderScrapeButton()}</InputGroup.Append>
|
||||||
|
</InputGroup>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group controlId="synopsis" as={Row}>
|
||||||
|
<Form.Label column sm={labelXS} xl={labelXL}>
|
||||||
|
Synopsis
|
||||||
|
</Form.Label>
|
||||||
|
<Col sm={fieldXS} xl={fieldXL}>
|
||||||
|
<Form.Control
|
||||||
|
as="textarea"
|
||||||
|
className="text-input"
|
||||||
|
placeholder="Synopsis"
|
||||||
|
{...formik.getFieldProps("synopsis")}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<DetailsEditNavbar
|
||||||
|
objectName={movie?.name ?? "movie"}
|
||||||
|
isNew={isNew}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onToggleEdit={onCancel}
|
||||||
|
onSave={() => formik.handleSubmit()}
|
||||||
|
onImageChange={onFrontImageChange}
|
||||||
|
onImageChangeURL={setFrontImage}
|
||||||
|
onClearImage={() => {
|
||||||
|
setFrontImage(null);
|
||||||
|
}}
|
||||||
|
onBackImageChange={onBackImageChange}
|
||||||
|
onBackImageChangeURL={setBackImage}
|
||||||
|
onClearBackImage={() => {
|
||||||
|
setBackImage(null);
|
||||||
|
}}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{maybeRenderScrapeDialog()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,3 +18,21 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.movie-images {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
margin: 1rem;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
.movie-image-container {
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
|
|||||||
|
|
||||||
const [createStudio] = useStudioCreate({ name: "" });
|
const [createStudio] = useStudioCreate({ name: "" });
|
||||||
const [createPerformer] = usePerformerCreate();
|
const [createPerformer] = usePerformerCreate();
|
||||||
const [createMovie] = useMovieCreate({ name: "" });
|
const [createMovie] = useMovieCreate();
|
||||||
const [createTag] = useTagCreate({ name: "" });
|
const [createTag] = useTagCreate({ name: "" });
|
||||||
|
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
|
|||||||
@@ -602,9 +602,8 @@ export const movieMutationImpactedQueries = [
|
|||||||
GQL.AllMoviesForFilterDocument,
|
GQL.AllMoviesForFilterDocument,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const useMovieCreate = (input: GQL.MovieCreateInput) =>
|
export const useMovieCreate = () =>
|
||||||
GQL.useMovieCreateMutation({
|
GQL.useMovieCreateMutation({
|
||||||
variables: input,
|
|
||||||
update: deleteCache([
|
update: deleteCache([
|
||||||
GQL.FindMoviesDocument,
|
GQL.FindMoviesDocument,
|
||||||
GQL.AllMoviesForFilterDocument,
|
GQL.AllMoviesForFilterDocument,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const URLField: React.FC<IURLField> = ({ name, value, url }) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<dl className="row">
|
<dl className="row mb-0">
|
||||||
<dt className="col-3 col-xl-2">{name}:</dt>
|
<dt className="col-3 col-xl-2">{name}:</dt>
|
||||||
<dd className="col-9 col-xl-10">
|
<dd className="col-9 col-xl-10">
|
||||||
{url ? (
|
{url ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user