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 { Route, Switch } from "react-router-dom";
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";
const Galleries = () => (
@@ -13,6 +14,7 @@ const Galleries = () => (
<GalleryList {...props} persistState={PersistanceLevel.ALL} />
)}
/>
<Route exact path="/galleries/new" component={GalleryCreate} />
<Route path="/galleries/:id/:tab?" component={Gallery} />
</Switch>
);

View File

@@ -2,6 +2,7 @@ import { Tab, Nav, Dropdown } from "react-bootstrap";
import React, { useEffect, useState } from "react";
import { useParams, useHistory, Link } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import {
mutateMetadataScan,
useFindGallery,
@@ -9,7 +10,7 @@ import {
} from "src/core/StashService";
import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared";
import { TextUtils } from "src/utils";
import * as Mousetrap from "mousetrap";
import Mousetrap from "mousetrap";
import { useToast } from "src/hooks";
import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton";
import { GalleryEditPanel } from "./GalleryEditPanel";
@@ -20,27 +21,26 @@ import { GalleryAddPanel } from "./GalleryAddPanel";
import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel";
import { GalleryScenesPanel } from "./GalleryScenesPanel";
interface IProps {
gallery: GQL.GalleryDataFragment;
}
interface IGalleryParams {
id?: string;
tab?: string;
}
export const Gallery: React.FC = () => {
const { tab = "images", id = "new" } = useParams<IGalleryParams>();
export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
const { tab = "images" } = useParams<IGalleryParams>();
const history = useHistory();
const Toast = useToast();
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 activeRightTabKey = tab === "images" || tab === "add" ? tab : "images";
const setActiveRightTabKey = (newTab: string | null) => {
if (tab !== 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({
variables: {
input: {
id: gallery?.id ?? "",
organized: !gallery?.organized,
id: gallery.id,
organized: !gallery.organized,
},
},
});
@@ -118,7 +118,7 @@ export const Gallery: React.FC = () => {
<Icon icon="ellipsis-v" />
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{gallery?.path ? (
{gallery.path ? (
<Dropdown.Item
key="rescan"
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 (
<div className="row">
{maybeRenderDeleteDialog()}
@@ -323,3 +294,17 @@ export const Gallery: React.FC = () => {
</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";
interface IGalleryAddProps {
gallery: Partial<GQL.GalleryDataFragment>;
gallery: GQL.GalleryDataFragment;
}
export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
@@ -20,7 +20,7 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
function filterHook(filter: ListFilterModel) {
const galleryValue = {
id: gallery.id!,
id: gallery.id,
label: gallery.title ?? TextUtils.fileNameFromPath(gallery.path ?? ""),
};
// 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";
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() {
if (!props.gallery.details || props.gallery.details === "") return;
if (!gallery.details) return;
return (
<>
<h6>Details</h6>
<p className="pre">{props.gallery.details}</p>
<p className="pre">{gallery.details}</p>
</>
);
}
function renderTags() {
if (!props.gallery.tags || props.gallery.tags.length === 0) return;
const tags = props.gallery.tags.map((tag) => (
if (gallery.tags.length === 0) return;
const tags = gallery.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} tagType="gallery" />
));
return (
@@ -37,14 +39,13 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = (props) => {
}
function renderPerformers() {
if (!props.gallery.performers || props.gallery.performers.length === 0)
return;
const performers = sortPerformers(props.gallery.performers);
if (gallery.performers.length === 0) return;
const performers = sortPerformers(gallery.performers);
const cards = performers.map((performer) => (
<PerformerCard
key={performer.id}
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
const galleryDetailsWidth = props.gallery.studio ? "col-9" : "col-12";
const title =
props.gallery.title ?? TextUtils.fileNameFromPath(props.gallery.path ?? "");
const galleryDetailsWidth = gallery.studio ? "col-9" : "col-12";
const title = gallery.title ?? TextUtils.fileNameFromPath(gallery.path ?? "");
return (
<>
@@ -70,29 +70,29 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = (props) => {
<h3 className="gallery-header d-xl-none">
<TruncatedText text={title} />
</h3>
{props.gallery.date ? (
{gallery.date ? (
<h5>
<FormattedDate
value={props.gallery.date}
value={gallery.date}
format="long"
timeZone="utc"
/>
</h5>
) : undefined}
{props.gallery.rating ? (
{gallery.rating ? (
<h6>
Rating: <RatingStars value={props.gallery.rating} />
Rating: <RatingStars value={gallery.rating} />
</h6>
) : (
""
)}
</div>
{props.gallery.studio && (
{gallery.studio && (
<div className="col-3 d-xl-none">
<Link to={`/studios/${props.gallery.studio.id}`}>
<Link to={`/studios/${gallery.studio.id}`}>
<img
src={props.gallery.studio.image_path ?? ""}
alt={`${props.gallery.studio.name} logo`}
src={gallery.studio.image_path ?? ""}
alt={`${gallery.studio.name} logo`}
className="studio-logo float-right"
/>
</Link>

View File

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

View File

@@ -386,7 +386,12 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
<Prompt
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">

View File

@@ -5,12 +5,12 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { SceneList } from "src/components/Scenes/SceneList";
interface IMovieScenesPanel {
movie: Partial<GQL.MovieDataFragment>;
movie: GQL.MovieDataFragment;
}
export const MovieScenesPanel: React.FC<IMovieScenesPanel> = ({ movie }) => {
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
let movieCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "movies";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,12 +38,12 @@ import { SceneGenerateDialog } from "../SceneGenerateDialog";
import { SceneVideoFilterPanel } from "./SceneVideoFilterPanel";
import { OrganizedButton } from "./OrganizedButton";
interface ISceneParams {
id?: string;
interface IProps {
scene: GQL.SceneDataFragment;
refetch: () => void;
}
export const Scene: React.FC = () => {
const { id = "new" } = useParams<ISceneParams>();
const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
const location = useLocation();
const history = useHistory();
const Toast = useToast();
@@ -53,18 +53,16 @@ export const Scene: React.FC = () => {
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
const [collapsed, setCollapsed] = useState(false);
const { data, error, loading, refetch } = useFindScene(id);
const scene = data?.findScene;
const {
data: sceneStreams,
error: streamableError,
loading: streamableLoading,
} = useSceneStreams(id);
} = useSceneStreams(scene.id);
const [oLoading, setOLoading] = useState(false);
const [incrementO] = useSceneIncrementO(scene?.id ?? "0");
const [decrementO] = useSceneDecrementO(scene?.id ?? "0");
const [resetO] = useSceneResetO(scene?.id ?? "0");
const [incrementO] = useSceneIncrementO(scene.id);
const [decrementO] = useSceneDecrementO(scene.id);
const [resetO] = useSceneResetO(scene.id);
const [organizedLoading, setOrganizedLoading] = useState(false);
@@ -85,7 +83,7 @@ export const Scene: React.FC = () => {
const queryParams = queryString.parse(location.search);
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) {
const query = await queryFindScenes(filter);
@@ -113,7 +111,7 @@ export const Scene: React.FC = () => {
useEffect(() => {
setRerenderPlayer(true);
}, [id]);
}, [scene.id]);
useEffect(() => {
setSceneQueue(SceneQueue.fromQueryParameters(location.search));
@@ -142,8 +140,8 @@ export const Scene: React.FC = () => {
await updateScene({
variables: {
input: {
id: scene?.id ?? "",
organized: !scene?.organized,
id: scene.id,
organized: !scene.organized,
},
},
});
@@ -192,10 +190,6 @@ export const Scene: React.FC = () => {
}
async function onRescan() {
if (!scene) {
return;
}
await mutateMetadataScan({
paths: [scene.path],
});
@@ -214,10 +208,6 @@ export const Scene: React.FC = () => {
}
async function onGenerateScreenshot(at?: number) {
if (!scene) {
return;
}
await generateScreenshot({
variables: {
id: scene.id,
@@ -323,7 +313,7 @@ export const Scene: React.FC = () => {
}
function maybeRenderDeleteDialog() {
if (isDeleteAlertOpen && scene) {
if (isDeleteAlertOpen) {
return (
<DeleteScenesDialog selected={[scene]} onClose={onDeleteDialogClosed} />
);
@@ -331,7 +321,7 @@ export const Scene: React.FC = () => {
}
function maybeRenderSceneGenerateDialog() {
if (isGenerateDialogOpen && scene) {
if (isGenerateDialogOpen) {
return (
<SceneGenerateDialog
selectedIds={[scene.id]}
@@ -343,8 +333,7 @@ export const Scene: React.FC = () => {
}
}
function renderOperations() {
return (
const renderOperations = () => (
<Dropdown>
<Dropdown.Toggle
variant="secondary"
@@ -398,14 +387,8 @@ export const Scene: React.FC = () => {
</Dropdown.Menu>
</Dropdown>
);
}
function renderTabs() {
if (!scene) {
return;
}
return (
const renderTabs = () => (
<Tab.Container
activeKey={activeTabKey}
onSelect={(k) => k && setActiveTabKey(k)}
@@ -534,10 +517,7 @@ export const Scene: React.FC = () => {
<Tab.Pane eventKey="scene-video-filter-panel">
<SceneVideoFilterPanel scene={scene} />
</Tab.Pane>
<Tab.Pane
className="file-info-panel"
eventKey="scene-file-info-panel"
>
<Tab.Pane className="file-info-panel" eventKey="scene-file-info-panel">
<SceneFileInfoPanel scene={scene} />
</Tab.Pane>
<Tab.Pane eventKey="scene-edit-panel">
@@ -551,7 +531,6 @@ export const Scene: React.FC = () => {
</Tab.Content>
</Tab.Container>
);
}
// set up hotkeys
useEffect(() => {
@@ -582,10 +561,8 @@ export const Scene: React.FC = () => {
return collapsed ? ">" : "<";
}
if (loading || streamableLoading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (streamableLoading) return <LoadingIndicator />;
if (streamableError) return <ErrorMessage error={streamableError.message} />;
if (!scene) return <ErrorMessage error={`No scene found with id ${id}.`} />;
return (
<div className="row">
@@ -638,3 +615,17 @@ export const Scene: React.FC = () => {
</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 { Route, Switch } from "react-router-dom";
import { PersistanceLevel } from "src/hooks/ListHook";
import { Scene } from "./SceneDetails/Scene";
import Scene from "./SceneDetails/Scene";
import { SceneList } from "./SceneList";
import { SceneMarkerList } from "./SceneMarkerList";

View File

@@ -2,14 +2,12 @@ import { Tabs, Tab } from "react-bootstrap";
import React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import {
useFindStudio,
useStudioUpdate,
useStudioCreate,
useStudioDestroy,
mutateMetadataAutoTag,
} from "src/core/StashService";
@@ -30,32 +28,29 @@ import { StudioEditPanel } from "./StudioEditPanel";
import { StudioDetailsPanel } from "./StudioDetailsPanel";
import { StudioMoviesPanel } from "./StudioMoviesPanel";
interface IProps {
studio: GQL.StudioDataFragment;
}
interface IStudioParams {
id?: string;
tab?: string;
}
export const Studio: React.FC = () => {
const StudioPage: React.FC<IProps> = ({ studio }) => {
const history = useHistory();
const Toast = useToast();
const intl = useIntl();
const { tab = "details", id = "new" } = useParams<IStudioParams>();
const isNew = id === "new";
const { tab = "details" } = useParams<IStudioParams>();
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Studio state
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 [createStudio] = useStudioCreate();
const [deleteStudio] = useStudioDestroy({ id });
const [deleteStudio] = useStudioDestroy({ id: studio.id });
// set up hotkeys
useEffect(() => {
@@ -68,25 +63,14 @@ export const Studio: React.FC = () => {
};
});
useEffect(() => {
if (data && data.findStudio) {
setImage(undefined);
}
}, [data]);
function onImageLoad(imageData: string) {
setImage(imageData);
}
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
async function onSave(
input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput>
) {
async function onSave(input: Partial<GQL.StudioUpdateInput>) {
try {
setIsLoading(true);
if (!isNew) {
const result = await updateStudio({
variables: {
input: input as GQL.StudioUpdateInput,
@@ -95,26 +79,13 @@ export const Studio: React.FC = () => {
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) {
Toast.error(e);
} finally {
setIsLoading(false);
}
}
async function onAutoTag() {
if (!studio?.id) return;
if (!studio.id) return;
try {
await mutateMetadataAutoTag({ studios: [studio.id] });
Toast.success({
@@ -153,7 +124,7 @@ export const Studio: React.FC = () => {
id="dialogs.delete_confirm"
values={{
entityName:
studio?.name ??
studio.name ??
intl.formatMessage({ id: "studio" }).toLocaleLowerCase(),
}}
/>
@@ -167,7 +138,7 @@ export const Studio: React.FC = () => {
}
function renderImage() {
let studioImage = studio?.image_path;
let studioImage = studio.image_path;
if (isEditing) {
if (image === null) {
studioImage = `${studioImage}&default=true`;
@@ -177,9 +148,7 @@ export const Studio: React.FC = () => {
}
if (studioImage) {
return (
<img className="logo" alt={studio?.name ?? ""} src={studioImage} />
);
return <img className="logo" alt={studio.name} src={studioImage} />;
}
}
@@ -194,31 +163,13 @@ export const Studio: React.FC = () => {
const setActiveTabKey = (newTab: string | null) => {
if (tab !== 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 (
<div className="row">
<div
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="studio-detils col-md-4">
<div className="text-center">
{imageEncoding ? (
<LoadingIndicator message="Encoding image..." />
@@ -226,12 +177,12 @@ export const Studio: React.FC = () => {
renderImage()
)}
</div>
{!isEditing && !isNew && studio ? (
{!isEditing ? (
<>
<StudioDetailsPanel studio={studio} />
<DetailsEditNavbar
objectName={studio.name ?? intl.formatMessage({ id: "studio" })}
isNew={isNew}
isNew={false}
isEditing={isEditing}
onToggleEdit={onToggleEdit}
onSave={() => {}}
@@ -243,7 +194,7 @@ export const Studio: React.FC = () => {
</>
) : (
<StudioEditPanel
studio={studio ?? ({} as Partial<GQL.Studio>)}
studio={studio}
onSubmit={onSave}
onCancel={onToggleEdit}
onDelete={onDelete}
@@ -251,7 +202,6 @@ export const Studio: React.FC = () => {
/>
)}
</div>
{studio?.id && (
<div className="col col-md-8">
<Tabs
id="studio-tabs"
@@ -289,8 +239,21 @@ export const Studio: React.FC = () => {
</Tab>
</Tabs>
</div>
)}
{renderDeleteAlert()}
</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";
interface IStudioChildrenPanel {
studio: Partial<GQL.StudioDataFragment>;
studio: GQL.StudioDataFragment;
}
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";
interface IStudioDetailsPanel {
studio: Partial<GQL.StudioDataFragment>;
studio: GQL.StudioDataFragment;
}
export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
@@ -31,7 +31,7 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
}
function renderTagsList() {
if (!studio?.aliases?.length) {
if (!studio.aliases?.length) {
return;
}

View File

@@ -211,7 +211,12 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
<>
<Prompt
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">

View File

@@ -4,7 +4,7 @@ import { GalleryList } from "src/components/Galleries/GalleryList";
import { studioFilterHook } from "src/core/studios";
interface IStudioGalleriesPanel {
studio: Partial<GQL.StudioDataFragment>;
studio: GQL.StudioDataFragment;
}
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";
interface IStudioImagesPanel {
studio: Partial<GQL.StudioDataFragment>;
studio: GQL.StudioDataFragment;
}
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";
interface IStudioMoviesPanel {
studio: Partial<GQL.StudioDataFragment>;
studio: GQL.StudioDataFragment;
}
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";
interface IStudioPerformersPanel {
studio: Partial<GQL.StudioDataFragment>;
studio: GQL.StudioDataFragment;
}
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";
interface IStudioScenesPanel {
studio: Partial<GQL.StudioDataFragment>;
studio: GQL.StudioDataFragment;
}
export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({ studio }) => {

View File

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

View File

@@ -2,20 +2,19 @@ import { Tabs, Tab, Dropdown } from "react-bootstrap";
import React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import {
useFindTag,
useTagUpdate,
useTagCreate,
useTagDestroy,
mutateMetadataAutoTag,
} from "src/core/StashService";
import { ImageUtils } from "src/utils";
import {
DetailsEditNavbar,
ErrorMessage,
Modal,
LoadingIndicator,
Icon,
@@ -31,33 +30,30 @@ import { TagDetailsPanel } from "./TagDetailsPanel";
import { TagEditPanel } from "./TagEditPanel";
import { TagMergeModal } from "./TagMergeDialog";
interface IProps {
tag: GQL.TagDataFragment;
}
interface ITabParams {
id?: string;
tab?: string;
}
export const Tag: React.FC = () => {
const TagPage: React.FC<IProps> = ({ tag }) => {
const history = useHistory();
const Toast = useToast();
const intl = useIntl();
const { tab = "scenes", id = "new" } = useParams<ITabParams>();
const isNew = id === "new";
const { tab = "scenes" } = useParams<ITabParams>();
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [mergeType, setMergeType] = useState<"from" | "into" | undefined>();
// Editing tag state
const [image, setImage] = useState<string | null>();
// Tag state
const { data, error, loading } = useFindTag(id);
const tag = data?.findTag;
const [updateTag] = useTagUpdate();
const [createTag] = useTagCreate();
const [deleteTag] = useTagDestroy({ id });
const [deleteTag] = useTagDestroy({ id: tag.id });
const activeTabKey =
tab === "markers" ||
@@ -69,7 +65,7 @@ export const Tag: React.FC = () => {
const setActiveTabKey = (newTab: string | null) => {
if (tab !== 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) {
setImage(imageData);
}
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
if (!isNew && !isEditing) {
if (!data?.findTag || loading) return <LoadingIndicator />;
if (error) return <div>{error.message}</div>;
}
function getTagInput(
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
) {
const ret: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = {
...input,
image,
id: tag.id,
};
if (!isNew) {
(ret as GQL.TagUpdateInput).id = id;
}
return ret;
}
@@ -125,10 +107,9 @@ export const Tag: React.FC = () => {
) {
try {
const oldRelations = {
parents: tag?.parents ?? [],
children: tag?.children ?? [],
parents: tag.parents ?? [],
children: tag.children ?? [],
};
if (!isNew) {
const result = await updateTag({
variables: {
input: getTagInput(input) as GQL.TagUpdateInput,
@@ -143,29 +124,13 @@ export const Tag: React.FC = () => {
});
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) {
Toast.error(e);
}
}
async function onAutoTag() {
if (!tag?.id) return;
if (!tag.id) return;
try {
await mutateMetadataAutoTag({ tags: [tag.id] });
Toast.success({
@@ -179,8 +144,8 @@ export const Tag: React.FC = () => {
async function onDelete() {
try {
const oldRelations = {
parents: tag?.parents ?? [],
children: tag?.children ?? [],
parents: tag.parents ?? [],
children: tag.children ?? [],
};
await deleteTag();
tagRelationHook(tag as GQL.TagDataFragment, oldRelations, {
@@ -212,7 +177,7 @@ export const Tag: React.FC = () => {
id="dialogs.delete_confirm"
values={{
entityName:
tag?.name ??
tag.name ??
intl.formatMessage({ id: "tag" }).toLocaleLowerCase(),
}}
/>
@@ -227,7 +192,7 @@ export const Tag: React.FC = () => {
}
function renderImage() {
let tagImage = tag?.image_path;
let tagImage = tag.image_path;
if (isEditing) {
if (image === null) {
tagImage = `${tagImage}&default=true`;
@@ -237,7 +202,7 @@ export const Tag: React.FC = () => {
}
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 (
<div className="row">
<div
className={cx("tag-details", {
"col-md-4": !isNew,
"col-md-8": isNew,
})}
>
<div className="tag-details col-md-4">
<div className="text-center logo-container">
{imageEncoding ? (
<LoadingIndicator message="Encoding image..." />
) : (
renderImage()
)}
{!isNew && tag && <h2>{tag.name}</h2>}
<h2>{tag.name}</h2>
</div>
{!isEditing && !isNew && tag ? (
{!isEditing ? (
<>
<TagDetailsPanel tag={tag} />
{/* HACK - this is also rendered in the TagEditPanel */}
<DetailsEditNavbar
objectName={tag.name ?? "tag"}
isNew={isNew}
objectName={tag.name}
isNew={false}
isEditing={isEditing}
onToggleEdit={onToggleEdit}
onSave={() => {}}
@@ -317,7 +277,7 @@ export const Tag: React.FC = () => {
</>
) : (
<TagEditPanel
tag={tag ?? undefined}
tag={tag}
onSubmit={onSave}
onCancel={onToggleEdit}
onDelete={onDelete}
@@ -325,7 +285,6 @@ export const Tag: React.FC = () => {
/>
)}
</div>
{!isNew && tag && (
<div className="col col-md-8">
<Tabs
id="tag-tabs"
@@ -345,10 +304,7 @@ export const Tag: React.FC = () => {
>
<TagGalleriesPanel tag={tag} />
</Tab>
<Tab
eventKey="markers"
title={intl.formatMessage({ id: "markers" })}
>
<Tab eventKey="markers" title={intl.formatMessage({ id: "markers" })}>
<TagMarkersPanel tag={tag} />
</Tab>
<Tab
@@ -359,9 +315,22 @@ export const Tag: React.FC = () => {
</Tab>
</Tabs>
</div>
)}
{renderDeleteAlert()}
{renderMergeDialog()}
</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";
interface ITagDetails {
tag: Partial<GQL.TagDataFragment>;
tag: GQL.TagDataFragment;
}
export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => {
function renderAliasesField() {
if (!tag.aliases?.length) {
if (!tag.aliases.length) {
return;
}

View File

@@ -8,12 +8,12 @@ import {
import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList";
interface ITagMarkersPanel {
tag: Partial<GQL.TagDataFragment>;
tag: GQL.TagDataFragment;
}
export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({ tag }) => {
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
let tagCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "tags";

View File

@@ -1,11 +1,13 @@
import React from "react";
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";
const Tags = () => (
<Switch>
<Route exact path="/tags" component={TagList} />
<Route exact path="/tags/new" component={TagCreate} />
<Route path="/tags/:id/:tab?" component={Tag} />
</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 { ListFilterModel } from "src/models/list-filter/filter";
export const performerFilterHook = (
performer: Partial<GQL.PerformerDataFragment>
) => {
export const performerFilterHook = (performer: GQL.PerformerDataFragment) => {
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
let performerCriterion = filter.criteria.find((c) => {
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 { ListFilterModel } from "src/models/list-filter/filter";
export const studioFilterHook = (studio: Partial<GQL.StudioDataFragment>) => {
export const studioFilterHook = (studio: GQL.StudioDataFragment) => {
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
let studioCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "studios";