Movie UI refresh (#1227)

* Improve movie UI
* Return nil when no back image set
This commit is contained in:
WithoutPants
2021-03-31 14:54:58 +11:00
committed by GitHub
parent d5e9030768
commit ccb96c3795
9 changed files with 639 additions and 388 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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>
); );
}; };

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -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 ? (