mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +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) {
|
||||
// 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)
|
||||
backimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL()
|
||||
return &backimagePath, nil
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Added scene queue.
|
||||
|
||||
### 🎨 Improvements
|
||||
* Improve Movie UI.
|
||||
* Change performer text query to search by name and alias only.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import cx from "classnames";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
@@ -7,29 +7,19 @@ import {
|
||||
useMovieUpdate,
|
||||
useMovieCreate,
|
||||
useMovieDestroy,
|
||||
queryScrapeMovieURL,
|
||||
useListMovieScrapers,
|
||||
} from "src/core/StashService";
|
||||
import { useParams, useHistory } from "react-router-dom";
|
||||
import {
|
||||
DetailsEditNavbar,
|
||||
LoadingIndicator,
|
||||
Modal,
|
||||
StudioSelect,
|
||||
Icon,
|
||||
} from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import { Table, Form, Modal as BSModal, Button } from "react-bootstrap";
|
||||
import {
|
||||
TableUtils,
|
||||
ImageUtils,
|
||||
EditableTextUtils,
|
||||
TextUtils,
|
||||
DurationUtils,
|
||||
} from "src/utils";
|
||||
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
||||
import { Modal as BSModal, Button } from "react-bootstrap";
|
||||
import { ImageUtils } from "src/utils";
|
||||
import { MovieScenesPanel } from "./MovieScenesPanel";
|
||||
import { MovieScrapeDialog } from "./MovieScrapeDialog";
|
||||
import { MovieDetailsPanel } from "./MovieDetailsPanel";
|
||||
import { MovieEditPanel } from "./MovieEditPanel";
|
||||
|
||||
interface IMovieParams {
|
||||
id?: string;
|
||||
@@ -53,111 +43,32 @@ export const Movie: React.FC = () => {
|
||||
const [backImage, setBackImage] = useState<string | undefined | null>(
|
||||
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
|
||||
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>(
|
||||
undefined
|
||||
);
|
||||
|
||||
// Network state
|
||||
const { data, error, loading } = useFindMovie(id);
|
||||
const movie = data?.findMovie;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [updateMovie] = useMovieUpdate();
|
||||
const [createMovie] = useMovieCreate(getMovieInput() as GQL.MovieCreateInput);
|
||||
const [deleteMovie] = useMovieDestroy(
|
||||
getMovieInput() as GQL.MovieDestroyInput
|
||||
);
|
||||
|
||||
const Scrapers = useListMovieScrapers();
|
||||
const [scrapedMovie, setScrapedMovie] = useState<
|
||||
GQL.ScrapedMovie | undefined
|
||||
>();
|
||||
|
||||
const intl = useIntl();
|
||||
const [createMovie] = useMovieCreate();
|
||||
const [deleteMovie] = useMovieDestroy({ id });
|
||||
|
||||
// set up hotkeys
|
||||
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("d d", () => onDelete());
|
||||
|
||||
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("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) {
|
||||
setImageClipboard(imageData);
|
||||
setIsImageAlertOpen(true);
|
||||
@@ -165,10 +76,8 @@ export const Movie: React.FC = () => {
|
||||
|
||||
function setImageFromClipboard(isFrontImage: boolean) {
|
||||
if (isFrontImage) {
|
||||
setImagePreview(imageClipboard);
|
||||
setFrontImage(imageClipboard);
|
||||
} else {
|
||||
setBackImagePreview(imageClipboard);
|
||||
setBackImage(imageClipboard);
|
||||
}
|
||||
|
||||
@@ -176,16 +85,6 @@ export const Movie: React.FC = () => {
|
||||
setIsImageAlertOpen(false);
|
||||
}
|
||||
|
||||
function onBackImageLoad(imageData: string) {
|
||||
setBackImagePreview(imageData);
|
||||
setBackImage(imageData);
|
||||
}
|
||||
|
||||
function onFrontImageLoad(imageData: string) {
|
||||
setImagePreview(imageData);
|
||||
setFrontImage(imageData);
|
||||
}
|
||||
|
||||
const encodingImage = ImageUtils.usePasteImage(showImageAlert, isEditing);
|
||||
|
||||
if (!isNew && !isEditing) {
|
||||
@@ -195,41 +94,41 @@ export const Movie: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
function getMovieInput() {
|
||||
const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
|
||||
name,
|
||||
aliases,
|
||||
duration,
|
||||
date,
|
||||
rating: rating ?? null,
|
||||
studio_id: studioId ?? null,
|
||||
director,
|
||||
synopsis,
|
||||
url,
|
||||
function getMovieInput(
|
||||
input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
|
||||
) {
|
||||
const ret: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
|
||||
...input,
|
||||
front_image: frontImage,
|
||||
back_image: backImage,
|
||||
};
|
||||
|
||||
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 {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!isNew) {
|
||||
const result = await updateMovie({
|
||||
variables: {
|
||||
input: getMovieInput() as GQL.MovieUpdateInput,
|
||||
input: getMovieInput(input) as GQL.MovieUpdateInput,
|
||||
},
|
||||
});
|
||||
if (result.data?.movieUpdate) {
|
||||
updateMovieData(result.data.movieUpdate);
|
||||
setIsEditing(false);
|
||||
history.push(`/movies/${result.data.movieUpdate.id}`);
|
||||
}
|
||||
} else {
|
||||
const result = await createMovie();
|
||||
const result = await createMovie({
|
||||
variables: getMovieInput(input) as GQL.MovieCreateInput,
|
||||
});
|
||||
if (result.data?.movieCreate?.id) {
|
||||
history.push(`/movies/${result.data.movieCreate.id}`);
|
||||
setIsEditing(false);
|
||||
@@ -237,31 +136,29 @@ export const Movie: React.FC = () => {
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await deleteMovie();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
// redirect to movies page
|
||||
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() {
|
||||
setIsEditing(!isEditing);
|
||||
updateMovieData(movie);
|
||||
setFrontImage(undefined);
|
||||
setBackImage(undefined);
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
@@ -272,7 +169,7 @@ export const Movie: React.FC = () => {
|
||||
accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -314,153 +211,42 @@ export const Movie: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
function updateMovieEditStateFromScraper(
|
||||
state: Partial<GQL.ScrapedMovieDataFragment>
|
||||
) {
|
||||
if (state.name) {
|
||||
setName(state.name);
|
||||
}
|
||||
|
||||
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);
|
||||
function renderFrontImage() {
|
||||
let image = movie?.front_image_path;
|
||||
if (isEditing) {
|
||||
if (frontImage === null) {
|
||||
image = `${image}&default=true`;
|
||||
} else if (frontImage) {
|
||||
image = frontImage;
|
||||
}
|
||||
}
|
||||
|
||||
async function onScrapeMovieURL() {
|
||||
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) {
|
||||
if (image) {
|
||||
return (
|
||||
!!scrapedUrl &&
|
||||
(Scrapers?.data?.listMovieScrapers ?? []).some((s) =>
|
||||
(s?.movie?.urls ?? []).some((u) => scrapedUrl.includes(u))
|
||||
)
|
||||
<div className="movie-image-container">
|
||||
<img alt="Front Cover" src={image} />
|
||||
</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 (
|
||||
<Button
|
||||
className="minimal scrape-url-button"
|
||||
onClick={() => onScrapeMovieURL()}
|
||||
>
|
||||
<Icon icon="file-upload" />
|
||||
</Button>
|
||||
<div className="movie-image-container">
|
||||
<img alt="Back Cover" src={image} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 />;
|
||||
@@ -468,125 +254,55 @@ export const Movie: React.FC = () => {
|
||||
// TODO: CSS class
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="movie-details col">
|
||||
{isNew && <h2>Add Movie</h2>}
|
||||
<div
|
||||
className={cx("movie-details mb-3 col", {
|
||||
"col-xl-4 col-lg-6": !isNew,
|
||||
})}
|
||||
>
|
||||
<div className="logo w-100">
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
) : (
|
||||
<>
|
||||
<img alt={name} className="logo w-50" src={imagePreview} />
|
||||
<img alt={name} className="logo w-50" src={backimagePreview} />
|
||||
</>
|
||||
<div className="movie-images">
|
||||
{renderFrontImage()}
|
||||
{renderBackImage()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<tbody>
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Name",
|
||||
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>
|
||||
|
||||
{!isEditing && movie ? (
|
||||
<>
|
||||
<MovieDetailsPanel movie={movie} />
|
||||
{/* HACK - this is also rendered in the MovieEditPanel */}
|
||||
<DetailsEditNavbar
|
||||
objectName={name ?? "movie"}
|
||||
objectName={movie?.name ?? "movie"}
|
||||
isNew={isNew}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={onToggleEdit}
|
||||
onSave={onSave}
|
||||
onImageChange={onFrontImageChange}
|
||||
onImageChangeURL={onFrontImageLoad}
|
||||
onClearImage={onClearFrontImage}
|
||||
onBackImageChange={onBackImageChange}
|
||||
onBackImageChangeURL={onBackImageLoad}
|
||||
onClearBackImage={onClearBackImage}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<MovieEditPanel
|
||||
movie={movie ?? undefined}
|
||||
onSubmit={onSave}
|
||||
onCancel={onToggleEdit}
|
||||
onDelete={onDelete}
|
||||
setFrontImage={setFrontImage}
|
||||
setBackImage={setBackImage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isNew && (
|
||||
<div className="col-lg-8 col-md-7">
|
||||
|
||||
{!isNew && movie && (
|
||||
<div className="col-xl-8 col-lg-6">
|
||||
<MovieScenesPanel movie={movie} />
|
||||
</div>
|
||||
)}
|
||||
{renderDeleteAlert()}
|
||||
{renderImageAlert()}
|
||||
{maybeRenderScrapeDialog()}
|
||||
</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%;
|
||||
}
|
||||
}
|
||||
|
||||
.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 [createPerformer] = usePerformerCreate();
|
||||
const [createMovie] = useMovieCreate({ name: "" });
|
||||
const [createMovie] = useMovieCreate();
|
||||
const [createTag] = useTagCreate({ name: "" });
|
||||
|
||||
const Toast = useToast();
|
||||
|
||||
@@ -602,9 +602,8 @@ export const movieMutationImpactedQueries = [
|
||||
GQL.AllMoviesForFilterDocument,
|
||||
];
|
||||
|
||||
export const useMovieCreate = (input: GQL.MovieCreateInput) =>
|
||||
export const useMovieCreate = () =>
|
||||
GQL.useMovieCreateMutation({
|
||||
variables: input,
|
||||
update: deleteCache([
|
||||
GQL.FindMoviesDocument,
|
||||
GQL.AllMoviesForFilterDocument,
|
||||
|
||||
@@ -29,7 +29,7 @@ export const URLField: React.FC<IURLField> = ({ name, value, url }) => {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<dl className="row">
|
||||
<dl className="row mb-0">
|
||||
<dt className="col-3 col-xl-2">{name}:</dt>
|
||||
<dd className="col-9 col-xl-10">
|
||||
{url ? (
|
||||
|
||||
Reference in New Issue
Block a user