Split out entity creation from view pages (#1884)

* Split performerCreate page into separate page
* Split studioCreate into a separate page
* Remove Partial types from performer/studio
* Split tagCreate into a separate page
* Split movieCreate into a separate page
* Split out galleryCreate into its own page
* Add loader to scene page
* Fix performer name fallback
* Fix movie layout shift
* Fix prompt comment and switch studio prompt to localized string
This commit is contained in:
InfiniteTF
2021-10-26 00:43:45 +02:00
committed by GitHub
parent c8182bdb4c
commit 1fffc0519a
41 changed files with 894 additions and 683 deletions

View File

@@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { PersistanceLevel } from "src/hooks/ListHook"; import { PersistanceLevel } from "src/hooks/ListHook";
import { Gallery } from "./GalleryDetails/Gallery"; import Gallery from "./GalleryDetails/Gallery";
import GalleryCreate from "./GalleryDetails/GalleryCreate";
import { GalleryList } from "./GalleryList"; import { GalleryList } from "./GalleryList";
const Galleries = () => ( const Galleries = () => (
@@ -13,6 +14,7 @@ const Galleries = () => (
<GalleryList {...props} persistState={PersistanceLevel.ALL} /> <GalleryList {...props} persistState={PersistanceLevel.ALL} />
)} )}
/> />
<Route exact path="/galleries/new" component={GalleryCreate} />
<Route path="/galleries/:id/:tab?" component={Gallery} /> <Route path="/galleries/:id/:tab?" component={Gallery} />
</Switch> </Switch>
); );

View File

@@ -2,6 +2,7 @@ import { Tab, Nav, Dropdown } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory, Link } from "react-router-dom"; import { useParams, useHistory, Link } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { import {
mutateMetadataScan, mutateMetadataScan,
useFindGallery, useFindGallery,
@@ -9,7 +10,7 @@ import {
} from "src/core/StashService"; } from "src/core/StashService";
import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared"; import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import * as Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton"; import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton";
import { GalleryEditPanel } from "./GalleryEditPanel"; import { GalleryEditPanel } from "./GalleryEditPanel";
@@ -20,27 +21,26 @@ import { GalleryAddPanel } from "./GalleryAddPanel";
import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel"; import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel";
import { GalleryScenesPanel } from "./GalleryScenesPanel"; import { GalleryScenesPanel } from "./GalleryScenesPanel";
interface IProps {
gallery: GQL.GalleryDataFragment;
}
interface IGalleryParams { interface IGalleryParams {
id?: string;
tab?: string; tab?: string;
} }
export const Gallery: React.FC = () => { export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
const { tab = "images", id = "new" } = useParams<IGalleryParams>(); const { tab = "images" } = useParams<IGalleryParams>();
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const isNew = id === "new";
const { data, error, loading } = useFindGallery(id);
const gallery = data?.findGallery;
const [activeTabKey, setActiveTabKey] = useState("gallery-details-panel"); const [activeTabKey, setActiveTabKey] = useState("gallery-details-panel");
const activeRightTabKey = tab === "images" || tab === "add" ? tab : "images"; const activeRightTabKey = tab === "images" || tab === "add" ? tab : "images";
const setActiveRightTabKey = (newTab: string | null) => { const setActiveRightTabKey = (newTab: string | null) => {
if (tab !== newTab) { if (tab !== newTab) {
const tabParam = newTab === "images" ? "" : `/${newTab}`; const tabParam = newTab === "images" ? "" : `/${newTab}`;
history.replace(`/galleries/${id}${tabParam}`); history.replace(`/galleries/${gallery.id}${tabParam}`);
} }
}; };
@@ -54,8 +54,8 @@ export const Gallery: React.FC = () => {
await updateGallery({ await updateGallery({
variables: { variables: {
input: { input: {
id: gallery?.id ?? "", id: gallery.id,
organized: !gallery?.organized, organized: !gallery.organized,
}, },
}, },
}); });
@@ -118,7 +118,7 @@ export const Gallery: React.FC = () => {
<Icon icon="ellipsis-v" /> <Icon icon="ellipsis-v" />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white"> <Dropdown.Menu className="bg-secondary text-white">
{gallery?.path ? ( {gallery.path ? (
<Dropdown.Item <Dropdown.Item
key="rescan" key="rescan"
className="bg-secondary text-white" className="bg-secondary text-white"
@@ -268,35 +268,6 @@ export const Gallery: React.FC = () => {
}; };
}); });
if (loading) {
return <LoadingIndicator />;
}
if (error) return <ErrorMessage error={error.message} />;
if (isNew)
return (
<div className="row new-view">
<div className="col-md-6">
<h2>
<FormattedMessage
id="actions.create_entity"
values={{ entityType: intl.formatMessage({ id: "gallery" }) }}
/>
</h2>
<GalleryEditPanel
isNew
gallery={undefined}
isVisible
onDelete={() => setIsDeleteAlertOpen(true)}
/>
</div>
</div>
);
if (!gallery)
return <ErrorMessage error={`No gallery with id ${id} found.`} />;
return ( return (
<div className="row"> <div className="row">
{maybeRenderDeleteDialog()} {maybeRenderDeleteDialog()}
@@ -323,3 +294,17 @@ export const Gallery: React.FC = () => {
</div> </div>
); );
}; };
const GalleryLoader: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const { data, loading, error } = useFindGallery(id ?? "");
if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!data?.findGallery)
return <ErrorMessage error={`No gallery found with id ${id}.`} />;
return <GalleryPage gallery={data.findGallery} />;
};
export default GalleryLoader;

View File

@@ -11,7 +11,7 @@ import { useIntl } from "react-intl";
import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { IconProp } from "@fortawesome/fontawesome-svg-core";
interface IGalleryAddProps { interface IGalleryAddProps {
gallery: Partial<GQL.GalleryDataFragment>; gallery: GQL.GalleryDataFragment;
} }
export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => { export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
@@ -20,7 +20,7 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
function filterHook(filter: ListFilterModel) { function filterHook(filter: ListFilterModel) {
const galleryValue = { const galleryValue = {
id: gallery.id!, id: gallery.id,
label: gallery.title ?? TextUtils.fileNameFromPath(gallery.path ?? ""), label: gallery.title ?? TextUtils.fileNameFromPath(gallery.path ?? ""),
}; };
// if galleries is already present, then we modify it, otherwise add // if galleries is already present, then we modify it, otherwise add

View File

@@ -0,0 +1,28 @@
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { GalleryEditPanel } from "./GalleryEditPanel";
const GalleryCreate: React.FC = () => {
const intl = useIntl();
return (
<div className="row new-view">
<div className="col-md-6">
<h2>
<FormattedMessage
id="actions.create_entity"
values={{ entityType: intl.formatMessage({ id: "gallery" }) }}
/>
</h2>
<GalleryEditPanel
isNew
gallery={undefined}
isVisible
onDelete={() => {}}
/>
</div>
</div>
);
};
export default GalleryCreate;

View File

@@ -9,23 +9,25 @@ import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { sortPerformers } from "src/core/performers"; import { sortPerformers } from "src/core/performers";
interface IGalleryDetailProps { interface IGalleryDetailProps {
gallery: Partial<GQL.GalleryDataFragment>; gallery: GQL.GalleryDataFragment;
} }
export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = (props) => { export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
gallery,
}) => {
function renderDetails() { function renderDetails() {
if (!props.gallery.details || props.gallery.details === "") return; if (!gallery.details) return;
return ( return (
<> <>
<h6>Details</h6> <h6>Details</h6>
<p className="pre">{props.gallery.details}</p> <p className="pre">{gallery.details}</p>
</> </>
); );
} }
function renderTags() { function renderTags() {
if (!props.gallery.tags || props.gallery.tags.length === 0) return; if (gallery.tags.length === 0) return;
const tags = props.gallery.tags.map((tag) => ( const tags = gallery.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} tagType="gallery" /> <TagLink key={tag.id} tag={tag} tagType="gallery" />
)); ));
return ( return (
@@ -37,14 +39,13 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = (props) => {
} }
function renderPerformers() { function renderPerformers() {
if (!props.gallery.performers || props.gallery.performers.length === 0) if (gallery.performers.length === 0) return;
return; const performers = sortPerformers(gallery.performers);
const performers = sortPerformers(props.gallery.performers);
const cards = performers.map((performer) => ( const cards = performers.map((performer) => (
<PerformerCard <PerformerCard
key={performer.id} key={performer.id}
performer={performer} performer={performer}
ageFromDate={props.gallery.date ?? undefined} ageFromDate={gallery.date ?? undefined}
/> />
)); ));
@@ -59,9 +60,8 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = (props) => {
} }
// filename should use entire row if there is no studio // filename should use entire row if there is no studio
const galleryDetailsWidth = props.gallery.studio ? "col-9" : "col-12"; const galleryDetailsWidth = gallery.studio ? "col-9" : "col-12";
const title = const title = gallery.title ?? TextUtils.fileNameFromPath(gallery.path ?? "");
props.gallery.title ?? TextUtils.fileNameFromPath(props.gallery.path ?? "");
return ( return (
<> <>
@@ -70,29 +70,29 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = (props) => {
<h3 className="gallery-header d-xl-none"> <h3 className="gallery-header d-xl-none">
<TruncatedText text={title} /> <TruncatedText text={title} />
</h3> </h3>
{props.gallery.date ? ( {gallery.date ? (
<h5> <h5>
<FormattedDate <FormattedDate
value={props.gallery.date} value={gallery.date}
format="long" format="long"
timeZone="utc" timeZone="utc"
/> />
</h5> </h5>
) : undefined} ) : undefined}
{props.gallery.rating ? ( {gallery.rating ? (
<h6> <h6>
Rating: <RatingStars value={props.gallery.rating} /> Rating: <RatingStars value={gallery.rating} />
</h6> </h6>
) : ( ) : (
"" ""
)} )}
</div> </div>
{props.gallery.studio && ( {gallery.studio && (
<div className="col-3 d-xl-none"> <div className="col-3 d-xl-none">
<Link to={`/studios/${props.gallery.studio.id}`}> <Link to={`/studios/${gallery.studio.id}`}>
<img <img
src={props.gallery.studio.image_path ?? ""} src={gallery.studio.image_path ?? ""}
alt={`${props.gallery.studio.name} logo`} alt={`${gallery.studio.name} logo`}
className="studio-logo float-right" className="studio-logo float-right"
/> />
</Link> </Link>

View File

@@ -1,17 +1,16 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, 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 {
useFindMovie, useFindMovie,
useMovieUpdate, useMovieUpdate,
useMovieCreate,
useMovieDestroy, useMovieDestroy,
} 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,
ErrorMessage,
LoadingIndicator, LoadingIndicator,
Modal, Modal,
} from "src/components/Shared"; } from "src/components/Shared";
@@ -20,19 +19,17 @@ import { MovieScenesPanel } from "./MovieScenesPanel";
import { MovieDetailsPanel } from "./MovieDetailsPanel"; import { MovieDetailsPanel } from "./MovieDetailsPanel";
import { MovieEditPanel } from "./MovieEditPanel"; import { MovieEditPanel } from "./MovieEditPanel";
interface IMovieParams { interface IProps {
id?: string; movie: GQL.MovieDataFragment;
} }
export const Movie: React.FC = () => { const MoviePage: React.FC<IProps> = ({ movie }) => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const { id = "new" } = useParams<IMovieParams>();
const isNew = id === "new";
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew); const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing movie state // Editing movie state
@@ -44,14 +41,10 @@ export const Movie: React.FC = () => {
); );
const [encodingImage, setEncodingImage] = useState<boolean>(false); const [encodingImage, setEncodingImage] = useState<boolean>(false);
// Network state const [updateMovie, { loading: updating }] = useMovieUpdate();
const { data, error, loading } = useFindMovie(id); const [deleteMovie, { loading: deleting }] = useMovieDestroy({
const movie = data?.findMovie; id: movie.id,
});
const [isLoading, setIsLoading] = useState(false);
const [updateMovie] = useMovieUpdate();
const [createMovie] = useMovieCreate();
const [deleteMovie] = useMovieDestroy({ id });
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
@@ -66,23 +59,14 @@ export const Movie: React.FC = () => {
const onImageEncoding = (isEncoding = false) => setEncodingImage(isEncoding); const onImageEncoding = (isEncoding = false) => setEncodingImage(isEncoding);
if (!isNew && !isEditing) {
if (!data || !data.findMovie || loading) return <LoadingIndicator />;
if (error) {
return <>{error!.message}</>;
}
}
function getMovieInput( function getMovieInput(
input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
) { ) {
const ret: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = { const ret: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
...input, ...input,
id: movie.id,
}; };
if (!isNew) {
(ret as GQL.MovieUpdateInput).id = id;
}
return ret; return ret;
} }
@@ -90,42 +74,25 @@ export const Movie: React.FC = () => {
input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
) { ) {
try { try {
setIsLoading(true); const result = await updateMovie({
variables: {
if (!isNew) { input: getMovieInput(input) as GQL.MovieUpdateInput,
const result = await updateMovie({ },
variables: { });
input: getMovieInput(input) as GQL.MovieUpdateInput, if (result.data?.movieUpdate) {
}, setIsEditing(false);
}); history.push(`/movies/${result.data.movieUpdate.id}`);
if (result.data?.movieUpdate) {
setIsEditing(false);
history.push(`/movies/${result.data.movieUpdate.id}`);
}
} else {
const result = await createMovie({
variables: getMovieInput(input) as GQL.MovieCreateInput,
});
if (result.data?.movieCreate?.id) {
history.push(`/movies/${result.data.movieCreate.id}`);
setIsEditing(false);
}
} }
} 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
@@ -155,7 +122,7 @@ export const Movie: React.FC = () => {
id="dialogs.delete_confirm" id="dialogs.delete_confirm"
values={{ values={{
entityName: entityName:
movie?.name ?? movie.name ??
intl.formatMessage({ id: "movie" }).toLocaleLowerCase(), intl.formatMessage({ id: "movie" }).toLocaleLowerCase(),
}} }}
/> />
@@ -165,7 +132,7 @@ export const Movie: React.FC = () => {
} }
function renderFrontImage() { function renderFrontImage() {
let image = movie?.front_image_path; let image = movie.front_image_path;
if (isEditing) { if (isEditing) {
if (frontImage === null) { if (frontImage === null) {
image = `${image}&default=true`; image = `${image}&default=true`;
@@ -184,7 +151,7 @@ export const Movie: React.FC = () => {
} }
function renderBackImage() { function renderBackImage() {
let image = movie?.back_image_path; let image = movie.back_image_path;
if (isEditing) { if (isEditing) {
if (backImage === null) { if (backImage === null) {
image = undefined; image = undefined;
@@ -202,16 +169,12 @@ export const Movie: React.FC = () => {
} }
} }
if (isLoading) return <LoadingIndicator />; if (updating || deleting) return <LoadingIndicator />;
// TODO: CSS class // TODO: CSS class
return ( return (
<div className="row"> <div className="row">
<div <div className="movie-details mb-3 col col-xl-4 col-lg-6">
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..." />
@@ -223,13 +186,13 @@ export const Movie: React.FC = () => {
)} )}
</div> </div>
{!isEditing && movie ? ( {!isEditing ? (
<> <>
<MovieDetailsPanel movie={movie} /> <MovieDetailsPanel movie={movie} />
{/* HACK - this is also rendered in the MovieEditPanel */} {/* HACK - this is also rendered in the MovieEditPanel */}
<DetailsEditNavbar <DetailsEditNavbar
objectName={movie?.name ?? "movie"} objectName={movie.name}
isNew={isNew} isNew={false}
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={onToggleEdit} onToggleEdit={onToggleEdit}
onSave={() => {}} onSave={() => {}}
@@ -239,7 +202,7 @@ export const Movie: React.FC = () => {
</> </>
) : ( ) : (
<MovieEditPanel <MovieEditPanel
movie={movie ?? undefined} movie={movie}
onSubmit={onSave} onSubmit={onSave}
onCancel={onToggleEdit} onCancel={onToggleEdit}
onDelete={onDelete} onDelete={onDelete}
@@ -250,12 +213,24 @@ export const Movie: React.FC = () => {
)} )}
</div> </div>
{!isNew && movie && ( <div className="col-xl-8 col-lg-6">
<div className="col-xl-8 col-lg-6"> <MovieScenesPanel movie={movie} />
<MovieScenesPanel movie={movie} /> </div>
</div>
)}
{renderDeleteAlert()} {renderDeleteAlert()}
</div> </div>
); );
}; };
const MovieLoader: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const { data, loading, error } = useFindMovie(id ?? "");
if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!data?.findMovie)
return <ErrorMessage error={`No movie found with id ${id}.`} />;
return <MoviePage movie={data.findMovie} />;
};
export default MovieLoader;

View File

@@ -0,0 +1,99 @@
import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { useMovieCreate } from "src/core/StashService";
import { useHistory } from "react-router-dom";
import { LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks";
import { MovieEditPanel } from "./MovieEditPanel";
export const MovieCreate: React.FC = () => {
const history = useHistory();
const Toast = useToast();
// Editing movie state
const [frontImage, setFrontImage] = useState<string | undefined | null>(
undefined
);
const [backImage, setBackImage] = useState<string | undefined | null>(
undefined
);
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [createMovie] = useMovieCreate();
const onImageEncoding = (isEncoding = false) => setEncodingImage(isEncoding);
function getMovieInput(
input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
) {
const ret: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
...input,
};
return ret;
}
async function onSave(
input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
) {
try {
const result = await createMovie({
variables: getMovieInput(input) as GQL.MovieCreateInput,
});
if (result.data?.movieCreate?.id) {
history.push(`/movies/${result.data.movieCreate.id}`);
}
} catch (e) {
Toast.error(e);
}
}
function renderFrontImage() {
if (frontImage) {
return (
<div className="movie-image-container">
<img alt="Front Cover" src={frontImage} />
</div>
);
}
}
function renderBackImage() {
if (backImage) {
return (
<div className="movie-image-container">
<img alt="Back Cover" src={backImage} />
</div>
);
}
}
// TODO: CSS class
return (
<div className="row">
<div className="movie-details mb-3 col">
<div className="logo w-100">
{encodingImage ? (
<LoadingIndicator message="Encoding image..." />
) : (
<div className="movie-images">
{renderFrontImage()}
{renderBackImage()}
</div>
)}
</div>
<MovieEditPanel
onSubmit={onSave}
onCancel={() => history.push("/movies")}
onDelete={() => {}}
setFrontImage={setFrontImage}
setBackImage={setBackImage}
onImageEncoding={onImageEncoding}
/>
</div>
</div>
);
};
export default MovieCreate;

View File

@@ -6,7 +6,7 @@ import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { TextField, URLField } from "src/utils/field"; import { TextField, URLField } from "src/utils/field";
interface IMovieDetailsPanel { interface IMovieDetailsPanel {
movie: Partial<GQL.MovieDataFragment>; movie: GQL.MovieDataFragment;
} }
export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => { export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {

View File

@@ -386,7 +386,12 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
<Prompt <Prompt
when={formik.dirty} when={formik.dirty}
message={intl.formatMessage({ id: "dialogs.unsaved_changes" })} message={(location, action) => {
// Check if it's a redirect after movie creation
if (action === "PUSH" && location.pathname.startsWith("/movies/"))
return true;
return intl.formatMessage({ id: "dialogs.unsaved_changes" });
}}
/> />
<Form noValidate onSubmit={formik.handleSubmit} id="movie-edit"> <Form noValidate onSubmit={formik.handleSubmit} id="movie-edit">

View File

@@ -5,12 +5,12 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { SceneList } from "src/components/Scenes/SceneList"; import { SceneList } from "src/components/Scenes/SceneList";
interface IMovieScenesPanel { interface IMovieScenesPanel {
movie: Partial<GQL.MovieDataFragment>; movie: GQL.MovieDataFragment;
} }
export const MovieScenesPanel: React.FC<IMovieScenesPanel> = ({ movie }) => { export const MovieScenesPanel: React.FC<IMovieScenesPanel> = ({ movie }) => {
function filterHook(filter: ListFilterModel) { function filterHook(filter: ListFilterModel) {
const movieValue = { id: movie.id!, label: movie.name! }; const movieValue = { id: movie.id, label: movie.name };
// if movie is already present, then we modify it, otherwise add // if movie is already present, then we modify it, otherwise add
let movieCriterion = filter.criteria.find((c) => { let movieCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "movies"; return c.criterionOption.type === "movies";

View File

@@ -1,11 +1,13 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { Movie } from "./MovieDetails/Movie"; import Movie from "./MovieDetails/Movie";
import MovieCreate from "./MovieDetails/MovieCreate";
import { MovieList } from "./MovieList"; import { MovieList } from "./MovieList";
const Movies = () => ( const Movies = () => (
<Switch> <Switch>
<Route exact path="/movies" component={MovieList} /> <Route exact path="/movies" component={MovieList} />
<Route exact path="/movies/new" component={MovieCreate} />
<Route path="/movies/:id/:tab?" component={Movie} /> <Route path="/movies/:id/:tab?" component={Movie} />
</Switch> </Switch>
); );

View File

@@ -1,7 +1,3 @@
.movie-details {
max-width: 1200px;
}
.movie-card { .movie-card {
width: 240px; width: 240px;

View File

@@ -27,23 +27,21 @@ import { PerformerMoviesPanel } from "./PerformerMoviesPanel";
import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel";
import { PerformerEditPanel } from "./PerformerEditPanel"; import { PerformerEditPanel } from "./PerformerEditPanel";
interface IProps {
performer: GQL.PerformerDataFragment;
}
interface IPerformerParams { interface IPerformerParams {
id?: string;
tab?: string; tab?: string;
} }
export const Performer: React.FC = () => { const PerformerPage: React.FC<IProps> = ({ performer }) => {
const Toast = useToast(); const Toast = useToast();
const history = useHistory(); const history = useHistory();
const intl = useIntl(); const intl = useIntl();
const { tab = "details", id = "new" } = useParams<IPerformerParams>(); const { tab = "details" } = useParams<IPerformerParams>();
const isNew = id === "new";
// Performer state
const [imagePreview, setImagePreview] = useState<string | null>(); const [imagePreview, setImagePreview] = useState<string | null>();
const [imageEncoding, setImageEncoding] = useState<boolean>(false); const [imageEncoding, setImageEncoding] = useState<boolean>(false);
const { data, loading: performerLoading, error } = useFindPerformer(id);
const performer = data?.findPerformer || ({} as Partial<GQL.Performer>);
// if undefined then get the existing image // if undefined then get the existing image
// if null then get the default (no) image // if null then get the default (no) image
@@ -51,7 +49,7 @@ export const Performer: React.FC = () => {
const activeImage = const activeImage =
imagePreview === undefined imagePreview === undefined
? performer.image_path ?? "" ? performer.image_path ?? ""
: imagePreview ?? (isNew ? "" : `${performer.image_path}&default=true`); : imagePreview ?? `${performer.image_path}&default=true`;
const lightboxImages = useMemo( const lightboxImages = useMemo(
() => [{ paths: { thumbnail: activeImage, image: activeImage } }], () => [{ paths: { thumbnail: activeImage, image: activeImage } }],
[activeImage] [activeImage]
@@ -61,12 +59,8 @@ export const Performer: React.FC = () => {
images: lightboxImages, images: lightboxImages,
}); });
// Network state
const [loading, setIsLoading] = useState(false);
const isLoading = performerLoading || loading;
const [updatePerformer] = usePerformerUpdate(); const [updatePerformer] = usePerformerUpdate();
const [deletePerformer] = usePerformerDestroy(); const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy();
const activeTabKey = const activeTabKey =
tab === "scenes" || tab === "scenes" ||
@@ -80,7 +74,7 @@ export const Performer: React.FC = () => {
const setActiveTabKey = (newTab: string | null) => { const setActiveTabKey = (newTab: string | null) => {
if (tab !== newTab) { if (tab !== newTab) {
const tabParam = newTab === "details" ? "" : `/${newTab}`; const tabParam = newTab === "details" ? "" : `/${newTab}`;
history.replace(`/performers/${id}${tabParam}`); history.replace(`/performers/${performer.id}${tabParam}`);
} }
}; };
@@ -131,19 +125,12 @@ export const Performer: React.FC = () => {
}; };
}); });
if (isLoading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!performer.id && !isNew)
return <ErrorMessage error={`No performer found with id ${id}.`} />;
async function onDelete() { async function onDelete() {
setIsLoading(true);
try { try {
await deletePerformer({ variables: { id } }); await deletePerformer({ variables: { id: performer.id } });
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
setIsLoading(false);
// redirect to performers page // redirect to performers page
history.push("/performers"); history.push("/performers");
@@ -175,7 +162,7 @@ export const Performer: React.FC = () => {
<PerformerEditPanel <PerformerEditPanel
performer={performer} performer={performer}
isVisible={activeTabKey === "edit"} isVisible={activeTabKey === "edit"}
isNew={isNew} isNew={false}
onDelete={onDelete} onDelete={onDelete}
onImageChange={onImageChange} onImageChange={onImageChange}
onImageEncoding={onImageEncoding} onImageEncoding={onImageEncoding}
@@ -303,39 +290,13 @@ export const Performer: React.FC = () => {
</span> </span>
); );
function renderPerformerImage() { if (isDestroying)
if (imageEncoding) {
return <LoadingIndicator message="Encoding image..." />;
}
if (activeImage) {
return <img className="performer" src={activeImage} alt="Performer" />;
}
}
if (isNew)
return ( return (
<div className="row new-view" id="performer-page"> <LoadingIndicator
<div className="performer-image-container col-md-4 text-center"> message={`Deleting performer ${performer.id}: ${performer.name}`}
{renderPerformerImage()} />
</div>
<div className="col-md-8">
<h2>Create Performer</h2>
<PerformerEditPanel
performer={performer}
isVisible
isNew
onDelete={onDelete}
onImageChange={onImageChange}
onImageEncoding={onImageEncoding}
/>
</div>
</div>
); );
if (!performer.id) {
return <LoadingIndicator />;
}
return ( return (
<div id="performer-page" className="row"> <div id="performer-page" className="row">
<div className="performer-image-container col-md-4 text-center"> <div className="performer-image-container col-md-4 text-center">
@@ -370,3 +331,17 @@ export const Performer: React.FC = () => {
</div> </div>
); );
}; };
const PerformerLoader: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const { data, loading, error } = useFindPerformer(id ?? "");
if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!data?.findPerformer)
return <ErrorMessage error={`No performer found with id ${id}.`} />;
return <PerformerPage performer={data.findPerformer} />;
};
export default PerformerLoader;

View File

@@ -0,0 +1,42 @@
import React, { useState } from "react";
import { LoadingIndicator } from "src/components/Shared";
import { PerformerEditPanel } from "./PerformerEditPanel";
const PerformerCreate: React.FC = () => {
const [imagePreview, setImagePreview] = useState<string | null>();
const [imageEncoding, setImageEncoding] = useState<boolean>(false);
const activeImage = imagePreview ?? "";
const onImageChange = (image?: string | null) => setImagePreview(image);
const onImageEncoding = (isEncoding = false) => setImageEncoding(isEncoding);
function renderPerformerImage() {
if (imageEncoding) {
return <LoadingIndicator message="Encoding image..." />;
}
if (activeImage) {
return <img className="performer" src={activeImage} alt="Performer" />;
}
}
return (
<div className="row new-view" id="performer-page">
<div className="performer-image-container col-md-4 text-center">
{renderPerformerImage()}
</div>
<div className="col-md-8">
<h2>Create Performer</h2>
<PerformerEditPanel
performer={{}}
isVisible
isNew
onImageChange={onImageChange}
onImageEncoding={onImageEncoding}
/>
</div>
</div>
);
};
export default PerformerCreate;

View File

@@ -7,7 +7,7 @@ import { TextField, URLField } from "src/utils/field";
import { genderToString } from "src/utils/gender"; import { genderToString } from "src/utils/gender";
interface IPerformerDetails { interface IPerformerDetails {
performer: Partial<GQL.PerformerDataFragment>; performer: GQL.PerformerDataFragment;
} }
export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
@@ -17,7 +17,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
const intl = useIntl(); const intl = useIntl();
function renderTagsField() { function renderTagsField() {
if (!performer.tags?.length) { if (!performer.tags.length) {
return; return;
} }
@@ -38,7 +38,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
} }
function renderStashIDs() { function renderStashIDs() {
if (!performer.stash_ids?.length) { if (!performer.stash_ids.length) {
return; return;
} }

View File

@@ -4,7 +4,7 @@ import { GalleryList } from "src/components/Galleries/GalleryList";
import { performerFilterHook } from "src/core/performers"; import { performerFilterHook } from "src/core/performers";
interface IPerformerDetailsProps { interface IPerformerDetailsProps {
performer: Partial<GQL.PerformerDataFragment>; performer: GQL.PerformerDataFragment;
} }
export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> = ({ export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> = ({

View File

@@ -4,7 +4,7 @@ import { ImageList } from "src/components/Images/ImageList";
import { performerFilterHook } from "src/core/performers"; import { performerFilterHook } from "src/core/performers";
interface IPerformerImagesPanel { interface IPerformerImagesPanel {
performer: Partial<GQL.PerformerDataFragment>; performer: GQL.PerformerDataFragment;
} }
export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> = ({ export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> = ({

View File

@@ -4,7 +4,7 @@ import { MovieList } from "src/components/Movies/MovieList";
import { performerFilterHook } from "src/core/performers"; import { performerFilterHook } from "src/core/performers";
interface IPerformerDetailsProps { interface IPerformerDetailsProps {
performer: Partial<GQL.PerformerDataFragment>; performer: GQL.PerformerDataFragment;
} }
export const PerformerMoviesPanel: React.FC<IPerformerDetailsProps> = ({ export const PerformerMoviesPanel: React.FC<IPerformerDetailsProps> = ({

View File

@@ -6,7 +6,7 @@ import { mutateMetadataAutoTag } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
interface IPerformerOperationsProps { interface IPerformerOperationsProps {
performer: Partial<GQL.PerformerDataFragment>; performer: GQL.PerformerDataFragment;
} }
export const PerformerOperationsPanel: React.FC<IPerformerOperationsProps> = ({ export const PerformerOperationsPanel: React.FC<IPerformerOperationsProps> = ({
@@ -15,9 +15,6 @@ export const PerformerOperationsPanel: React.FC<IPerformerOperationsProps> = ({
const Toast = useToast(); const Toast = useToast();
async function onAutoTag() { async function onAutoTag() {
if (!performer?.id) {
return;
}
try { try {
await mutateMetadataAutoTag({ performers: [performer.id] }); await mutateMetadataAutoTag({ performers: [performer.id] });
Toast.success({ content: "Started auto tagging" }); Toast.success({ content: "Started auto tagging" });

View File

@@ -4,7 +4,7 @@ import { SceneList } from "src/components/Scenes/SceneList";
import { performerFilterHook } from "src/core/performers"; import { performerFilterHook } from "src/core/performers";
interface IPerformerDetailsProps { interface IPerformerDetailsProps {
performer: Partial<GQL.PerformerDataFragment>; performer: GQL.PerformerDataFragment;
} }
export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({ export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({

View File

@@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { PersistanceLevel } from "src/hooks/ListHook"; import { PersistanceLevel } from "src/hooks/ListHook";
import { Performer } from "./PerformerDetails/Performer"; import Performer from "./PerformerDetails/Performer";
import PerformerCreate from "./PerformerDetails/PerformerCreate";
import { PerformerList } from "./PerformerList"; import { PerformerList } from "./PerformerList";
const Performers = () => ( const Performers = () => (
@@ -13,6 +14,7 @@ const Performers = () => (
<PerformerList persistState={PersistanceLevel.ALL} {...props} /> <PerformerList persistState={PersistanceLevel.ALL} {...props} />
)} )}
/> />
<Route path="/performers/new" component={PerformerCreate} />
<Route path="/performers/:id/:tab?" component={Performer} /> <Route path="/performers/:id/:tab?" component={Performer} />
</Switch> </Switch>
); );

View File

@@ -38,12 +38,12 @@ import { SceneGenerateDialog } from "../SceneGenerateDialog";
import { SceneVideoFilterPanel } from "./SceneVideoFilterPanel"; import { SceneVideoFilterPanel } from "./SceneVideoFilterPanel";
import { OrganizedButton } from "./OrganizedButton"; import { OrganizedButton } from "./OrganizedButton";
interface ISceneParams { interface IProps {
id?: string; scene: GQL.SceneDataFragment;
refetch: () => void;
} }
export const Scene: React.FC = () => { const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
const { id = "new" } = useParams<ISceneParams>();
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
@@ -53,18 +53,16 @@ export const Scene: React.FC = () => {
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp()); const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const { data, error, loading, refetch } = useFindScene(id);
const scene = data?.findScene;
const { const {
data: sceneStreams, data: sceneStreams,
error: streamableError, error: streamableError,
loading: streamableLoading, loading: streamableLoading,
} = useSceneStreams(id); } = useSceneStreams(scene.id);
const [oLoading, setOLoading] = useState(false); const [oLoading, setOLoading] = useState(false);
const [incrementO] = useSceneIncrementO(scene?.id ?? "0"); const [incrementO] = useSceneIncrementO(scene.id);
const [decrementO] = useSceneDecrementO(scene?.id ?? "0"); const [decrementO] = useSceneDecrementO(scene.id);
const [resetO] = useSceneResetO(scene?.id ?? "0"); const [resetO] = useSceneResetO(scene.id);
const [organizedLoading, setOrganizedLoading] = useState(false); const [organizedLoading, setOrganizedLoading] = useState(false);
@@ -85,7 +83,7 @@ export const Scene: React.FC = () => {
const queryParams = queryString.parse(location.search); const queryParams = queryString.parse(location.search);
const autoplay = queryParams?.autoplay === "true"; const autoplay = queryParams?.autoplay === "true";
const currentQueueIndex = queueScenes.findIndex((s) => s.id === id); const currentQueueIndex = queueScenes.findIndex((s) => s.id === scene.id);
async function getQueueFilterScenes(filter: ListFilterModel) { async function getQueueFilterScenes(filter: ListFilterModel) {
const query = await queryFindScenes(filter); const query = await queryFindScenes(filter);
@@ -113,7 +111,7 @@ export const Scene: React.FC = () => {
useEffect(() => { useEffect(() => {
setRerenderPlayer(true); setRerenderPlayer(true);
}, [id]); }, [scene.id]);
useEffect(() => { useEffect(() => {
setSceneQueue(SceneQueue.fromQueryParameters(location.search)); setSceneQueue(SceneQueue.fromQueryParameters(location.search));
@@ -142,8 +140,8 @@ export const Scene: React.FC = () => {
await updateScene({ await updateScene({
variables: { variables: {
input: { input: {
id: scene?.id ?? "", id: scene.id,
organized: !scene?.organized, organized: !scene.organized,
}, },
}, },
}); });
@@ -192,10 +190,6 @@ export const Scene: React.FC = () => {
} }
async function onRescan() { async function onRescan() {
if (!scene) {
return;
}
await mutateMetadataScan({ await mutateMetadataScan({
paths: [scene.path], paths: [scene.path],
}); });
@@ -214,10 +208,6 @@ export const Scene: React.FC = () => {
} }
async function onGenerateScreenshot(at?: number) { async function onGenerateScreenshot(at?: number) {
if (!scene) {
return;
}
await generateScreenshot({ await generateScreenshot({
variables: { variables: {
id: scene.id, id: scene.id,
@@ -323,7 +313,7 @@ export const Scene: React.FC = () => {
} }
function maybeRenderDeleteDialog() { function maybeRenderDeleteDialog() {
if (isDeleteAlertOpen && scene) { if (isDeleteAlertOpen) {
return ( return (
<DeleteScenesDialog selected={[scene]} onClose={onDeleteDialogClosed} /> <DeleteScenesDialog selected={[scene]} onClose={onDeleteDialogClosed} />
); );
@@ -331,7 +321,7 @@ export const Scene: React.FC = () => {
} }
function maybeRenderSceneGenerateDialog() { function maybeRenderSceneGenerateDialog() {
if (isGenerateDialogOpen && scene) { if (isGenerateDialogOpen) {
return ( return (
<SceneGenerateDialog <SceneGenerateDialog
selectedIds={[scene.id]} selectedIds={[scene.id]}
@@ -343,215 +333,204 @@ export const Scene: React.FC = () => {
} }
} }
function renderOperations() { const renderOperations = () => (
return ( <Dropdown>
<Dropdown> <Dropdown.Toggle
<Dropdown.Toggle variant="secondary"
variant="secondary" id="operation-menu"
id="operation-menu" className="minimal"
className="minimal" title="Operations"
title="Operations"
>
<Icon icon="ellipsis-v" />
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
<Dropdown.Item
key="rescan"
className="bg-secondary text-white"
onClick={() => onRescan()}
>
<FormattedMessage id="actions.rescan" />
</Dropdown.Item>
<Dropdown.Item
key="generate"
className="bg-secondary text-white"
onClick={() => setIsGenerateDialogOpen(true)}
>
<FormattedMessage id="actions.generate" />
</Dropdown.Item>
<Dropdown.Item
key="generate-screenshot"
className="bg-secondary text-white"
onClick={() =>
onGenerateScreenshot(JWUtils.getPlayer().getPosition())
}
>
<FormattedMessage id="actions.generate_thumb_from_current" />
</Dropdown.Item>
<Dropdown.Item
key="generate-default"
className="bg-secondary text-white"
onClick={() => onGenerateScreenshot()}
>
<FormattedMessage id="actions.generate_thumb_default" />
</Dropdown.Item>
<Dropdown.Item
key="delete-scene"
className="bg-secondary text-white"
onClick={() => setIsDeleteAlertOpen(true)}
>
<FormattedMessage
id="actions.delete_entity"
values={{ entityType: intl.formatMessage({ id: "scene" }) }}
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
}
function renderTabs() {
if (!scene) {
return;
}
return (
<Tab.Container
activeKey={activeTabKey}
onSelect={(k) => k && setActiveTabKey(k)}
> >
<div> <Icon icon="ellipsis-v" />
<Nav variant="tabs" className="mr-auto"> </Dropdown.Toggle>
<Nav.Item> <Dropdown.Menu className="bg-secondary text-white">
<Nav.Link eventKey="scene-details-panel"> <Dropdown.Item
<FormattedMessage id="scenes" /> key="rescan"
</Nav.Link> className="bg-secondary text-white"
</Nav.Item> onClick={() => onRescan()}
{(queueScenes ?? []).length > 0 ? ( >
<Nav.Item> <FormattedMessage id="actions.rescan" />
<Nav.Link eventKey="scene-queue-panel"> </Dropdown.Item>
<FormattedMessage id="queue" /> <Dropdown.Item
</Nav.Link> key="generate"
</Nav.Item> className="bg-secondary text-white"
) : ( onClick={() => setIsGenerateDialogOpen(true)}
"" >
)} <FormattedMessage id="actions.generate" />
<Nav.Item> </Dropdown.Item>
<Nav.Link eventKey="scene-markers-panel"> <Dropdown.Item
<FormattedMessage id="markers" /> key="generate-screenshot"
</Nav.Link> className="bg-secondary text-white"
</Nav.Item> onClick={() =>
{scene.movies.length > 0 ? ( onGenerateScreenshot(JWUtils.getPlayer().getPosition())
<Nav.Item> }
<Nav.Link eventKey="scene-movie-panel"> >
<FormattedMessage <FormattedMessage id="actions.generate_thumb_from_current" />
id="countables.movies" </Dropdown.Item>
values={{ count: scene.movies.length }} <Dropdown.Item
/> key="generate-default"
</Nav.Link> className="bg-secondary text-white"
</Nav.Item> onClick={() => onGenerateScreenshot()}
) : ( >
"" <FormattedMessage id="actions.generate_thumb_default" />
)} </Dropdown.Item>
{scene.galleries.length >= 1 ? ( <Dropdown.Item
<Nav.Item> key="delete-scene"
<Nav.Link eventKey="scene-galleries-panel"> className="bg-secondary text-white"
<FormattedMessage onClick={() => setIsDeleteAlertOpen(true)}
id="countables.galleries" >
values={{ count: scene.galleries.length }} <FormattedMessage
/> id="actions.delete_entity"
</Nav.Link> values={{ entityType: intl.formatMessage({ id: "scene" }) }}
</Nav.Item> />
) : undefined} </Dropdown.Item>
<Nav.Item> </Dropdown.Menu>
<Nav.Link eventKey="scene-video-filter-panel"> </Dropdown>
<FormattedMessage id="effect_filters.name" /> );
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scene-file-info-panel">
<FormattedMessage id="file_info" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scene-edit-panel">
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item>
<ButtonGroup className="ml-auto">
<Nav.Item className="ml-auto">
<ExternalPlayerButton scene={scene} />
</Nav.Item>
<Nav.Item className="ml-auto">
<OCounterButton
loading={oLoading}
value={scene.o_counter || 0}
onIncrement={onIncrementClick}
onDecrement={onDecrementClick}
onReset={onResetClick}
/>
</Nav.Item>
<Nav.Item>
<OrganizedButton
loading={organizedLoading}
organized={scene.organized}
onClick={onOrganizedClick}
/>
</Nav.Item>
<Nav.Item>{renderOperations()}</Nav.Item>
</ButtonGroup>
</Nav>
</div>
<Tab.Content> const renderTabs = () => (
<Tab.Pane eventKey="scene-details-panel"> <Tab.Container
<SceneDetailPanel scene={scene} /> activeKey={activeTabKey}
</Tab.Pane> onSelect={(k) => k && setActiveTabKey(k)}
<Tab.Pane eventKey="scene-queue-panel"> >
<QueueViewer <div>
scenes={queueScenes} <Nav variant="tabs" className="mr-auto">
currentID={scene.id} <Nav.Item>
onSceneClicked={(sceneID) => playScene(sceneID)} <Nav.Link eventKey="scene-details-panel">
onNext={onQueueNext} <FormattedMessage id="scenes" />
onPrevious={onQueuePrevious} </Nav.Link>
onRandom={onQueueRandom} </Nav.Item>
start={queueStart} {(queueScenes ?? []).length > 0 ? (
hasMoreScenes={queueHasMoreScenes()} <Nav.Item>
onLessScenes={() => onQueueLessScenes()} <Nav.Link eventKey="scene-queue-panel">
onMoreScenes={() => onQueueMoreScenes()} <FormattedMessage id="queue" />
/> </Nav.Link>
</Tab.Pane> </Nav.Item>
<Tab.Pane eventKey="scene-markers-panel"> ) : (
<SceneMarkersPanel ""
scene={scene}
onClickMarker={onClickMarker}
isVisible={activeTabKey === "scene-markers-panel"}
/>
</Tab.Pane>
<Tab.Pane eventKey="scene-movie-panel">
<SceneMoviePanel scene={scene} />
</Tab.Pane>
{scene.galleries.length === 1 && (
<Tab.Pane eventKey="scene-galleries-panel">
<GalleryViewer galleryId={scene.galleries[0].id} />
</Tab.Pane>
)} )}
{scene.galleries.length > 1 && ( <Nav.Item>
<Tab.Pane eventKey="scene-galleries-panel"> <Nav.Link eventKey="scene-markers-panel">
<SceneGalleriesPanel galleries={scene.galleries} /> <FormattedMessage id="markers" />
</Tab.Pane> </Nav.Link>
</Nav.Item>
{scene.movies.length > 0 ? (
<Nav.Item>
<Nav.Link eventKey="scene-movie-panel">
<FormattedMessage
id="countables.movies"
values={{ count: scene.movies.length }}
/>
</Nav.Link>
</Nav.Item>
) : (
""
)} )}
<Tab.Pane eventKey="scene-video-filter-panel"> {scene.galleries.length >= 1 ? (
<SceneVideoFilterPanel scene={scene} /> <Nav.Item>
<Nav.Link eventKey="scene-galleries-panel">
<FormattedMessage
id="countables.galleries"
values={{ count: scene.galleries.length }}
/>
</Nav.Link>
</Nav.Item>
) : undefined}
<Nav.Item>
<Nav.Link eventKey="scene-video-filter-panel">
<FormattedMessage id="effect_filters.name" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scene-file-info-panel">
<FormattedMessage id="file_info" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scene-edit-panel">
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item>
<ButtonGroup className="ml-auto">
<Nav.Item className="ml-auto">
<ExternalPlayerButton scene={scene} />
</Nav.Item>
<Nav.Item className="ml-auto">
<OCounterButton
loading={oLoading}
value={scene.o_counter || 0}
onIncrement={onIncrementClick}
onDecrement={onDecrementClick}
onReset={onResetClick}
/>
</Nav.Item>
<Nav.Item>
<OrganizedButton
loading={organizedLoading}
organized={scene.organized}
onClick={onOrganizedClick}
/>
</Nav.Item>
<Nav.Item>{renderOperations()}</Nav.Item>
</ButtonGroup>
</Nav>
</div>
<Tab.Content>
<Tab.Pane eventKey="scene-details-panel">
<SceneDetailPanel scene={scene} />
</Tab.Pane>
<Tab.Pane eventKey="scene-queue-panel">
<QueueViewer
scenes={queueScenes}
currentID={scene.id}
onSceneClicked={(sceneID) => playScene(sceneID)}
onNext={onQueueNext}
onPrevious={onQueuePrevious}
onRandom={onQueueRandom}
start={queueStart}
hasMoreScenes={queueHasMoreScenes()}
onLessScenes={() => onQueueLessScenes()}
onMoreScenes={() => onQueueMoreScenes()}
/>
</Tab.Pane>
<Tab.Pane eventKey="scene-markers-panel">
<SceneMarkersPanel
scene={scene}
onClickMarker={onClickMarker}
isVisible={activeTabKey === "scene-markers-panel"}
/>
</Tab.Pane>
<Tab.Pane eventKey="scene-movie-panel">
<SceneMoviePanel scene={scene} />
</Tab.Pane>
{scene.galleries.length === 1 && (
<Tab.Pane eventKey="scene-galleries-panel">
<GalleryViewer galleryId={scene.galleries[0].id} />
</Tab.Pane> </Tab.Pane>
<Tab.Pane )}
className="file-info-panel" {scene.galleries.length > 1 && (
eventKey="scene-file-info-panel" <Tab.Pane eventKey="scene-galleries-panel">
> <SceneGalleriesPanel galleries={scene.galleries} />
<SceneFileInfoPanel scene={scene} />
</Tab.Pane> </Tab.Pane>
<Tab.Pane eventKey="scene-edit-panel"> )}
<SceneEditPanel <Tab.Pane eventKey="scene-video-filter-panel">
isVisible={activeTabKey === "scene-edit-panel"} <SceneVideoFilterPanel scene={scene} />
scene={scene} </Tab.Pane>
onDelete={() => setIsDeleteAlertOpen(true)} <Tab.Pane className="file-info-panel" eventKey="scene-file-info-panel">
onUpdate={() => refetch()} <SceneFileInfoPanel scene={scene} />
/> </Tab.Pane>
</Tab.Pane> <Tab.Pane eventKey="scene-edit-panel">
</Tab.Content> <SceneEditPanel
</Tab.Container> isVisible={activeTabKey === "scene-edit-panel"}
); scene={scene}
} onDelete={() => setIsDeleteAlertOpen(true)}
onUpdate={() => refetch()}
/>
</Tab.Pane>
</Tab.Content>
</Tab.Container>
);
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
@@ -582,10 +561,8 @@ export const Scene: React.FC = () => {
return collapsed ? ">" : "<"; return collapsed ? ">" : "<";
} }
if (loading || streamableLoading) return <LoadingIndicator />; if (streamableLoading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (streamableError) return <ErrorMessage error={streamableError.message} />; if (streamableError) return <ErrorMessage error={streamableError.message} />;
if (!scene) return <ErrorMessage error={`No scene found with id ${id}.`} />;
return ( return (
<div className="row"> <div className="row">
@@ -638,3 +615,17 @@ export const Scene: React.FC = () => {
</div> </div>
); );
}; };
const SceneLoader: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const { data, loading, error, refetch } = useFindScene(id ?? "");
if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!data?.findScene)
return <ErrorMessage error={`No scene found with id ${id}.`} />;
return <ScenePage scene={data.findScene} refetch={refetch} />;
};
export default SceneLoader;

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { PersistanceLevel } from "src/hooks/ListHook"; import { PersistanceLevel } from "src/hooks/ListHook";
import { Scene } from "./SceneDetails/Scene"; import Scene from "./SceneDetails/Scene";
import { SceneList } from "./SceneList"; import { SceneList } from "./SceneList";
import { SceneMarkerList } from "./SceneMarkerList"; import { SceneMarkerList } from "./SceneMarkerList";

View File

@@ -2,14 +2,12 @@ import { Tabs, Tab } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom"; import { useParams, useHistory } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, 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 {
useFindStudio, useFindStudio,
useStudioUpdate, useStudioUpdate,
useStudioCreate,
useStudioDestroy, useStudioDestroy,
mutateMetadataAutoTag, mutateMetadataAutoTag,
} from "src/core/StashService"; } from "src/core/StashService";
@@ -30,32 +28,29 @@ import { StudioEditPanel } from "./StudioEditPanel";
import { StudioDetailsPanel } from "./StudioDetailsPanel"; import { StudioDetailsPanel } from "./StudioDetailsPanel";
import { StudioMoviesPanel } from "./StudioMoviesPanel"; import { StudioMoviesPanel } from "./StudioMoviesPanel";
interface IProps {
studio: GQL.StudioDataFragment;
}
interface IStudioParams { interface IStudioParams {
id?: string;
tab?: string; tab?: string;
} }
export const Studio: React.FC = () => { const StudioPage: React.FC<IProps> = ({ studio }) => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const { tab = "details", id = "new" } = useParams<IStudioParams>(); const { tab = "details" } = useParams<IStudioParams>();
const isNew = id === "new";
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew); const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Studio state // Studio state
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const { data, loading: studioLoading, error } = useFindStudio(id);
const studio = data?.findStudio;
const [isLoading, setIsLoading] = useState(false);
const [updateStudio] = useStudioUpdate(); const [updateStudio] = useStudioUpdate();
const [createStudio] = useStudioCreate(); const [deleteStudio] = useStudioDestroy({ id: studio.id });
const [deleteStudio] = useStudioDestroy({ id });
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
@@ -68,53 +63,29 @@ export const Studio: React.FC = () => {
}; };
}); });
useEffect(() => {
if (data && data.findStudio) {
setImage(undefined);
}
}, [data]);
function onImageLoad(imageData: string) { function onImageLoad(imageData: string) {
setImage(imageData); setImage(imageData);
} }
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
async function onSave( async function onSave(input: Partial<GQL.StudioUpdateInput>) {
input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput>
) {
try { try {
setIsLoading(true); const result = await updateStudio({
variables: {
if (!isNew) { input: input as GQL.StudioUpdateInput,
const result = await updateStudio({ },
variables: { });
input: input as GQL.StudioUpdateInput, if (result.data?.studioUpdate) {
}, setIsEditing(false);
});
if (result.data?.studioUpdate) {
setIsEditing(false);
}
} else {
const result = await createStudio({
variables: {
input: input as GQL.StudioCreateInput,
},
});
if (result.data?.studioCreate?.id) {
history.push(`/studios/${result.data.studioCreate.id}`);
setIsEditing(false);
}
} }
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} finally {
setIsLoading(false);
} }
} }
async function onAutoTag() { async function onAutoTag() {
if (!studio?.id) return; if (!studio.id) return;
try { try {
await mutateMetadataAutoTag({ studios: [studio.id] }); await mutateMetadataAutoTag({ studios: [studio.id] });
Toast.success({ Toast.success({
@@ -153,7 +124,7 @@ export const Studio: React.FC = () => {
id="dialogs.delete_confirm" id="dialogs.delete_confirm"
values={{ values={{
entityName: entityName:
studio?.name ?? studio.name ??
intl.formatMessage({ id: "studio" }).toLocaleLowerCase(), intl.formatMessage({ id: "studio" }).toLocaleLowerCase(),
}} }}
/> />
@@ -167,7 +138,7 @@ export const Studio: React.FC = () => {
} }
function renderImage() { function renderImage() {
let studioImage = studio?.image_path; let studioImage = studio.image_path;
if (isEditing) { if (isEditing) {
if (image === null) { if (image === null) {
studioImage = `${studioImage}&default=true`; studioImage = `${studioImage}&default=true`;
@@ -177,9 +148,7 @@ export const Studio: React.FC = () => {
} }
if (studioImage) { if (studioImage) {
return ( return <img className="logo" alt={studio.name} src={studioImage} />;
<img className="logo" alt={studio?.name ?? ""} src={studioImage} />
);
} }
} }
@@ -194,31 +163,13 @@ export const Studio: React.FC = () => {
const setActiveTabKey = (newTab: string | null) => { const setActiveTabKey = (newTab: string | null) => {
if (tab !== newTab) { if (tab !== newTab) {
const tabParam = newTab === "scenes" ? "" : `/${newTab}`; const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
history.replace(`/studios/${id}${tabParam}`); history.replace(`/studios/${studio.id}${tabParam}`);
} }
}; };
if (isLoading || studioLoading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!studio?.id && !isNew)
return <ErrorMessage error={`No studio found with id ${id}.`} />;
return ( return (
<div className="row"> <div className="row">
<div <div className="studio-detils col-md-4">
className={cx("studio-details", {
"col-md-4": !isNew,
"col-md-8": isNew,
})}
>
{isNew && (
<h2>
{intl.formatMessage(
{ id: "actions.add_entity" },
{ entityType: intl.formatMessage({ id: "studio" }) }
)}
</h2>
)}
<div className="text-center"> <div className="text-center">
{imageEncoding ? ( {imageEncoding ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
@@ -226,12 +177,12 @@ export const Studio: React.FC = () => {
renderImage() renderImage()
)} )}
</div> </div>
{!isEditing && !isNew && studio ? ( {!isEditing ? (
<> <>
<StudioDetailsPanel studio={studio} /> <StudioDetailsPanel studio={studio} />
<DetailsEditNavbar <DetailsEditNavbar
objectName={studio.name ?? intl.formatMessage({ id: "studio" })} objectName={studio.name ?? intl.formatMessage({ id: "studio" })}
isNew={isNew} isNew={false}
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={onToggleEdit} onToggleEdit={onToggleEdit}
onSave={() => {}} onSave={() => {}}
@@ -243,7 +194,7 @@ export const Studio: React.FC = () => {
</> </>
) : ( ) : (
<StudioEditPanel <StudioEditPanel
studio={studio ?? ({} as Partial<GQL.Studio>)} studio={studio}
onSubmit={onSave} onSubmit={onSave}
onCancel={onToggleEdit} onCancel={onToggleEdit}
onDelete={onDelete} onDelete={onDelete}
@@ -251,46 +202,58 @@ export const Studio: React.FC = () => {
/> />
)} )}
</div> </div>
{studio?.id && ( <div className="col col-md-8">
<div className="col col-md-8"> <Tabs
<Tabs id="studio-tabs"
id="studio-tabs" mountOnEnter
mountOnEnter unmountOnExit
unmountOnExit activeKey={activeTabKey}
activeKey={activeTabKey} onSelect={setActiveTabKey}
onSelect={setActiveTabKey} >
<Tab eventKey="scenes" title={intl.formatMessage({ id: "scenes" })}>
<StudioScenesPanel studio={studio} />
</Tab>
<Tab
eventKey="galleries"
title={intl.formatMessage({ id: "galleries" })}
> >
<Tab eventKey="scenes" title={intl.formatMessage({ id: "scenes" })}> <StudioGalleriesPanel studio={studio} />
<StudioScenesPanel studio={studio} /> </Tab>
</Tab> <Tab eventKey="images" title={intl.formatMessage({ id: "images" })}>
<Tab <StudioImagesPanel studio={studio} />
eventKey="galleries" </Tab>
title={intl.formatMessage({ id: "galleries" })} <Tab
> eventKey="performers"
<StudioGalleriesPanel studio={studio} /> title={intl.formatMessage({ id: "performers" })}
</Tab> >
<Tab eventKey="images" title={intl.formatMessage({ id: "images" })}> <StudioPerformersPanel studio={studio} />
<StudioImagesPanel studio={studio} /> </Tab>
</Tab> <Tab eventKey="movies" title={intl.formatMessage({ id: "movies" })}>
<Tab <StudioMoviesPanel studio={studio} />
eventKey="performers" </Tab>
title={intl.formatMessage({ id: "performers" })} <Tab
> eventKey="childstudios"
<StudioPerformersPanel studio={studio} /> title={intl.formatMessage({ id: "subsidiary_studios" })}
</Tab> >
<Tab eventKey="movies" title={intl.formatMessage({ id: "movies" })}> <StudioChildrenPanel studio={studio} />
<StudioMoviesPanel studio={studio} /> </Tab>
</Tab> </Tabs>
<Tab </div>
eventKey="childstudios"
title={intl.formatMessage({ id: "subsidiary_studios" })}
>
<StudioChildrenPanel studio={studio} />
</Tab>
</Tabs>
</div>
)}
{renderDeleteAlert()} {renderDeleteAlert()}
</div> </div>
); );
}; };
const StudioLoader: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const { data, loading, error } = useFindStudio(id ?? "");
if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!data?.findStudio)
return <ErrorMessage error={`No studio found with id ${id}.`} />;
return <StudioPage studio={data.findStudio} />;
};
export default StudioLoader;

View File

@@ -5,7 +5,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { StudioList } from "../StudioList"; import { StudioList } from "../StudioList";
interface IStudioChildrenPanel { interface IStudioChildrenPanel {
studio: Partial<GQL.StudioDataFragment>; studio: GQL.StudioDataFragment;
} }
export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({ export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({

View File

@@ -0,0 +1,79 @@
import React, { useState } from "react";
import { useHistory } from "react-router-dom";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { useStudioCreate } from "src/core/StashService";
import { ImageUtils } from "src/utils";
import { LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks";
import { StudioEditPanel } from "./StudioEditPanel";
const StudioCreate: React.FC = () => {
const history = useHistory();
const Toast = useToast();
const intl = useIntl();
// Studio state
const [image, setImage] = useState<string | null>();
const [createStudio] = useStudioCreate();
function onImageLoad(imageData: string) {
setImage(imageData);
}
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
async function onSave(
input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput>
) {
try {
const result = await createStudio({
variables: {
input: input as GQL.StudioCreateInput,
},
});
if (result.data?.studioCreate?.id) {
history.push(`/studios/${result.data.studioCreate.id}`);
}
} catch (e) {
Toast.error(e);
}
}
function renderImage() {
if (image) {
return <img className="logo" alt="" src={image} />;
}
}
return (
<div className="row">
<div className="studio-details col-md-8">
<h2>
{intl.formatMessage(
{ id: "actions.add_entity" },
{ entityType: intl.formatMessage({ id: "studio" }) }
)}
</h2>
<div className="text-center">
{imageEncoding ? (
<LoadingIndicator message="Encoding image..." />
) : (
renderImage()
)}
</div>
<StudioEditPanel
studio={{}}
onSubmit={onSave}
onImageChange={setImage}
onCancel={() => history.push("/studios")}
onDelete={() => {}}
/>
</div>
</div>
);
};
export default StudioCreate;

View File

@@ -7,7 +7,7 @@ import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { TextField, URLField } from "src/utils/field"; import { TextField, URLField } from "src/utils/field";
interface IStudioDetailsPanel { interface IStudioDetailsPanel {
studio: Partial<GQL.StudioDataFragment>; studio: GQL.StudioDataFragment;
} }
export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
@@ -31,7 +31,7 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
} }
function renderTagsList() { function renderTagsList() {
if (!studio?.aliases?.length) { if (!studio.aliases?.length) {
return; return;
} }

View File

@@ -211,7 +211,12 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
<> <>
<Prompt <Prompt
when={formik.dirty} when={formik.dirty}
message="Unsaved changes. Are you sure you want to leave?" message={(location, action) => {
// Check if it's a redirect after studio creation
if (action === "PUSH" && location.pathname.startsWith("/studios/"))
return true;
return intl.formatMessage({ id: "dialogs.unsaved_changes" });
}}
/> />
<Form noValidate onSubmit={formik.handleSubmit} id="studio-edit"> <Form noValidate onSubmit={formik.handleSubmit} id="studio-edit">

View File

@@ -4,7 +4,7 @@ import { GalleryList } from "src/components/Galleries/GalleryList";
import { studioFilterHook } from "src/core/studios"; import { studioFilterHook } from "src/core/studios";
interface IStudioGalleriesPanel { interface IStudioGalleriesPanel {
studio: Partial<GQL.StudioDataFragment>; studio: GQL.StudioDataFragment;
} }
export const StudioGalleriesPanel: React.FC<IStudioGalleriesPanel> = ({ export const StudioGalleriesPanel: React.FC<IStudioGalleriesPanel> = ({

View File

@@ -4,7 +4,7 @@ import { studioFilterHook } from "src/core/studios";
import { ImageList } from "src/components/Images/ImageList"; import { ImageList } from "src/components/Images/ImageList";
interface IStudioImagesPanel { interface IStudioImagesPanel {
studio: Partial<GQL.StudioDataFragment>; studio: GQL.StudioDataFragment;
} }
export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({ studio }) => { export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({ studio }) => {

View File

@@ -4,7 +4,7 @@ import { MovieList } from "src/components/Movies/MovieList";
import { studioFilterHook } from "src/core/studios"; import { studioFilterHook } from "src/core/studios";
interface IStudioMoviesPanel { interface IStudioMoviesPanel {
studio: Partial<GQL.StudioDataFragment>; studio: GQL.StudioDataFragment;
} }
export const StudioMoviesPanel: React.FC<IStudioMoviesPanel> = ({ studio }) => { export const StudioMoviesPanel: React.FC<IStudioMoviesPanel> = ({ studio }) => {

View File

@@ -5,7 +5,7 @@ import { PerformerList } from "src/components/Performers/PerformerList";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
interface IStudioPerformersPanel { interface IStudioPerformersPanel {
studio: Partial<GQL.StudioDataFragment>; studio: GQL.StudioDataFragment;
} }
export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({ export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({

View File

@@ -4,7 +4,7 @@ import { SceneList } from "src/components/Scenes/SceneList";
import { studioFilterHook } from "src/core/studios"; import { studioFilterHook } from "src/core/studios";
interface IStudioScenesPanel { interface IStudioScenesPanel {
studio: Partial<GQL.StudioDataFragment>; studio: GQL.StudioDataFragment;
} }
export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({ studio }) => { export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({ studio }) => {

View File

@@ -1,11 +1,13 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { Studio } from "./StudioDetails/Studio"; import Studio from "./StudioDetails/Studio";
import StudioCreate from "./StudioDetails/StudioCreate";
import { StudioList } from "./StudioList"; import { StudioList } from "./StudioList";
const Studios = () => ( const Studios = () => (
<Switch> <Switch>
<Route exact path="/studios" component={StudioList} /> <Route exact path="/studios" component={StudioList} />
<Route exact path="/studios/new" component={StudioCreate} />
<Route path="/studios/:id/:tab?" component={Studio} /> <Route path="/studios/:id/:tab?" component={Studio} />
</Switch> </Switch>
); );

View File

@@ -2,20 +2,19 @@ import { Tabs, Tab, Dropdown } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom"; import { useParams, useHistory } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, 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 {
useFindTag, useFindTag,
useTagUpdate, useTagUpdate,
useTagCreate,
useTagDestroy, useTagDestroy,
mutateMetadataAutoTag, mutateMetadataAutoTag,
} from "src/core/StashService"; } from "src/core/StashService";
import { ImageUtils } from "src/utils"; import { ImageUtils } from "src/utils";
import { import {
DetailsEditNavbar, DetailsEditNavbar,
ErrorMessage,
Modal, Modal,
LoadingIndicator, LoadingIndicator,
Icon, Icon,
@@ -31,33 +30,30 @@ import { TagDetailsPanel } from "./TagDetailsPanel";
import { TagEditPanel } from "./TagEditPanel"; import { TagEditPanel } from "./TagEditPanel";
import { TagMergeModal } from "./TagMergeDialog"; import { TagMergeModal } from "./TagMergeDialog";
interface IProps {
tag: GQL.TagDataFragment;
}
interface ITabParams { interface ITabParams {
id?: string;
tab?: string; tab?: string;
} }
export const Tag: React.FC = () => { const TagPage: React.FC<IProps> = ({ tag }) => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const { tab = "scenes", id = "new" } = useParams<ITabParams>(); const { tab = "scenes" } = useParams<ITabParams>();
const isNew = id === "new";
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew); const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [mergeType, setMergeType] = useState<"from" | "into" | undefined>(); const [mergeType, setMergeType] = useState<"from" | "into" | undefined>();
// Editing tag state // Editing tag state
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
// Tag state
const { data, error, loading } = useFindTag(id);
const tag = data?.findTag;
const [updateTag] = useTagUpdate(); const [updateTag] = useTagUpdate();
const [createTag] = useTagCreate(); const [deleteTag] = useTagDestroy({ id: tag.id });
const [deleteTag] = useTagDestroy({ id });
const activeTabKey = const activeTabKey =
tab === "markers" || tab === "markers" ||
@@ -69,7 +65,7 @@ export const Tag: React.FC = () => {
const setActiveTabKey = (newTab: string | null) => { const setActiveTabKey = (newTab: string | null) => {
if (tab !== newTab) { if (tab !== newTab) {
const tabParam = newTab === "scenes" ? "" : `/${newTab}`; const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
history.replace(`/tags/${id}${tabParam}`); history.replace(`/tags/${tag.id}${tabParam}`);
} }
}; };
@@ -88,35 +84,21 @@ export const Tag: React.FC = () => {
}; };
}); });
useEffect(() => {
if (data && data.findTag) {
setImage(undefined);
}
}, [data]);
function onImageLoad(imageData: string) { function onImageLoad(imageData: string) {
setImage(imageData); setImage(imageData);
} }
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
if (!isNew && !isEditing) {
if (!data?.findTag || loading) return <LoadingIndicator />;
if (error) return <div>{error.message}</div>;
}
function getTagInput( function getTagInput(
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
) { ) {
const ret: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { const ret: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = {
...input, ...input,
image, image,
id: tag.id,
}; };
if (!isNew) {
(ret as GQL.TagUpdateInput).id = id;
}
return ret; return ret;
} }
@@ -125,39 +107,22 @@ export const Tag: React.FC = () => {
) { ) {
try { try {
const oldRelations = { const oldRelations = {
parents: tag?.parents ?? [], parents: tag.parents ?? [],
children: tag?.children ?? [], children: tag.children ?? [],
}; };
if (!isNew) { const result = await updateTag({
const result = await updateTag({ variables: {
variables: { input: getTagInput(input) as GQL.TagUpdateInput,
input: getTagInput(input) as GQL.TagUpdateInput, },
}, });
if (result.data?.tagUpdate) {
setIsEditing(false);
const updated = result.data.tagUpdate;
tagRelationHook(updated, oldRelations, {
parents: updated.parents,
children: updated.children,
}); });
if (result.data?.tagUpdate) { return updated.id;
setIsEditing(false);
const updated = result.data.tagUpdate;
tagRelationHook(updated, oldRelations, {
parents: updated.parents,
children: updated.children,
});
return updated.id;
}
} else {
const result = await createTag({
variables: {
input: getTagInput(input) as GQL.TagCreateInput,
},
});
if (result.data?.tagCreate?.id) {
setIsEditing(false);
const created = result.data.tagCreate;
tagRelationHook(created, oldRelations, {
parents: created.parents,
children: created.children,
});
return created.id;
}
} }
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -165,7 +130,7 @@ export const Tag: React.FC = () => {
} }
async function onAutoTag() { async function onAutoTag() {
if (!tag?.id) return; if (!tag.id) return;
try { try {
await mutateMetadataAutoTag({ tags: [tag.id] }); await mutateMetadataAutoTag({ tags: [tag.id] });
Toast.success({ Toast.success({
@@ -179,8 +144,8 @@ export const Tag: React.FC = () => {
async function onDelete() { async function onDelete() {
try { try {
const oldRelations = { const oldRelations = {
parents: tag?.parents ?? [], parents: tag.parents ?? [],
children: tag?.children ?? [], children: tag.children ?? [],
}; };
await deleteTag(); await deleteTag();
tagRelationHook(tag as GQL.TagDataFragment, oldRelations, { tagRelationHook(tag as GQL.TagDataFragment, oldRelations, {
@@ -212,7 +177,7 @@ export const Tag: React.FC = () => {
id="dialogs.delete_confirm" id="dialogs.delete_confirm"
values={{ values={{
entityName: entityName:
tag?.name ?? tag.name ??
intl.formatMessage({ id: "tag" }).toLocaleLowerCase(), intl.formatMessage({ id: "tag" }).toLocaleLowerCase(),
}} }}
/> />
@@ -227,7 +192,7 @@ export const Tag: React.FC = () => {
} }
function renderImage() { function renderImage() {
let tagImage = tag?.image_path; let tagImage = tag.image_path;
if (isEditing) { if (isEditing) {
if (image === null) { if (image === null) {
tagImage = `${tagImage}&default=true`; tagImage = `${tagImage}&default=true`;
@@ -237,7 +202,7 @@ export const Tag: React.FC = () => {
} }
if (tagImage) { if (tagImage) {
return <img className="logo" alt={tag?.name ?? ""} src={tagImage} />; return <img className="logo" alt={tag.name} src={tagImage} />;
} }
} }
@@ -284,27 +249,22 @@ export const Tag: React.FC = () => {
return ( return (
<div className="row"> <div className="row">
<div <div className="tag-details col-md-4">
className={cx("tag-details", {
"col-md-4": !isNew,
"col-md-8": isNew,
})}
>
<div className="text-center logo-container"> <div className="text-center logo-container">
{imageEncoding ? ( {imageEncoding ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
) : ( ) : (
renderImage() renderImage()
)} )}
{!isNew && tag && <h2>{tag.name}</h2>} <h2>{tag.name}</h2>
</div> </div>
{!isEditing && !isNew && tag ? ( {!isEditing ? (
<> <>
<TagDetailsPanel tag={tag} /> <TagDetailsPanel tag={tag} />
{/* HACK - this is also rendered in the TagEditPanel */} {/* HACK - this is also rendered in the TagEditPanel */}
<DetailsEditNavbar <DetailsEditNavbar
objectName={tag.name ?? "tag"} objectName={tag.name}
isNew={isNew} isNew={false}
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={onToggleEdit} onToggleEdit={onToggleEdit}
onSave={() => {}} onSave={() => {}}
@@ -317,7 +277,7 @@ export const Tag: React.FC = () => {
</> </>
) : ( ) : (
<TagEditPanel <TagEditPanel
tag={tag ?? undefined} tag={tag}
onSubmit={onSave} onSubmit={onSave}
onCancel={onToggleEdit} onCancel={onToggleEdit}
onDelete={onDelete} onDelete={onDelete}
@@ -325,43 +285,52 @@ export const Tag: React.FC = () => {
/> />
)} )}
</div> </div>
{!isNew && tag && ( <div className="col col-md-8">
<div className="col col-md-8"> <Tabs
<Tabs id="tag-tabs"
id="tag-tabs" mountOnEnter
mountOnEnter activeKey={activeTabKey}
activeKey={activeTabKey} onSelect={setActiveTabKey}
onSelect={setActiveTabKey} >
<Tab eventKey="scenes" title={intl.formatMessage({ id: "scenes" })}>
<TagScenesPanel tag={tag} />
</Tab>
<Tab eventKey="images" title={intl.formatMessage({ id: "images" })}>
<TagImagesPanel tag={tag} />
</Tab>
<Tab
eventKey="galleries"
title={intl.formatMessage({ id: "galleries" })}
> >
<Tab eventKey="scenes" title={intl.formatMessage({ id: "scenes" })}> <TagGalleriesPanel tag={tag} />
<TagScenesPanel tag={tag} /> </Tab>
</Tab> <Tab eventKey="markers" title={intl.formatMessage({ id: "markers" })}>
<Tab eventKey="images" title={intl.formatMessage({ id: "images" })}> <TagMarkersPanel tag={tag} />
<TagImagesPanel tag={tag} /> </Tab>
</Tab> <Tab
<Tab eventKey="performers"
eventKey="galleries" title={intl.formatMessage({ id: "performers" })}
title={intl.formatMessage({ id: "galleries" })} >
> <TagPerformersPanel tag={tag} />
<TagGalleriesPanel tag={tag} /> </Tab>
</Tab> </Tabs>
<Tab </div>
eventKey="markers"
title={intl.formatMessage({ id: "markers" })}
>
<TagMarkersPanel tag={tag} />
</Tab>
<Tab
eventKey="performers"
title={intl.formatMessage({ id: "performers" })}
>
<TagPerformersPanel tag={tag} />
</Tab>
</Tabs>
</div>
)}
{renderDeleteAlert()} {renderDeleteAlert()}
{renderMergeDialog()} {renderMergeDialog()}
</div> </div>
); );
}; };
const TagLoader: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const { data, loading, error } = useFindTag(id ?? "");
if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!data?.findTag)
return <ErrorMessage error={`No tag found with id ${id}.`} />;
return <TagPage tag={data.findTag} />;
};
export default TagLoader;

View File

@@ -0,0 +1,91 @@
import React, { useState } from "react";
import { useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { useTagCreate } from "src/core/StashService";
import { ImageUtils } from "src/utils";
import { LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks";
import { tagRelationHook } from "src/core/tags";
import { TagEditPanel } from "./TagEditPanel";
const TagCreate: React.FC = () => {
const history = useHistory();
const Toast = useToast();
// Editing tag state
const [image, setImage] = useState<string | null>();
const [createTag] = useTagCreate();
function onImageLoad(imageData: string) {
setImage(imageData);
}
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
function getTagInput(
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
) {
const ret: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = {
...input,
image,
};
return ret;
}
async function onSave(
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
) {
try {
const oldRelations = {
parents: [],
children: [],
};
const result = await createTag({
variables: {
input: getTagInput(input) as GQL.TagCreateInput,
},
});
if (result.data?.tagCreate?.id) {
const created = result.data.tagCreate;
tagRelationHook(created, oldRelations, {
parents: created.parents,
children: created.children,
});
return created.id;
}
} catch (e) {
Toast.error(e);
}
}
function renderImage() {
if (image) {
return <img className="logo" alt="" src={image} />;
}
}
return (
<div className="row">
<div className="tag-details col-md-8">
<div className="text-center logo-container">
{imageEncoding ? (
<LoadingIndicator message="Encoding image..." />
) : (
renderImage()
)}
</div>
<TagEditPanel
onSubmit={onSave}
onCancel={() => history.push("/tags")}
onDelete={() => {}}
setImage={setImage}
/>
</div>
</div>
);
};
export default TagCreate;

View File

@@ -5,12 +5,12 @@ import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
interface ITagDetails { interface ITagDetails {
tag: Partial<GQL.TagDataFragment>; tag: GQL.TagDataFragment;
} }
export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => { export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => {
function renderAliasesField() { function renderAliasesField() {
if (!tag.aliases?.length) { if (!tag.aliases.length) {
return; return;
} }

View File

@@ -8,12 +8,12 @@ import {
import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList"; import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList";
interface ITagMarkersPanel { interface ITagMarkersPanel {
tag: Partial<GQL.TagDataFragment>; tag: GQL.TagDataFragment;
} }
export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({ tag }) => { export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({ tag }) => {
function filterHook(filter: ListFilterModel) { function filterHook(filter: ListFilterModel) {
const tagValue = { id: tag.id!, label: tag.name! }; const tagValue = { id: tag.id, label: tag.name };
// if tag is already present, then we modify it, otherwise add // if tag is already present, then we modify it, otherwise add
let tagCriterion = filter.criteria.find((c) => { let tagCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "tags"; return c.criterionOption.type === "tags";

View File

@@ -1,11 +1,13 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { Tag } from "./TagDetails/Tag"; import Tag from "./TagDetails/Tag";
import TagCreate from "./TagDetails/TagCreate";
import { TagList } from "./TagList"; import { TagList } from "./TagList";
const Tags = () => ( const Tags = () => (
<Switch> <Switch>
<Route exact path="/tags" component={TagList} /> <Route exact path="/tags" component={TagList} />
<Route exact path="/tags/new" component={TagCreate} />
<Route path="/tags/:id/:tab?" component={Tag} /> <Route path="/tags/:id/:tab?" component={Tag} />
</Switch> </Switch>
); );

View File

@@ -2,11 +2,12 @@ import { PerformersCriterion } from "src/models/list-filter/criteria/performers"
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
export const performerFilterHook = ( export const performerFilterHook = (performer: GQL.PerformerDataFragment) => {
performer: Partial<GQL.PerformerDataFragment>
) => {
return (filter: ListFilterModel) => { return (filter: ListFilterModel) => {
const performerValue = { id: performer.id!, label: performer.name! }; const performerValue = {
id: performer.id,
label: performer.name ?? `Performer ${performer.id}`,
};
// if performers is already present, then we modify it, otherwise add // if performers is already present, then we modify it, otherwise add
let performerCriterion = filter.criteria.find((c) => { let performerCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "performers"; return c.criterionOption.type === "performers";

View File

@@ -2,9 +2,9 @@ import * as GQL from "src/core/generated-graphql";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
export const studioFilterHook = (studio: Partial<GQL.StudioDataFragment>) => { export const studioFilterHook = (studio: GQL.StudioDataFragment) => {
return (filter: ListFilterModel) => { return (filter: ListFilterModel) => {
const studioValue = { id: studio.id!, label: studio.name! }; const studioValue = { id: studio.id, label: studio.name };
// if studio is already present, then we modify it, otherwise add // if studio is already present, then we modify it, otherwise add
let studioCriterion = filter.criteria.find((c) => { let studioCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "studios"; return c.criterionOption.type === "studios";