Fix yup schemas (#3509)

* Fix yup schemas
* Add internationalization
This commit is contained in:
DingDongSoLong4
2023-03-07 07:19:56 +02:00
committed by GitHub
parent 6b59b9643c
commit 9ede271c05
39 changed files with 632 additions and 651 deletions

View File

@@ -21,6 +21,7 @@ import {
useSystemStatus, useSystemStatus,
} from "src/core/StashService"; } from "src/core/StashService";
import flattenMessages from "./utils/flattenMessages"; import flattenMessages from "./utils/flattenMessages";
import * as yup from "yup";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import MousetrapPause from "mousetrap-pause"; import MousetrapPause from "mousetrap-pause";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
@@ -126,7 +127,18 @@ export const App: React.FC = () => {
} }
); );
setMessages(flattenMessages(mergedMessages)); const newMessages = flattenMessages(mergedMessages) as Record<
string,
string
>;
yup.setLocale({
mixed: {
required: newMessages["validation.required"],
},
});
setMessages(newMessages);
}; };
setLocale(); setLocale();

View File

@@ -38,6 +38,7 @@ import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
interface IProps { interface IProps {
gallery: Partial<GQL.GalleryDataFragment>; gallery: Partial<GQL.GalleryDataFragment>;
@@ -79,44 +80,50 @@ export const GalleryEditPanel: React.FC<IProps> = ({
isNew || (gallery?.files?.length === 0 && !gallery?.folder); isNew || (gallery?.files?.length === 0 && !gallery?.folder);
const schema = yup.object({ const schema = yup.object({
title: titleRequired title: titleRequired ? yup.string().required() : yup.string().ensure(),
? yup.string().required() url: yup.string().ensure(),
: yup.string().optional().nullable(), date: yup
details: yup.string().optional().nullable(), .string()
url: yup.string().optional().nullable(), .ensure()
date: yup.string().optional().nullable(), .test({
rating100: yup.number().optional().nullable(), name: "date",
studio_id: yup.string().optional().nullable(), test: (value) => {
performer_ids: yup.array(yup.string().required()).optional().nullable(), if (!value) return true;
tag_ids: yup.array(yup.string().required()).optional().nullable(), if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false;
scene_ids: yup.array(yup.string().required()).optional().nullable(), if (Number.isNaN(Date.parse(value))) return false;
return true;
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
}),
rating100: yup.number().nullable().defined(),
studio_id: yup.string().required().nullable(),
performer_ids: yup.array(yup.string().required()).defined(),
tag_ids: yup.array(yup.string().required()).defined(),
scene_ids: yup.array(yup.string().required()).defined(),
details: yup.string().ensure(),
}); });
const initialValues = { const initialValues = {
title: gallery?.title ?? "", title: gallery?.title ?? "",
details: gallery?.details ?? "",
url: gallery?.url ?? "", url: gallery?.url ?? "",
date: gallery?.date ?? "", date: gallery?.date ?? "",
rating100: gallery?.rating100 ?? null, rating100: gallery?.rating100 ?? null,
studio_id: gallery?.studio?.id, studio_id: gallery?.studio?.id ?? null,
performer_ids: (gallery?.performers ?? []).map((p) => p.id), performer_ids: (gallery?.performers ?? []).map((p) => p.id),
tag_ids: (gallery?.tags ?? []).map((t) => t.id), tag_ids: (gallery?.tags ?? []).map((t) => t.id),
scene_ids: (gallery?.scenes ?? []).map((s) => s.id), scene_ids: (gallery?.scenes ?? []).map((s) => s.id),
details: gallery?.details ?? "",
}; };
type InputValues = typeof initialValues; type InputValues = yup.InferType<typeof schema>;
const formik = useFormik({ const formik = useFormik<InputValues>({
initialValues, initialValues,
enableReinitialize: true,
validationSchema: schema, validationSchema: schema,
onSubmit: (values) => onSave(getGalleryInput(values)), onSubmit: (values) => onSave(values),
}); });
// always dirty if creating a new gallery with a title
if (isNew && gallery?.title) {
formik.dirty = true;
}
function setRating(v: number) { function setRating(v: number) {
formik.setFieldValue("rating100", v); formik.setFieldValue("rating100", v);
} }
@@ -166,24 +173,13 @@ export const GalleryEditPanel: React.FC<IProps> = ({
setQueryableScrapers(newQueryableScrapers); setQueryableScrapers(newQueryableScrapers);
}, [Scrapers]); }, [Scrapers]);
function getGalleryInput( async function onSave(input: GQL.GalleryCreateInput) {
input: InputValues
): GQL.GalleryCreateInput | GQL.GalleryUpdateInput {
return {
id: isNew ? undefined : gallery?.id ?? "",
...input,
};
}
async function onSave(
input: GQL.GalleryCreateInput | GQL.GalleryUpdateInput
) {
setIsLoading(true); setIsLoading(true);
try { try {
if (isNew) { if (isNew) {
const result = await createGallery({ const result = await createGallery({
variables: { variables: {
input: input as GQL.GalleryCreateInput, input,
}, },
}); });
if (result.data?.galleryCreate) { if (result.data?.galleryCreate) {
@@ -202,7 +198,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
} else { } else {
const result = await updateGallery({ const result = await updateGallery({
variables: { variables: {
input: input as GQL.GalleryUpdateInput, input: {
id: gallery.id!,
...input,
},
}, },
}); });
if (result.data?.galleryUpdate) { if (result.data?.galleryUpdate) {
@@ -216,7 +215,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
} }
), ),
}); });
formik.resetForm({ values: formik.values }); formik.resetForm();
} }
} }
} catch (e) { } catch (e) {
@@ -271,7 +270,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
return; return;
} }
const currentGallery = getGalleryInput(formik.values); const currentGallery = {
id: gallery.id!,
...formik.values,
};
return ( return (
<GalleryScrapeDialog <GalleryScrapeDialog
@@ -384,7 +386,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
function renderTextField(field: string, title: string, placeholder?: string) { function renderTextField(field: string, title: string, placeholder?: string) {
return ( return (
<Form.Group controlId={title} as={Row}> <Form.Group controlId={field} as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title, title,
})} })}
@@ -419,7 +421,9 @@ export const GalleryEditPanel: React.FC<IProps> = ({
<Button <Button
className="edit-button" className="edit-button"
variant="primary" variant="primary"
disabled={!formik.dirty} disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()} onClick={() => formik.submitForm()}
> >
<FormattedMessage id="actions.save" /> <FormattedMessage id="actions.save" />
@@ -561,8 +565,8 @@ export const GalleryEditPanel: React.FC<IProps> = ({
<Form.Control <Form.Control
as="textarea" as="textarea"
className="gallery-description text-input" className="gallery-description text-input"
onChange={(newValue: React.ChangeEvent<HTMLTextAreaElement>) => onChange={(e) =>
formik.setFieldValue("details", newValue.currentTarget.value) formik.setFieldValue("details", e.currentTarget.value)
} }
value={formik.values.details} value={formik.values.details}
/> />

View File

@@ -19,6 +19,7 @@ import { Prompt } from "react-router-dom";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
interface IProps { interface IProps {
image: GQL.ImageDataFragment; image: GQL.ImageDataFragment;
@@ -42,31 +43,44 @@ export const ImageEditPanel: React.FC<IProps> = ({
const [updateImage] = useImageUpdate(); const [updateImage] = useImageUpdate();
const schema = yup.object({ const schema = yup.object({
title: yup.string().optional().nullable(), title: yup.string().ensure(),
rating100: yup.number().optional().nullable(), url: yup.string().ensure(),
url: yup.string().optional().nullable(), date: yup
date: yup.string().optional().nullable(), .string()
studio_id: yup.string().optional().nullable(), .ensure()
performer_ids: yup.array(yup.string().required()).optional().nullable(), .test({
tag_ids: yup.array(yup.string().required()).optional().nullable(), name: "date",
test: (value) => {
if (!value) return true;
if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false;
if (Number.isNaN(Date.parse(value))) return false;
return true;
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
}),
rating100: yup.number().nullable().defined(),
studio_id: yup.string().required().nullable(),
performer_ids: yup.array(yup.string().required()).defined(),
tag_ids: yup.array(yup.string().required()).defined(),
}); });
const initialValues = { const initialValues = {
title: image.title ?? "", title: image.title ?? "",
rating100: image.rating100 ?? null,
url: image?.url ?? "", url: image?.url ?? "",
date: image?.date ?? "", date: image?.date ?? "",
studio_id: image.studio?.id, rating100: image.rating100 ?? null,
studio_id: image.studio?.id ?? null,
performer_ids: (image.performers ?? []).map((p) => p.id), performer_ids: (image.performers ?? []).map((p) => p.id),
tag_ids: (image.tags ?? []).map((t) => t.id), tag_ids: (image.tags ?? []).map((t) => t.id),
}; };
type InputValues = typeof initialValues; type InputValues = yup.InferType<typeof schema>;
const formik = useFormik({ const formik = useFormik<InputValues>({
initialValues, initialValues,
enableReinitialize: true,
validationSchema: schema, validationSchema: schema,
onSubmit: (values) => onSave(getImageInput(values)), onSubmit: (values) => onSave(values),
}); });
function setRating(v: number) { function setRating(v: number) {
@@ -95,19 +109,15 @@ export const ImageEditPanel: React.FC<IProps> = ({
} }
}); });
function getImageInput(input: InputValues): GQL.ImageUpdateInput { async function onSave(input: InputValues) {
return {
id: image.id,
...input,
};
}
async function onSave(input: GQL.ImageUpdateInput) {
setIsLoading(true); setIsLoading(true);
try { try {
const result = await updateImage({ const result = await updateImage({
variables: { variables: {
input, input: {
id: image.id,
...input,
},
}, },
}); });
if (result.data?.imageUpdate) { if (result.data?.imageUpdate) {
@@ -117,7 +127,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
{ entity: intl.formatMessage({ id: "image" }).toLocaleLowerCase() } { entity: intl.formatMessage({ id: "image" }).toLocaleLowerCase() }
), ),
}); });
formik.resetForm({ values: formik.values }); formik.resetForm();
} }
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -127,7 +137,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
function renderTextField(field: string, title: string, placeholder?: string) { function renderTextField(field: string, title: string, placeholder?: string) {
return ( return (
<Form.Group controlId={title} as={Row}> <Form.Group controlId={field} as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title, title,
})} })}
@@ -161,7 +171,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
<Button <Button
className="edit-button" className="edit-button"
variant="primary" variant="primary"
disabled={!formik.dirty} disabled={!formik.dirty || !isEqual(formik.errors, {})}
onClick={() => formik.submitForm()} onClick={() => formik.submitForm()}
> >
<FormattedMessage id="actions.save" /> <FormattedMessage id="actions.save" />

View File

@@ -33,12 +33,8 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing movie state // Editing movie state
const [frontImage, setFrontImage] = useState<string | undefined | null>( const [frontImage, setFrontImage] = useState<string | null>();
undefined const [backImage, setBackImage] = useState<string | null>();
);
const [backImage, setBackImage] = useState<string | undefined | null>(
undefined
);
const [encodingImage, setEncodingImage] = useState<boolean>(false); const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [updateMovie, { loading: updating }] = useMovieUpdate(); const [updateMovie, { loading: updating }] = useMovieUpdate();
@@ -59,26 +55,14 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
}; };
}); });
const onImageEncoding = (isEncoding = false) => setEncodingImage(isEncoding); async function onSave(input: GQL.MovieCreateInput) {
function getMovieInput(
input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
) {
const ret: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
...input,
id: movie.id,
};
return ret;
}
async function onSave(
input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
) {
try { try {
const result = await updateMovie({ const result = await updateMovie({
variables: { variables: {
input: getMovieInput(input) as GQL.MovieUpdateInput, input: {
id: movie.id,
...input,
},
}, },
}); });
if (result.data?.movieUpdate) { if (result.data?.movieUpdate) {
@@ -214,7 +198,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
onDelete={onDelete} onDelete={onDelete}
setFrontImage={setFrontImage} setFrontImage={setFrontImage}
setBackImage={setBackImage} setBackImage={setBackImage}
onImageEncoding={onImageEncoding} setEncodingImage={setEncodingImage}
/> />
)} )}
</div> </div>

View File

@@ -23,24 +23,10 @@ const MovieCreate: React.FC = () => {
const [createMovie] = useMovieCreate(); const [createMovie] = useMovieCreate();
const onImageEncoding = (isEncoding = false) => setEncodingImage(isEncoding); async function onSave(input: GQL.MovieCreateInput) {
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 { try {
const result = await createMovie({ const result = await createMovie({
variables: getMovieInput(input) as GQL.MovieCreateInput, variables: input,
}); });
if (result.data?.movieCreate?.id) { if (result.data?.movieCreate?.id) {
history.push(`/movies/${result.data.movieCreate.id}`); history.push(`/movies/${result.data.movieCreate.id}`);
@@ -92,7 +78,7 @@ const MovieCreate: React.FC = () => {
onDelete={() => {}} onDelete={() => {}}
setFrontImage={setFrontImage} setFrontImage={setFrontImage}
setBackImage={setBackImage} setBackImage={setBackImage}
onImageEncoding={onImageEncoding} setEncodingImage={setEncodingImage}
/> />
</div> </div>
</div> </div>

View File

@@ -23,17 +23,16 @@ import { Prompt } from "react-router-dom";
import { MovieScrapeDialog } from "./MovieScrapeDialog"; import { MovieScrapeDialog } from "./MovieScrapeDialog";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
interface IMovieEditPanel { interface IMovieEditPanel {
movie: Partial<GQL.MovieDataFragment>; movie: Partial<GQL.MovieDataFragment>;
onSubmit: ( onSubmit: (movie: GQL.MovieCreateInput) => void;
movie: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput>
) => void;
onCancel: () => void; onCancel: () => void;
onDelete: () => void; onDelete: () => void;
setFrontImage: (image?: string | null) => void; setFrontImage: (image?: string | null) => void;
setBackImage: (image?: string | null) => void; setBackImage: (image?: string | null) => void;
onImageEncoding: (loading?: boolean) => void; setEncodingImage: (loading: boolean) => void;
} }
export const MovieEditPanel: React.FC<IMovieEditPanel> = ({ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
@@ -43,7 +42,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
onDelete, onDelete,
setFrontImage, setFrontImage,
setBackImage, setBackImage,
onImageEncoding, setEncodingImage,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
@@ -61,59 +60,51 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
aliases: yup.string().optional().nullable(), aliases: yup.string().ensure(),
duration: yup.string().optional().nullable(), duration: yup.number().nullable().defined(),
date: yup date: yup
.string() .string()
.optional() .ensure()
.nullable() .test({
.matches(/^\d{4}-\d{2}-\d{2}$/), name: "date",
rating100: yup.number().optional().nullable(), test: (value) => {
studio_id: yup.string().optional().nullable(), if (!value) return true;
director: yup.string().optional().nullable(), if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false;
synopsis: yup.string().optional().nullable(), if (Number.isNaN(Date.parse(value))) return false;
url: yup.string().optional().nullable(), return true;
front_image: yup.string().optional().nullable(), },
back_image: yup.string().optional().nullable(), message: intl.formatMessage({ id: "validation.date_invalid_form" }),
}),
studio_id: yup.string().required().nullable(),
director: yup.string().ensure(),
rating100: yup.number().nullable().defined(),
url: yup.string().ensure(),
synopsis: yup.string().ensure(),
front_image: yup.string().nullable().optional(),
back_image: yup.string().nullable().optional(),
}); });
const initialValues = { const initialValues = {
name: movie?.name, name: movie?.name ?? "",
aliases: movie?.aliases, aliases: movie?.aliases ?? "",
duration: movie?.duration, duration: movie?.duration ?? null,
date: movie?.date, date: movie?.date ?? "",
studio_id: movie?.studio?.id ?? null,
director: movie?.director ?? "",
rating100: movie?.rating100 ?? null, rating100: movie?.rating100 ?? null,
studio_id: movie?.studio?.id, url: movie?.url ?? "",
director: movie?.director, synopsis: movie?.synopsis ?? "",
synopsis: movie?.synopsis,
url: movie?.url,
front_image: undefined,
back_image: undefined,
}; };
type InputValues = typeof initialValues; type InputValues = yup.InferType<typeof schema>;
const formik = useFormik({ const formik = useFormik<InputValues>({
initialValues, initialValues,
enableReinitialize: true,
validationSchema: schema, validationSchema: schema,
onSubmit: (values) => onSubmit(getMovieInput(values)), onSubmit: (values) => onSubmit(values),
}); });
const encodingImage = ImageUtils.usePasteImage(showImageAlert);
useEffect(() => {
setFrontImage(formik.values.front_image);
}, [formik.values.front_image, setFrontImage]);
useEffect(() => {
setBackImage(formik.values.back_image);
}, [formik.values.back_image, setBackImage]);
useEffect(
() => onImageEncoding(encodingImage),
[onImageEncoding, encodingImage]
);
function setRating(v: number) { function setRating(v: number) {
formik.setFieldValue("rating100", v); formik.setFieldValue("rating100", v);
} }
@@ -138,35 +129,6 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
}; };
}); });
function showImageAlert(imageData: string) {
setImageClipboard(imageData);
setIsImageAlertOpen(true);
}
function setImageFromClipboard(isFrontImage: boolean) {
if (isFrontImage) {
formik.setFieldValue("front_image", imageClipboard);
} else {
formik.setFieldValue("back_image", imageClipboard);
}
setImageClipboard(undefined);
setIsImageAlertOpen(false);
}
function getMovieInput(values: InputValues) {
const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
...values,
rating100: values.rating100 ?? null,
studio_id: values.studio_id ?? null,
};
if (movie && movie.id) {
(input as GQL.MovieUpdateInput).id = movie.id;
}
return input;
}
function updateMovieEditStateFromScraper( function updateMovieEditStateFromScraper(
state: Partial<GQL.ScrapedMovieDataFragment> state: Partial<GQL.ScrapedMovieDataFragment>
) { ) {
@@ -175,39 +137,42 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
} }
if (state.aliases) { if (state.aliases) {
formik.setFieldValue("aliases", state.aliases ?? undefined); formik.setFieldValue("aliases", state.aliases);
} }
if (state.duration) { if (state.duration) {
formik.setFieldValue( formik.setFieldValue(
"duration", "duration",
DurationUtils.stringToSeconds(state.duration) ?? undefined DurationUtils.stringToSeconds(state.duration)
); );
} }
if (state.date) { if (state.date) {
formik.setFieldValue("date", state.date ?? undefined); formik.setFieldValue("date", state.date);
} }
if (state.studio && state.studio.stored_id) { if (state.studio && state.studio.stored_id) {
formik.setFieldValue("studio_id", state.studio.stored_id ?? undefined); formik.setFieldValue("studio_id", state.studio.stored_id);
} }
if (state.director) { if (state.director) {
formik.setFieldValue("director", state.director ?? undefined); formik.setFieldValue("director", state.director);
} }
if (state.synopsis) { if (state.synopsis) {
formik.setFieldValue("synopsis", state.synopsis ?? undefined); formik.setFieldValue("synopsis", state.synopsis);
} }
if (state.url) { if (state.url) {
formik.setFieldValue("url", state.url ?? undefined); formik.setFieldValue("url", state.url);
} }
const imageStr = (state as GQL.ScrapedMovieDataFragment).front_image; if (state.front_image) {
formik.setFieldValue("front_image", imageStr ?? undefined); // image is a base64 string
formik.setFieldValue("front_image", state.front_image);
const backImageStr = (state as GQL.ScrapedMovieDataFragment).back_image; }
formik.setFieldValue("back_image", backImageStr ?? undefined); if (state.back_image) {
// image is a base64 string
formik.setFieldValue("back_image", state.back_image);
}
} }
async function onScrapeMovieURL() { async function onScrapeMovieURL() {
@@ -248,7 +213,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
return; return;
} }
const currentMovie = getMovieInput(formik.values); const currentMovie = {
id: movie.id!,
...formik.values,
};
// Get image paths for scrape gui // Get image paths for scrape gui
currentMovie.front_image = movie?.front_image_path; currentMovie.front_image = movie?.front_image_path;
@@ -272,16 +240,50 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
setScrapedMovie(undefined); setScrapedMovie(undefined);
} }
const encodingImage = ImageUtils.usePasteImage(showImageAlert);
useEffect(() => {
setFrontImage(formik.values.front_image);
}, [formik.values.front_image, setFrontImage]);
useEffect(() => {
setBackImage(formik.values.back_image);
}, [formik.values.back_image, setBackImage]);
useEffect(() => {
setEncodingImage(encodingImage);
}, [setEncodingImage, encodingImage]);
function onFrontImageLoad(imageData: string | null) {
formik.setFieldValue("front_image", imageData);
}
function onFrontImageChange(event: React.FormEvent<HTMLInputElement>) { function onFrontImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, (data) => ImageUtils.onImageChange(event, onFrontImageLoad);
formik.setFieldValue("front_image", data) }
);
function onBackImageLoad(imageData: string | null) {
formik.setFieldValue("back_image", imageData);
} }
function onBackImageChange(event: React.FormEvent<HTMLInputElement>) { function onBackImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, (data) => ImageUtils.onImageChange(event, onBackImageLoad);
formik.setFieldValue("back_image", data) }
);
function showImageAlert(imageData: string) {
setImageClipboard(imageData);
setIsImageAlertOpen(true);
}
function setImageFromClipboard(isFrontImage: boolean) {
if (isFrontImage) {
formik.setFieldValue("front_image", imageClipboard);
} else {
formik.setFieldValue("back_image", imageClipboard);
}
setImageClipboard(undefined);
setIsImageAlertOpen(false);
} }
function renderImageAlert() { function renderImageAlert() {
@@ -325,7 +327,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
const isEditing = true; const isEditing = true;
function renderTextField(field: string, title: string) { function renderTextField(field: string, title: string, placeholder?: string) {
return ( return (
<Form.Group controlId={field} as={Row}> <Form.Group controlId={field} as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
@@ -334,10 +336,13 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
<Col xs={9}> <Col xs={9}>
<Form.Control <Form.Control
className="text-input" className="text-input"
placeholder={title} placeholder={placeholder ?? title}
{...formik.getFieldProps(field)} {...formik.getFieldProps(field)}
isInvalid={!!formik.getFieldMeta(field).error} isInvalid={!!formik.getFieldMeta(field).error}
/> />
<Form.Control.Feedback type="invalid">
{formik.getFieldMeta(field).error}
</Form.Control.Feedback>
</Col> </Col>
</Form.Group> </Form.Group>
); );
@@ -392,14 +397,18 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
<Col xs={9}> <Col xs={9}>
<DurationInput <DurationInput
numericValue={formik.values.duration ?? undefined} numericValue={formik.values.duration ?? undefined}
onValueChange={(valueAsNumber: number) => { onValueChange={(valueAsNumber) => {
formik.setFieldValue("duration", valueAsNumber); formik.setFieldValue("duration", valueAsNumber ?? null);
}} }}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
{renderTextField("date", intl.formatMessage({ id: "date" }))} {renderTextField(
"date",
intl.formatMessage({ id: "date" }),
"YYYY-MM-DD"
)}
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
@@ -410,7 +419,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
onSelect={(items) => onSelect={(items) =>
formik.setFieldValue( formik.setFieldValue(
"studio_id", "studio_id",
items.length > 0 ? items[0]?.id : undefined items.length > 0 ? items[0]?.id : null
) )
} }
ids={formik.values.studio_id ? [formik.values.studio_id] : []} ids={formik.values.studio_id ? [formik.values.studio_id] : []}
@@ -466,18 +475,14 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
isNew={isNew} isNew={isNew}
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={onCancel} onToggleEdit={onCancel}
onSave={() => formik.handleSubmit()} onSave={formik.handleSubmit}
saveDisabled={!formik.dirty} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onImageChange={onFrontImageChange} onImageChange={onFrontImageChange}
onImageChangeURL={(i) => formik.setFieldValue("front_image", i)} onImageChangeURL={onFrontImageLoad}
onClearImage={() => { onClearImage={() => onFrontImageLoad(null)}
formik.setFieldValue("front_image", null);
}}
onBackImageChange={onBackImageChange} onBackImageChange={onBackImageChange}
onBackImageChangeURL={(i) => formik.setFieldValue("back_image", i)} onBackImageChangeURL={onBackImageLoad}
onClearBackImage={() => { onClearBackImage={() => onBackImageLoad(null)}
formik.setFieldValue("back_image", null);
}}
onDelete={onDelete} onDelete={onDelete}
/> />

View File

@@ -227,7 +227,7 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
<Form.Control <Form.Control
as="select" as="select"
className="input-control" className="input-control"
value={genderToString(updateInput.gender ?? undefined)} value={genderToString(updateInput.gender)}
onChange={(event) => onChange={(event) =>
setUpdateField({ setUpdateField({
gender: stringToGender(event.currentTarget.value), gender: stringToGender(event.currentTarget.value),

View File

@@ -54,17 +54,17 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
const abbreviateCounter = const abbreviateCounter =
(configuration?.ui as IUIConfig)?.abbreviateCounters ?? false; (configuration?.ui as IUIConfig)?.abbreviateCounters ?? false;
const [imagePreview, setImagePreview] = useState<string | null>();
const [imageEncoding, setImageEncoding] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
// 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
// otherwise get the set image // otherwise get the set image
const activeImage = const activeImage =
imagePreview === undefined image === undefined
? performer.image_path ?? "" ? performer.image_path ?? ""
: imagePreview ?? `${performer.image_path}&default=true`; : image ?? `${performer.image_path}&default=true`;
const lightboxImages = useMemo( const lightboxImages = useMemo(
() => [{ paths: { thumbnail: activeImage, image: activeImage } }], () => [{ paths: { thumbnail: activeImage, image: activeImage } }],
[activeImage] [activeImage]
@@ -91,10 +91,6 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
} }
}; };
const onImageChange = (image?: string | null) => setImagePreview(image);
const onImageEncoding = (isEncoding = false) => setImageEncoding(isEncoding);
async function onAutoTag() { async function onAutoTag() {
try { try {
await mutateMetadataAutoTag({ performers: [performer.id] }); await mutateMetadataAutoTag({ performers: [performer.id] });
@@ -254,11 +250,9 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
<PerformerEditPanel <PerformerEditPanel
performer={performer} performer={performer}
isVisible={isEditing} isVisible={isEditing}
onImageChange={onImageChange} onCancelEditing={() => setIsEditing(false)}
onImageEncoding={onImageEncoding} setImage={setImage}
onCancelEditing={() => { setEncodingImage={setEncodingImage}
setIsEditing(false);
}}
/> />
); );
} else { } else {
@@ -393,7 +387,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
</Helmet> </Helmet>
<div className="performer-image-container col-md-4 text-center"> <div className="performer-image-container col-md-4 text-center">
{imageEncoding ? ( {encodingImage ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
) : ( ) : (
<Button variant="link" onClick={() => showLightbox()}> <Button variant="link" onClick={() => showLightbox()}>

View File

@@ -5,8 +5,8 @@ import { PerformerEditPanel } from "./PerformerEditPanel";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
const PerformerCreate: React.FC = () => { const PerformerCreate: React.FC = () => {
const [imagePreview, setImagePreview] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const [imageEncoding, setImageEncoding] = useState<boolean>(false); const [encodingImage, setEncodingImage] = useState<boolean>(false);
const location = useLocation(); const location = useLocation();
const query = useMemo(() => new URLSearchParams(location.search), [location]); const query = useMemo(() => new URLSearchParams(location.search), [location]);
@@ -14,21 +14,17 @@ const PerformerCreate: React.FC = () => {
name: query.get("q") ?? undefined, name: query.get("q") ?? undefined,
}; };
const activeImage = imagePreview ?? "";
const intl = useIntl(); const intl = useIntl();
const onImageChange = (image?: string | null) => setImagePreview(image);
const onImageEncoding = (isEncoding = false) => setImageEncoding(isEncoding);
function renderPerformerImage() { function renderPerformerImage() {
if (imageEncoding) { if (encodingImage) {
return <LoadingIndicator message="Encoding image..." />; return <LoadingIndicator message="Encoding image..." />;
} }
if (activeImage) { if (image) {
return ( return (
<img <img
className="performer" className="performer"
src={activeImage} src={image}
alt={intl.formatMessage({ id: "performer" })} alt={intl.formatMessage({ id: "performer" })}
/> />
); );
@@ -50,8 +46,8 @@ const PerformerCreate: React.FC = () => {
<PerformerEditPanel <PerformerEditPanel
performer={performer} performer={performer}
isVisible isVisible
onImageChange={onImageChange} setImage={setImage}
onImageEncoding={onImageEncoding} setEncodingImage={setEncodingImage}
/> />
</div> </div>
</div> </div>

View File

@@ -27,8 +27,8 @@ import { useToast } from "src/hooks/Toast";
import { Prompt, useHistory } from "react-router-dom"; import { Prompt, useHistory } from "react-router-dom";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { import {
genderStrings,
genderToString, genderToString,
stringGenderMap,
stringToGender, stringToGender,
} from "src/utils/gender"; } from "src/utils/gender";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
@@ -42,6 +42,7 @@ import {
faTrashAlt, faTrashAlt,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { StringListInput } from "src/components/Shared/StringListInput"; import { StringListInput } from "src/components/Shared/StringListInput";
import isEqual from "lodash-es/isEqual";
const isScraper = ( const isScraper = (
scraper: GQL.Scraper | GQL.StashBox scraper: GQL.Scraper | GQL.StashBox
@@ -50,24 +51,24 @@ const isScraper = (
interface IPerformerDetails { interface IPerformerDetails {
performer: Partial<GQL.PerformerDataFragment>; performer: Partial<GQL.PerformerDataFragment>;
isVisible: boolean; isVisible: boolean;
onImageChange?: (image?: string | null) => void;
onImageEncoding?: (loading?: boolean) => void;
onCancelEditing?: () => void; onCancelEditing?: () => void;
setImage: (image?: string | null) => void;
setEncodingImage: (loading: boolean) => void;
} }
export const PerformerEditPanel: React.FC<IPerformerDetails> = ({ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
performer, performer,
isVisible, isVisible,
onImageChange,
onImageEncoding,
onCancelEditing, onCancelEditing,
setImage,
setEncodingImage,
}) => { }) => {
const Toast = useToast(); const Toast = useToast();
const history = useHistory(); const history = useHistory();
const isNew = performer.id === undefined; const isNew = performer.id === undefined;
// Editing stat // Editing state
const [scraper, setScraper] = useState<GQL.Scraper | IStashBox>(); const [scraper, setScraper] = useState<GQL.Scraper | IStashBox>();
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(); const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>();
const [isScraperModalOpen, setIsScraperModalOpen] = useState<boolean>(false); const [isScraperModalOpen, setIsScraperModalOpen] = useState<boolean>(false);
@@ -81,18 +82,13 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const Scrapers = useListPerformerScrapers(); const Scrapers = useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]); const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
const [scrapedPerformer, setScrapedPerformer] = useState< const [scrapedPerformer, setScrapedPerformer] =
GQL.ScrapedPerformer | undefined useState<GQL.ScrapedPerformer>();
>();
const { configuration: stashConfig } = React.useContext(ConfigurationContext); const { configuration: stashConfig } = React.useContext(ConfigurationContext);
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
const [createTag] = useTagCreate(); const [createTag] = useTagCreate();
const intl = useIntl(); const intl = useIntl();
const genderOptions = [""].concat(genderStrings);
const labelXS = 3; const labelXS = 3;
const labelXL = 2; const labelXL = 2;
const fieldXS = 9; const fieldXS = 9;
@@ -100,102 +96,121 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
disambiguation: yup.string().optional(), disambiguation: yup.string().ensure(),
alias_list: yup alias_list: yup
.array(yup.string().required()) .array(yup.string().required())
.optional() .defined()
.test({ .test({
name: "unique", name: "unique",
// eslint-disable-next-line @typescript-eslint/no-explicit-any test: (value, context) => {
test: (value: any) => { if (!value) return true;
return (value ?? []).length === new Set(value).size; const aliases = new Set(value);
aliases.add(context.parent.name);
return value.length + 1 === aliases.size;
}, },
message: intl.formatMessage({ id: "dialogs.aliases_must_be_unique" }), message: intl.formatMessage({
id: "validation.aliases_must_be_unique",
}),
}), }),
gender: yup.string().optional().oneOf(genderOptions), gender: yup.string<GQL.GenderEnum | "">().ensure(),
birthdate: yup.string().optional(), birthdate: yup
ethnicity: yup.string().optional(), .string()
eye_color: yup.string().optional(), .ensure()
country: yup.string().optional(), .test({
height_cm: yup.number().optional(), name: "date",
measurements: yup.string().optional(), test: (value) => {
fake_tits: yup.string().optional(), if (!value) return true;
career_length: yup.string().optional(), if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false;
tattoos: yup.string().optional(), if (Number.isNaN(Date.parse(value))) return false;
piercings: yup.string().optional(), return true;
url: yup.string().optional(), },
twitter: yup.string().optional(), message: intl.formatMessage({ id: "validation.date_invalid_form" }),
instagram: yup.string().optional(), }),
tag_ids: yup.array(yup.string().required()).optional(), death_date: yup
stash_ids: yup.mixed<GQL.StashIdInput[]>().optional(), .string()
image: yup.string().optional().nullable(), .ensure()
details: yup.string().optional(), .test({
death_date: yup.string().optional(), name: "date",
hair_color: yup.string().optional(), test: (value) => {
weight: yup.number().optional(), if (!value) return true;
ignore_auto_tag: yup.boolean().optional(), if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false;
if (Number.isNaN(Date.parse(value))) return false;
return true;
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
}),
country: yup.string().ensure(),
ethnicity: yup.string().ensure(),
hair_color: yup.string().ensure(),
eye_color: yup.string().ensure(),
height_cm: yup.number().nullable().defined().default(null),
weight: yup.number().nullable().defined().default(null),
measurements: yup.string().ensure(),
fake_tits: yup.string().ensure(),
tattoos: yup.string().ensure(),
piercings: yup.string().ensure(),
career_length: yup.string().ensure(),
url: yup.string().ensure(),
twitter: yup.string().ensure(),
instagram: yup.string().ensure(),
details: yup.string().ensure(),
tag_ids: yup.array(yup.string().required()).defined(),
ignore_auto_tag: yup.boolean().defined(),
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
image: yup.string().nullable().optional(),
}); });
const initialValues = { const initialValues = {
name: performer.name ?? "", name: performer.name ?? "",
disambiguation: performer.disambiguation ?? "", disambiguation: performer.disambiguation ?? "",
alias_list: performer.alias_list?.slice().sort(), alias_list: performer.alias_list ?? [],
gender: genderToString(performer.gender ?? undefined), gender: (performer.gender as GQL.GenderEnum) ?? "",
birthdate: performer.birthdate ?? "", birthdate: performer.birthdate ?? "",
ethnicity: performer.ethnicity ?? "", death_date: performer.death_date ?? "",
eye_color: performer.eye_color ?? "",
country: performer.country ?? "", country: performer.country ?? "",
height_cm: performer.height_cm ?? undefined, ethnicity: performer.ethnicity ?? "",
hair_color: performer.hair_color ?? "",
eye_color: performer.eye_color ?? "",
height_cm: performer.height_cm ?? null,
weight: performer.weight ?? null,
measurements: performer.measurements ?? "", measurements: performer.measurements ?? "",
fake_tits: performer.fake_tits ?? "", fake_tits: performer.fake_tits ?? "",
career_length: performer.career_length ?? "",
tattoos: performer.tattoos ?? "", tattoos: performer.tattoos ?? "",
piercings: performer.piercings ?? "", piercings: performer.piercings ?? "",
career_length: performer.career_length ?? "",
url: performer.url ?? "", url: performer.url ?? "",
twitter: performer.twitter ?? "", twitter: performer.twitter ?? "",
instagram: performer.instagram ?? "", instagram: performer.instagram ?? "",
tag_ids: (performer.tags ?? []).map((t) => t.id),
stash_ids: performer.stash_ids ?? undefined,
image: undefined,
details: performer.details ?? "", details: performer.details ?? "",
death_date: performer.death_date ?? "", tag_ids: (performer.tags ?? []).map((t) => t.id),
hair_color: performer.hair_color ?? "",
weight: performer.weight ?? undefined,
ignore_auto_tag: performer.ignore_auto_tag ?? false, ignore_auto_tag: performer.ignore_auto_tag ?? false,
stash_ids: getStashIDs(performer.stash_ids),
}; };
type InputValues = typeof initialValues; type InputValues = yup.InferType<typeof schema>;
const formik = useFormik({ const formik = useFormik<InputValues>({
initialValues, initialValues,
enableReinitialize: true,
validationSchema: schema, validationSchema: schema,
onSubmit: (values) => onSave(values), onSubmit: (values) => onSave(values),
}); });
// always dirty if creating a new performer with a name
if (isNew && performer.name) {
formik.dirty = true;
}
function translateScrapedGender(scrapedGender?: string) { function translateScrapedGender(scrapedGender?: string) {
if (!scrapedGender) { if (!scrapedGender) {
return; return;
} }
let retEnum: GQL.GenderEnum | undefined;
// try to translate from enum values first // try to translate from enum values first
const upperGender = scrapedGender?.toUpperCase(); const upperGender = scrapedGender.toUpperCase();
const asEnum = genderToString(upperGender); const asEnum = genderToString(upperGender);
if (asEnum) { if (asEnum) {
retEnum = stringToGender(asEnum); return stringToGender(asEnum);
} else { } else {
// try to match against gender strings // try to match against gender strings
const caseInsensitive = true; const caseInsensitive = true;
retEnum = stringToGender(scrapedGender, caseInsensitive); return stringToGender(scrapedGender, caseInsensitive);
} }
return genderToString(retEnum);
} }
function renderNewTags() { function renderNewTags() {
@@ -329,10 +344,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
} }
if (state.gender) { if (state.gender) {
// gender is a string in the scraper data // gender is a string in the scraper data
formik.setFieldValue( const newGender = translateScrapedGender(state.gender);
"gender", if (newGender) {
translateScrapedGender(state.gender ?? undefined) formik.setFieldValue("gender", newGender);
); }
} }
if (state.tags) { if (state.tags) {
// map tags to their ids and filter out those not found // map tags to their ids and filter out those not found
@@ -345,15 +360,14 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
// image is a base64 string // image is a base64 string
// #404: don't overwrite image if it has been modified by the user // #404: don't overwrite image if it has been modified by the user
// overwrite if not new since it came from a dialog // overwrite if not new since it came from a dialog
// overwrite if image was cleared (`null`) // overwrite if image is unset
// otherwise follow existing behaviour (`undefined`)
if ( if (
(!isNew || [null, undefined].includes(formik.values.image)) && (!isNew || !formik.values.image) &&
state.images && state.images &&
state.images.length > 0 state.images.length > 0
) { ) {
const imageStr = state.images[0]; const imageStr = state.images[0];
formik.setFieldValue("image", imageStr ?? undefined); formik.setFieldValue("image", imageStr);
} }
if (state.details) { if (state.details) {
formik.setFieldValue("details", state.details); formik.setFieldValue("details", state.details);
@@ -382,31 +396,47 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
} }
} }
function onImageLoad(imageData: string) { const encodingImage = ImageUtils.usePasteImage(onImageLoad);
useEffect(() => {
setImage(formik.values.image);
}, [formik.values.image, setImage]);
useEffect(
() => setEncodingImage(encodingImage),
[setEncodingImage, encodingImage]
);
function onImageLoad(imageData: string | null) {
formik.setFieldValue("image", imageData); formik.setFieldValue("image", imageData);
} }
async function onSave(performerInput: InputValues) { function onImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
async function onSave(input: InputValues) {
setIsLoading(true); setIsLoading(true);
try { try {
if (isNew) { if (isNew) {
const input = getCreateValues(performerInput);
const result = await createPerformer({ const result = await createPerformer({
variables: { variables: {
input, input: {
...input,
gender: input.gender || null,
},
}, },
}); });
if (result.data?.performerCreate) { if (result.data?.performerCreate) {
history.push(`/performers/${result.data.performerCreate.id}`); history.push(`/performers/${result.data.performerCreate.id}`);
} }
} else { } else {
const input = getUpdateValues(performerInput);
await updatePerformer({ await updatePerformer({
variables: { variables: {
input: { input: {
id: performer.id!,
...input, ...input,
stash_ids: getStashIDs(performerInput?.stash_ids), gender: input.gender || null,
}, },
}, },
}); });
@@ -440,16 +470,12 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
}); });
useEffect(() => { useEffect(() => {
if (onImageChange) { setImage(formik.values.image);
onImageChange(formik.values.image); }, [formik.values.image, setImage]);
}
return () => onImageChange?.();
}, [formik.values.image, onImageChange]);
useEffect( useEffect(() => {
() => onImageEncoding?.(imageEncoding), setEncodingImage(encodingImage);
[onImageEncoding, imageEncoding] }, [setEncodingImage, encodingImage]);
);
useEffect(() => { useEffect(() => {
const newQueryableScrapers = ( const newQueryableScrapers = (
@@ -463,33 +489,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
if (isLoading) return <LoadingIndicator />; if (isLoading) return <LoadingIndicator />;
function getUpdateValues(values: InputValues): GQL.PerformerUpdateInput {
return {
...values,
gender: stringToGender(values.gender) ?? null,
height_cm: values.height_cm ? Number(values.height_cm) : null,
weight: values.weight ? Number(values.weight) : null,
id: performer.id ?? "",
};
}
function getCreateValues(values: InputValues): GQL.PerformerCreateInput {
return {
...values,
gender: stringToGender(values.gender),
height_cm: values.height_cm ? Number(values.height_cm) : null,
weight: values.weight ? Number(values.weight) : null,
};
}
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function onImageChangeURL(url: string) {
formik.setFieldValue("image", url);
}
async function onReloadScrapers() { async function onReloadScrapers() {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -655,9 +654,9 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
return; return;
} }
const currentPerformer: Partial<GQL.PerformerUpdateInput> = { const currentPerformer = {
...formik.values, ...formik.values,
gender: stringToGender(formik.values.gender), gender: formik.values.gender || null,
image: formik.values.image ?? performer.image_path, image: formik.values.image ?? performer.image_path,
}; };
@@ -698,8 +697,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{renderScraperMenu()} {renderScraperMenu()}
<ImageInput <ImageInput
isEditing isEditing
onImageChange={onImageChangeHandler} onImageChange={onImageChange}
onImageURL={onImageChangeURL} onImageURL={onImageLoad}
/> />
<div> <div>
<Button <Button
@@ -712,7 +711,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
</div> </div>
<Button <Button
variant="success" variant="success"
disabled={!formik.dirty} disabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onClick={() => formik.submitForm()} onClick={() => formik.submitForm()}
> >
<FormattedMessage id="actions.save" /> <FormattedMessage id="actions.save" />
@@ -843,6 +842,9 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{...formik.getFieldProps(field)} {...formik.getFieldProps(field)}
isInvalid={!!formik.getFieldMeta(field).error} isInvalid={!!formik.getFieldMeta(field).error}
/> />
<Form.Control.Feedback type="invalid">
{formik.getFieldMeta(field).error}
</Form.Control.Feedback>
</Col> </Col>
</Form.Group> </Form.Group>
); );
@@ -902,7 +904,11 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<StringListInput <StringListInput
value={formik.values.alias_list ?? []} value={formik.values.alias_list ?? []}
setValue={(value) => formik.setFieldValue("alias_list", value)} setValue={(value) => formik.setFieldValue("alias_list", value)}
errors={formik.errors.alias_list} errors={
Array.isArray(formik.errors.alias_list)
? formik.errors.alias_list[0]
: formik.errors.alias_list
}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
@@ -917,9 +923,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
className="input-control" className="input-control"
{...formik.getFieldProps("gender")} {...formik.getFieldProps("gender")}
> >
{genderOptions.map((opt) => ( <option value="" key=""></option>
<option value={opt} key={opt}> {Array.from(stringGenderMap.entries()).map(([name, value]) => (
{opt} <option value={value} key={value}>
{name}
</option> </option>
))} ))}
</Form.Control> </Form.Control>

View File

@@ -152,8 +152,8 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
let retEnum: GQL.GenderEnum | undefined; let retEnum: GQL.GenderEnum | undefined;
// try to translate from enum values first // try to translate from enum values first
const upperGender = scrapedGender?.toUpperCase(); const upperGender = scrapedGender.toUpperCase();
const asEnum = genderToString(upperGender as GQL.GenderEnum); const asEnum = genderToString(upperGender);
if (asEnum) { if (asEnum) {
retEnum = stringToGender(asEnum); retEnum = stringToGender(asEnum);
} else { } else {
@@ -248,7 +248,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
); );
const [gender, setGender] = useState<ScrapeResult<string>>( const [gender, setGender] = useState<ScrapeResult<string>>(
new ScrapeResult<string>( new ScrapeResult<string>(
genderToString(props.performer.gender ?? undefined), genderToString(props.performer.gender),
translateScrapedGender(props.scraped.gender) translateScrapedGender(props.scraped.gender)
) )
); );

View File

@@ -51,6 +51,7 @@ import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import { lazyComponent } from "src/utils/lazyComponent"; import { lazyComponent } from "src/utils/lazyComponent";
import isEqual from "lodash-es/isEqual";
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
@@ -115,55 +116,66 @@ export const SceneEditPanel: React.FC<IProps> = ({
const [updateScene] = useSceneUpdate(); const [updateScene] = useSceneUpdate();
const schema = yup.object({ const schema = yup.object({
title: yup.string().optional().nullable(), title: yup.string().ensure(),
code: yup.string().optional().nullable(), code: yup.string().ensure(),
details: yup.string().optional().nullable(), url: yup.string().ensure(),
director: yup.string().optional().nullable(), date: yup
url: yup.string().optional().nullable(), .string()
date: yup.string().optional().nullable(), .ensure()
rating100: yup.number().optional().nullable(), .test({
gallery_ids: yup.array(yup.string().required()).optional().nullable(), name: "date",
studio_id: yup.string().optional().nullable(), test: (value) => {
performer_ids: yup.array(yup.string().required()).optional().nullable(), if (!value) return true;
if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false;
if (Number.isNaN(Date.parse(value))) return false;
return true;
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
}),
director: yup.string().ensure(),
rating100: yup.number().nullable().defined(),
gallery_ids: yup.array(yup.string().required()).defined(),
studio_id: yup.string().required().nullable(),
performer_ids: yup.array(yup.string().required()).defined(),
movies: yup movies: yup
.array( .array(
yup.object({ yup.object({
movie_id: yup.string().required(), movie_id: yup.string().required(),
scene_index: yup.string().optional().nullable(), scene_index: yup.number().nullable().defined(),
}) })
) )
.optional() .defined(),
.nullable(), tag_ids: yup.array(yup.string().required()).defined(),
tag_ids: yup.array(yup.string().required()).optional().nullable(), stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
cover_image: yup.string().optional().nullable(), details: yup.string().ensure(),
stash_ids: yup.mixed<GQL.StashIdInput[]>().optional().nullable(), cover_image: yup.string().nullable().optional(),
}); });
const initialValues = useMemo( const initialValues = useMemo(
() => ({ () => ({
title: scene.title ?? "", title: scene.title ?? "",
code: scene.code ?? "", code: scene.code ?? "",
details: scene.details ?? "",
director: scene.director ?? "",
url: scene.url ?? "", url: scene.url ?? "",
date: scene.date ?? "", date: scene.date ?? "",
director: scene.director ?? "",
rating100: scene.rating100 ?? null, rating100: scene.rating100 ?? null,
gallery_ids: (scene.galleries ?? []).map((g) => g.id), gallery_ids: (scene.galleries ?? []).map((g) => g.id),
studio_id: scene.studio?.id, studio_id: scene.studio?.id ?? null,
performer_ids: (scene.performers ?? []).map((p) => p.id), performer_ids: (scene.performers ?? []).map((p) => p.id),
movies: (scene.movies ?? []).map((m) => { movies: (scene.movies ?? []).map((m) => {
return { movie_id: m.movie.id, scene_index: m.scene_index }; return { movie_id: m.movie.id, scene_index: m.scene_index ?? null };
}), }),
tag_ids: (scene.tags ?? []).map((t) => t.id), tag_ids: (scene.tags ?? []).map((t) => t.id),
cover_image: initialCoverImage,
stash_ids: getStashIDs(scene.stash_ids), stash_ids: getStashIDs(scene.stash_ids),
details: scene.details ?? "",
cover_image: initialCoverImage,
}), }),
[scene, initialCoverImage] [scene, initialCoverImage]
); );
type InputValues = typeof initialValues; type InputValues = yup.InferType<typeof schema>;
const formik = useFormik({ const formik = useFormik<InputValues>({
initialValues, initialValues,
enableReinitialize: true, enableReinitialize: true,
validationSchema: schema, validationSchema: schema,
@@ -225,15 +237,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
setQueryableScrapers(newQueryableScrapers); setQueryableScrapers(newQueryableScrapers);
}, [Scrapers, stashConfig]); }, [Scrapers, stashConfig]);
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
function getSceneInput(input: InputValues): GQL.SceneUpdateInput {
return {
id: scene.id!,
...input,
};
}
function setMovieIds(movieIds: string[]) { function setMovieIds(movieIds: string[]) {
const existingMovies = formik.values.movies; const existingMovies = formik.values.movies;
@@ -245,29 +248,23 @@ export const SceneEditPanel: React.FC<IProps> = ({
return { return {
movie_id: m, movie_id: m,
scene_index: null,
}; };
}); });
formik.setFieldValue("movies", newMovies); formik.setFieldValue("movies", newMovies);
} }
function getCreateValues(values: InputValues): GQL.SceneCreateInput {
return {
...values,
};
}
async function onSave(input: InputValues) { async function onSave(input: InputValues) {
console.log("onSave", input);
setIsLoading(true); setIsLoading(true);
try { try {
if (!isNew) { if (!isNew) {
const updateValues = getSceneInput(input);
const result = await updateScene({ const result = await updateScene({
variables: { variables: {
input: { input: {
...updateValues,
id: scene.id!, id: scene.id!,
rating100: input.rating100 ?? null, ...input,
}, },
}, },
}); });
@@ -280,20 +277,17 @@ export const SceneEditPanel: React.FC<IProps> = ({
} }
), ),
}); });
formik.resetForm();
} }
} else { } else {
const createValues = getCreateValues(input);
const result = await mutateCreateScene({ const result = await mutateCreateScene({
...createValues, ...input,
file_ids: fileID ? [fileID] : undefined, file_ids: fileID ? [fileID] : undefined,
}); });
if (result.data?.sceneCreate?.id) { if (result.data?.sceneCreate?.id) {
history.push(`/scenes/${result.data?.sceneCreate.id}`); history.push(`/scenes/${result.data?.sceneCreate.id}`);
} }
} }
// clear the cover image so that it doesn't appear dirty
formik.resetForm({ values: formik.values });
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@@ -321,6 +315,8 @@ export const SceneEditPanel: React.FC<IProps> = ({
); );
} }
const encodingImage = ImageUtils.usePasteImage(onImageLoad);
function onImageLoad(imageData: string) { function onImageLoad(imageData: string) {
setCoverImagePreview(imageData); setCoverImagePreview(imageData);
formik.setFieldValue("cover_image", imageData); formik.setFieldValue("cover_image", imageData);
@@ -356,17 +352,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
) { ) {
setIsLoading(true); setIsLoading(true);
try { try {
const input: GQL.ScrapedSceneInput = { const result = await queryScrapeSceneQueryFragment(s, fragment);
date: fragment.date,
code: fragment.code,
details: fragment.details,
director: fragment.director,
remote_site_id: fragment.remote_site_id,
title: fragment.title,
url: fragment.url,
};
const result = await queryScrapeSceneQueryFragment(s, input);
if (!result.data || !result.data.scrapeSingleScene?.length) { if (!result.data || !result.data.scrapeSingleScene?.length) {
Toast.success({ Toast.success({
content: "No scenes found", content: "No scenes found",
@@ -414,7 +400,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
return; return;
} }
const currentScene = getSceneInput(formik.values); const currentScene = {
id: scene.id!,
...formik.values,
};
if (!currentScene.cover_image) { if (!currentScene.cover_image) {
currentScene.cover_image = scene.paths?.screenshot; currentScene.cover_image = scene.paths?.screenshot;
} }
@@ -671,7 +661,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
function renderTextField(field: string, title: string, placeholder?: string) { function renderTextField(field: string, title: string, placeholder?: string) {
return ( return (
<Form.Group controlId={title} as={Row}> <Form.Group controlId={field} as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title, title,
})} })}
@@ -682,13 +672,16 @@ export const SceneEditPanel: React.FC<IProps> = ({
{...formik.getFieldProps(field)} {...formik.getFieldProps(field)}
isInvalid={!!formik.getFieldMeta(field).error} isInvalid={!!formik.getFieldMeta(field).error}
/> />
<Form.Control.Feedback type="invalid">
{formik.getFieldMeta(field).error}
</Form.Control.Feedback>
</Col> </Col>
</Form.Group> </Form.Group>
); );
} }
const image = useMemo(() => { const image = useMemo(() => {
if (imageEncoding) { if (encodingImage) {
return <LoadingIndicator message="Encoding image..." />; return <LoadingIndicator message="Encoding image..." />;
} }
@@ -703,7 +696,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
} }
return <div></div>; return <div></div>;
}, [imageEncoding, coverImagePreview, intl]); }, [encodingImage, coverImagePreview, intl]);
if (isLoading) return <LoadingIndicator />; if (isLoading) return <LoadingIndicator />;
@@ -722,7 +715,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Button <Button
className="edit-button" className="edit-button"
variant="primary" variant="primary"
disabled={!isNew && !formik.dirty} disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()} onClick={() => formik.submitForm()}
> >
<FormattedMessage id="actions.save" /> <FormattedMessage id="actions.save" />
@@ -946,10 +941,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Form.Control <Form.Control
as="textarea" as="textarea"
className="scene-description text-input" className="scene-description text-input"
onChange={(newValue: React.ChangeEvent<HTMLTextAreaElement>) => onChange={(e) =>
formik.setFieldValue("details", newValue.currentTarget.value) formik.setFieldValue("details", e.currentTarget.value)
} }
value={formik.values.details} value={formik.values.details ?? ""}
/> />
</Form.Group> </Form.Group>
<div> <div>

View File

@@ -12,7 +12,6 @@ import {
useStudioDestroy, useStudioDestroy,
mutateMetadataAutoTag, mutateMetadataAutoTag,
} from "src/core/StashService"; } from "src/core/StashService";
import ImageUtils from "src/utils/image";
import { Counter } from "src/components/Shared/Counter"; import { Counter } from "src/components/Shared/Counter";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { ModalComponent } from "src/components/Shared/Modal"; import { ModalComponent } from "src/components/Shared/Modal";
@@ -54,8 +53,9 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Studio state // Editing studio state
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [updateStudio] = useStudioUpdate(); const [updateStudio] = useStudioUpdate();
const [deleteStudio] = useStudioDestroy({ id: studio.id }); const [deleteStudio] = useStudioDestroy({ id: studio.id });
@@ -73,17 +73,14 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
}; };
}); });
function onImageLoad(imageData: string) { async function onSave(input: GQL.StudioCreateInput) {
setImage(imageData);
}
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
async function onSave(input: Partial<GQL.StudioUpdateInput>) {
try { try {
const result = await updateStudio({ const result = await updateStudio({
variables: { variables: {
input: input as GQL.StudioUpdateInput, input: {
id: studio.id,
...input,
},
}, },
}); });
if (result.data?.studioUpdate) { if (result.data?.studioUpdate) {
@@ -181,7 +178,7 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
<div className="row"> <div className="row">
<div className="studio-details col-md-4"> <div className="studio-details col-md-4">
<div className="text-center"> <div className="text-center">
{imageEncoding ? ( {encodingImage ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
) : ( ) : (
renderImage() renderImage()
@@ -213,7 +210,8 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
onSubmit={onSave} onSubmit={onSave}
onCancel={onToggleEdit} onCancel={onToggleEdit}
onDelete={onDelete} onDelete={onDelete}
onImageChange={setImage} setImage={setImage}
setEncodingImage={setEncodingImage}
/> />
)} )}
</div> </div>

View File

@@ -4,7 +4,6 @@ import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { useStudioCreate } from "src/core/StashService"; import { useStudioCreate } from "src/core/StashService";
import ImageUtils from "src/utils/image";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { StudioEditPanel } from "./StudioEditPanel"; import { StudioEditPanel } from "./StudioEditPanel";
@@ -21,25 +20,16 @@ const StudioCreate: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
// Studio state // Editing studio state
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [createStudio] = useStudioCreate(); const [createStudio] = useStudioCreate();
function onImageLoad(imageData: string) { async function onSave(input: GQL.StudioCreateInput) {
setImage(imageData);
}
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
async function onSave(
input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput>
) {
try { try {
const result = await createStudio({ const result = await createStudio({
variables: { variables: { input },
input: input as GQL.StudioCreateInput,
},
}); });
if (result.data?.studioCreate?.id) { if (result.data?.studioCreate?.id) {
history.push(`/studios/${result.data.studioCreate.id}`); history.push(`/studios/${result.data.studioCreate.id}`);
@@ -65,7 +55,7 @@ const StudioCreate: React.FC = () => {
)} )}
</h2> </h2>
<div className="text-center"> <div className="text-center">
{imageEncoding ? ( {encodingImage ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
) : ( ) : (
renderImage() renderImage()
@@ -74,9 +64,10 @@ const StudioCreate: React.FC = () => {
<StudioEditPanel <StudioEditPanel
studio={studio} studio={studio}
onSubmit={onSave} onSubmit={onSave}
onImageChange={setImage}
onCancel={() => history.push("/studios")} onCancel={() => history.push("/studios")}
onDelete={() => {}} onDelete={() => {}}
setImage={setImage}
setEncodingImage={setEncodingImage}
/> />
</div> </div>
</div> </div>

View File

@@ -17,16 +17,15 @@ import { StringListInput } from "../../Shared/StringListInput";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
interface IStudioEditPanel { interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>; studio: Partial<GQL.StudioDataFragment>;
onSubmit: ( onSubmit: (studio: GQL.StudioCreateInput) => void;
studio: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput>
) => void;
onCancel: () => void; onCancel: () => void;
onDelete: () => void; onDelete: () => void;
onImageChange?: (image?: string | null) => void; setImage: (image?: string | null) => void;
onImageEncoding?: (loading?: boolean) => void; setEncodingImage: (loading: boolean) => void;
} }
export const StudioEditPanel: React.FC<IStudioEditPanel> = ({ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
@@ -34,83 +33,77 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
onSubmit, onSubmit,
onCancel, onCancel,
onDelete, onDelete,
onImageChange, setImage,
onImageEncoding, setEncodingImage,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const isNew = studio.id === undefined; const isNew = studio.id === undefined;
const { configuration } = React.useContext(ConfigurationContext); const { configuration } = React.useContext(ConfigurationContext);
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
url: yup.string().optional().nullable(), url: yup.string().ensure(),
details: yup.string().optional().nullable(), details: yup.string().ensure(),
image: yup.string().optional().nullable(), parent_id: yup.string().required().nullable(),
rating100: yup.number().optional().nullable(), rating100: yup.number().nullable().defined(),
parent_id: yup.string().optional().nullable(),
stash_ids: yup.mixed<GQL.StashIdInput[]>().optional().nullable(),
aliases: yup aliases: yup
.array(yup.string().required()) .array(yup.string().required())
.optional() .defined()
.test({ .test({
name: "unique", name: "unique",
// eslint-disable-next-line @typescript-eslint/no-explicit-any test: (value, context) => {
test: (value: any) => { if (!value) return true;
return (value ?? []).length === new Set(value).size; const aliases = new Set(value);
aliases.add(context.parent.name);
return value.length + 1 === aliases.size;
}, },
message: "aliases must be unique", message: intl.formatMessage({
id: "validation.aliases_must_be_unique",
}),
}), }),
ignore_auto_tag: yup.boolean().optional(), ignore_auto_tag: yup.boolean().defined(),
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
image: yup.string().nullable().optional(),
}); });
const initialValues = { const initialValues = {
name: studio.name ?? "", name: studio.name ?? "",
url: studio.url ?? "", url: studio.url ?? "",
details: studio.details ?? "", details: studio.details ?? "",
image: undefined, parent_id: studio.parent_studio?.id ?? null,
rating100: studio.rating100 ?? null, rating100: studio.rating100 ?? null,
parent_id: studio.parent_studio?.id, aliases: studio.aliases ?? [],
stash_ids: studio.stash_ids ?? undefined,
aliases: studio.aliases,
ignore_auto_tag: studio.ignore_auto_tag ?? false, ignore_auto_tag: studio.ignore_auto_tag ?? false,
stash_ids: getStashIDs(studio.stash_ids),
}; };
type InputValues = typeof initialValues; type InputValues = yup.InferType<typeof schema>;
const formik = useFormik({ const formik = useFormik<InputValues>({
initialValues, initialValues,
enableReinitialize: true,
validationSchema: schema, validationSchema: schema,
onSubmit: (values) => onSubmit(getStudioInput(values)), onSubmit: (values) => onSubmit(values),
}); });
// always dirty if creating a new studio with a name const encodingImage = ImageUtils.usePasteImage((imageData) =>
if (isNew && studio.name) { formik.setFieldValue("image", imageData)
formik.dirty = true; );
}
useEffect(() => {
setImage(formik.values.image);
}, [formik.values.image, setImage]);
useEffect(
() => setEncodingImage(encodingImage),
[setEncodingImage, encodingImage]
);
function setRating(v: number) { function setRating(v: number) {
formik.setFieldValue("rating100", v); formik.setFieldValue("rating100", v);
} }
function onImageLoad(imageData: string) {
formik.setFieldValue("image", imageData);
}
function getStudioInput(values: InputValues) {
const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = {
...values,
stash_ids: getStashIDs(values.stash_ids),
};
if (studio && studio.id) {
(input as GQL.StudioUpdateInput).id = studio.id;
}
return input;
}
useRatingKeybinds( useRatingKeybinds(
true, true,
configuration?.ui?.ratingSystemOptions?.type, configuration?.ui?.ratingSystemOptions?.type,
@@ -126,24 +119,12 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
}; };
}); });
useEffect(() => { function onImageLoad(imageData: string | null) {
if (onImageChange) { formik.setFieldValue("image", imageData);
onImageChange(formik.values.image);
}
return () => onImageChange?.();
}, [formik.values.image, onImageChange]);
useEffect(
() => onImageEncoding?.(imageEncoding),
[onImageEncoding, imageEncoding]
);
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
} }
function onImageChangeURL(url: string) { function onImageChange(event: React.FormEvent<HTMLInputElement>) {
formik.setFieldValue("image", url); ImageUtils.onImageChange(event, onImageLoad);
} }
const removeStashID = (stashID: GQL.StashIdInput) => { const removeStashID = (stashID: GQL.StashIdInput) => {
@@ -306,7 +287,11 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
<StringListInput <StringListInput
value={formik.values.aliases ?? []} value={formik.values.aliases ?? []}
setValue={(value) => formik.setFieldValue("aliases", value)} setValue={(value) => formik.setFieldValue("aliases", value)}
errors={formik.errors.aliases} errors={
Array.isArray(formik.errors.aliases)
? formik.errors.aliases[0]
: formik.errors.aliases
}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
@@ -333,13 +318,11 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
isNew={isNew} isNew={isNew}
isEditing isEditing
onToggleEdit={onCancel} onToggleEdit={onCancel}
onSave={() => formik.handleSubmit()} onSave={formik.handleSubmit}
saveDisabled={!formik.dirty} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onImageChange={onImageChangeHandler} onImageChange={onImageChange}
onImageChangeURL={onImageChangeURL} onImageChangeURL={onImageLoad}
onClearImage={() => { onClearImage={() => onImageLoad(null)}
formik.setFieldValue("image", null);
}}
onDelete={onDelete} onDelete={onDelete}
acceptSVG acceptSVG
/> />

View File

@@ -12,7 +12,6 @@ import {
useTagDestroy, useTagDestroy,
mutateMetadataAutoTag, mutateMetadataAutoTag,
} from "src/core/StashService"; } from "src/core/StashService";
import ImageUtils from "src/utils/image";
import { Counter } from "src/components/Shared/Counter"; import { Counter } from "src/components/Shared/Counter";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { ErrorMessage } from "src/components/Shared/ErrorMessage";
@@ -64,6 +63,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
// Editing tag state // Editing tag state
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [updateTag] = useTagUpdate(); const [updateTag] = useTagUpdate();
const [deleteTag] = useTagDestroy({ id: tag.id }); const [deleteTag] = useTagDestroy({ id: tag.id });
@@ -99,27 +99,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
}; };
}); });
function onImageLoad(imageData: string) { async function onSave(input: GQL.TagCreateInput) {
setImage(imageData);
}
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
function getTagInput(
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
) {
const ret: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = {
...input,
image,
id: tag.id,
};
return ret;
}
async function onSave(
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
) {
try { try {
const oldRelations = { const oldRelations = {
parents: tag.parents ?? [], parents: tag.parents ?? [],
@@ -127,7 +107,10 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
}; };
const result = await updateTag({ const result = await updateTag({
variables: { variables: {
input: getTagInput(input) as GQL.TagUpdateInput, input: {
id: tag.id,
...input,
},
}, },
}); });
if (result.data?.tagUpdate) { if (result.data?.tagUpdate) {
@@ -270,7 +253,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
<div className="row"> <div className="row">
<div className="tag-details col-md-4"> <div className="tag-details col-md-4">
<div className="text-center logo-container"> <div className="text-center logo-container">
{imageEncoding ? ( {encodingImage ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
) : ( ) : (
renderImage() renderImage()
@@ -303,6 +286,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
onCancel={onToggleEdit} onCancel={onToggleEdit}
onDelete={onDelete} onDelete={onDelete}
setImage={setImage} setImage={setImage}
setEncodingImage={setEncodingImage}
/> />
)} )}
</div> </div>

View File

@@ -3,7 +3,6 @@ import { useHistory, useLocation } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { useTagCreate } from "src/core/StashService"; import { useTagCreate } from "src/core/StashService";
import ImageUtils from "src/utils/image";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { tagRelationHook } from "src/core/tags"; import { tagRelationHook } from "src/core/tags";
@@ -21,38 +20,18 @@ const TagCreate: React.FC = () => {
// Editing tag state // Editing tag state
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [createTag] = useTagCreate(); const [createTag] = useTagCreate();
function onImageLoad(imageData: string) { async function onSave(input: GQL.TagCreateInput) {
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 { try {
const oldRelations = { const oldRelations = {
parents: [], parents: [],
children: [], children: [],
}; };
const result = await createTag({ const result = await createTag({
variables: { variables: { input },
input: getTagInput(input) as GQL.TagCreateInput,
},
}); });
if (result.data?.tagCreate?.id) { if (result.data?.tagCreate?.id) {
const created = result.data.tagCreate; const created = result.data.tagCreate;
@@ -77,7 +56,7 @@ const TagCreate: React.FC = () => {
<div className="row"> <div className="row">
<div className="tag-details col-md-8"> <div className="tag-details col-md-8">
<div className="text-center logo-container"> <div className="text-center logo-container">
{imageEncoding ? ( {encodingImage ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
) : ( ) : (
renderImage() renderImage()
@@ -89,6 +68,7 @@ const TagCreate: React.FC = () => {
onCancel={() => history.push("/tags")} onCancel={() => history.push("/tags")}
onDelete={() => {}} onDelete={() => {}}
setImage={setImage} setImage={setImage}
setEncodingImage={setEncodingImage}
/> />
</div> </div>
</div> </div>

View File

@@ -11,14 +11,16 @@ import { useFormik } from "formik";
import { Prompt } from "react-router-dom"; import { Prompt } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { StringListInput } from "src/components/Shared/StringListInput"; import { StringListInput } from "src/components/Shared/StringListInput";
import isEqual from "lodash-es/isEqual";
interface ITagEditPanel { interface ITagEditPanel {
tag: Partial<GQL.TagDataFragment>; tag: Partial<GQL.TagDataFragment>;
// returns id // returns id
onSubmit: (tag: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>) => void; onSubmit: (tag: GQL.TagCreateInput) => void;
onCancel: () => void; onCancel: () => void;
onDelete: () => void; onDelete: () => void;
setImage: (image?: string | null) => void; setImage: (image?: string | null) => void;
setEncodingImage: (loading: boolean) => void;
} }
export const TagEditPanel: React.FC<ITagEditPanel> = ({ export const TagEditPanel: React.FC<ITagEditPanel> = ({
@@ -27,6 +29,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
onCancel, onCancel,
onDelete, onDelete,
setImage, setImage,
setEncodingImage,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -39,46 +42,46 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
description: yup.string().optional().nullable(),
aliases: yup aliases: yup
.array(yup.string().required()) .array(yup.string().required())
.optional() .defined()
.test({ .test({
name: "unique", name: "unique",
// eslint-disable-next-line @typescript-eslint/no-explicit-any test: (value, context) => {
test: (value: any) => { if (!value) return true;
return (value ?? []).length === new Set(value).size; const aliases = new Set(value);
aliases.add(context.parent.name);
return value.length + 1 === aliases.size;
}, },
message: intl.formatMessage({ id: "dialogs.aliases_must_be_unique" }), message: intl.formatMessage({
id: "validation.aliases_must_be_unique",
}),
}), }),
parent_ids: yup.array(yup.string().required()).optional().nullable(), description: yup.string().ensure(),
child_ids: yup.array(yup.string().required()).optional().nullable(), parent_ids: yup.array(yup.string().required()).defined(),
ignore_auto_tag: yup.boolean().optional(), child_ids: yup.array(yup.string().required()).defined(),
ignore_auto_tag: yup.boolean().defined(),
image: yup.string().nullable().optional(),
}); });
const initialValues = { const initialValues = {
name: tag?.name, name: tag?.name ?? "",
description: tag?.description, aliases: tag?.aliases ?? [],
aliases: tag?.aliases, description: tag?.description ?? "",
parent_ids: (tag?.parents ?? []).map((t) => t.id), parent_ids: (tag?.parents ?? []).map((t) => t.id),
child_ids: (tag?.children ?? []).map((t) => t.id), child_ids: (tag?.children ?? []).map((t) => t.id),
ignore_auto_tag: tag?.ignore_auto_tag ?? false, ignore_auto_tag: tag?.ignore_auto_tag ?? false,
}; };
type InputValues = typeof initialValues; type InputValues = yup.InferType<typeof schema>;
const formik = useFormik({ const formik = useFormik<InputValues>({
initialValues, initialValues,
validationSchema: schema, validationSchema: schema,
enableReinitialize: true, enableReinitialize: true,
onSubmit: (values) => onSubmit(getTagInput(values)), onSubmit: (values) => onSubmit(values),
}); });
// always dirty if creating a new tag with a name
if (isNew && tag?.name) {
formik.dirty = true;
}
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
Mousetrap.bind("s s", () => formik.handleSubmit()); Mousetrap.bind("s s", () => formik.handleSubmit());
@@ -88,19 +91,22 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
}; };
}); });
function getTagInput(values: InputValues) { const encodingImage = ImageUtils.usePasteImage(onImageLoad);
const input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = {
...values,
};
if (tag && tag.id) { useEffect(() => {
(input as GQL.TagUpdateInput).id = tag.id; setImage(formik.values.image);
} }, [formik.values.image, setImage]);
return input;
useEffect(() => {
setEncodingImage(encodingImage);
}, [setEncodingImage, encodingImage]);
function onImageLoad(imageData: string | null) {
formik.setFieldValue("image", imageData);
} }
function onImageChange(event: React.FormEvent<HTMLInputElement>) { function onImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, setImage); ImageUtils.onImageChange(event, onImageLoad);
} }
const isEditing = true; const isEditing = true;
@@ -120,7 +126,8 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
<Prompt <Prompt
when={formik.dirty} when={formik.dirty}
message={(location, action) => { message={(location, action) => {
if (action === "PUSH" && location.pathname.startsWith(`/tags/`)) { // Check if it's a redirect after movie creation
if (action === "PUSH" && location.pathname.startsWith("/tags/")) {
return true; return true;
} }
return intl.formatMessage({ id: "dialogs.unsaved_changes" }); return intl.formatMessage({ id: "dialogs.unsaved_changes" });
@@ -151,9 +158,13 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<StringListInput <StringListInput
value={formik.values.aliases ?? []} value={formik.values.aliases}
setValue={(value) => formik.setFieldValue("aliases", value)} setValue={(value) => formik.setFieldValue("aliases", value)}
errors={formik.errors.aliases} errors={
Array.isArray(formik.errors.aliases)
? formik.errors.aliases[0]
: formik.errors.aliases
}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
@@ -191,9 +202,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
) )
} }
ids={formik.values.parent_ids} ids={formik.values.parent_ids}
excludeIds={(tag?.id ? [tag.id] : []).concat( excludeIds={[
...formik.values.child_ids ...(tag?.id ? [tag.id] : []),
)} ...formik.values.child_ids,
]}
creatable={false} creatable={false}
/> />
</Col> </Col>
@@ -218,9 +230,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
) )
} }
ids={formik.values.child_ids} ids={formik.values.child_ids}
excludeIds={(tag?.id ? [tag.id] : []).concat( excludeIds={[
...formik.values.parent_ids ...(tag?.id ? [tag.id] : []),
)} ...formik.values.parent_ids,
]}
creatable={false} creatable={false}
/> />
</Col> </Col>
@@ -248,13 +261,11 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
isNew={isNew} isNew={isNew}
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={onCancel} onToggleEdit={onCancel}
onSave={() => formik.handleSubmit()} onSave={formik.handleSubmit}
saveDisabled={!formik.dirty} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onImageChange={onImageChange} onImageChange={onImageChange}
onImageChangeURL={setImage} onImageChangeURL={onImageLoad}
onClearImage={() => { onClearImage={() => onImageLoad(null)}
setImage(null);
}}
onDelete={onDelete} onDelete={onDelete}
acceptSVG acceptSVG
/> />

View File

@@ -15,6 +15,7 @@
* Overhauled and improved HLS streaming. ([#3274](https://github.com/stashapp/stash/pull/3274)) * Overhauled and improved HLS streaming. ([#3274](https://github.com/stashapp/stash/pull/3274))
### 🐛 Bug fixes ### 🐛 Bug fixes
* Fixed Save button being disabled when stting Tag image. ([#3509](https://github.com/stashapp/stash/pull/3509))
* Fixed incorrect performer with identical name being matched when scraping from stash-box. ([#3488](https://github.com/stashapp/stash/pull/3488)) * Fixed incorrect performer with identical name being matched when scraping from stash-box. ([#3488](https://github.com/stashapp/stash/pull/3488))
* Fixed scene cover not being included when submitting file-less scenes to stash-box. ([#3465](https://github.com/stashapp/stash/pull/3465)) * Fixed scene cover not being included when submitting file-less scenes to stash-box. ([#3465](https://github.com/stashapp/stash/pull/3465))
* Fixed URL not being during stash-box scrape if the Studio URL is not set. ([#3439](https://github.com/stashapp/stash/pull/3439)) * Fixed URL not being during stash-box scrape if the Studio URL is not set. ([#3439](https://github.com/stashapp/stash/pull/3439))

View File

@@ -646,7 +646,6 @@
"details": "Detaily", "details": "Detaily",
"developmentVersion": "Vývojářská verze", "developmentVersion": "Vývojářská verze",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "aliasy musí být unikátní",
"delete_alert": "Následující {count, plural, one {{singularEntity}} other {{pluralEntity}}} budou permanentně smazány:", "delete_alert": "Následující {count, plural, one {{singularEntity}} other {{pluralEntity}}} budou permanentně smazány:",
"delete_confirm": "Jste si jisti, že chcete smazat {entityName}?", "delete_confirm": "Jste si jisti, že chcete smazat {entityName}?",
"delete_entity_desc": "{count, plural, one {Jste si jisti, že checete smazat toto {singularEntity}? Pokud není soubor rovněž smazán, tato {singularEntity} bude znovu přidána při příštím skenování.} other {Jste si jisti, že chcete smazat tyto {pluralEntity}? Pokud nejsou soubory rovněž smazány, tyto {pluralEntity} budou znovu přidány při příštím skenování.}}", "delete_entity_desc": "{count, plural, one {Jste si jisti, že checete smazat toto {singularEntity}? Pokud není soubor rovněž smazán, tato {singularEntity} bude znovu přidána při příštím skenování.} other {Jste si jisti, že chcete smazat tyto {pluralEntity}? Pokud nejsou soubory rovněž smazány, tyto {pluralEntity} budou znovu přidány při příštím skenování.}}",
@@ -696,5 +695,8 @@
"marker_image_previews_tooltip": "Animované WebP náhledy markerů, nezbytné pouze tehdy, pokud je Typ náhledu nastaven na \"Animovaný obrázek\".", "marker_image_previews_tooltip": "Animované WebP náhledy markerů, nezbytné pouze tehdy, pokud je Typ náhledu nastaven na \"Animovaný obrázek\".",
"marker_screenshots": "Screenshoty markerů" "marker_screenshots": "Screenshoty markerů"
} }
},
"validation": {
"aliases_must_be_unique": "aliasy musí být unikátní"
} }
} }

View File

@@ -614,7 +614,6 @@
"details": "Detaljer", "details": "Detaljer",
"developmentVersion": "Udviklingsversion", "developmentVersion": "Udviklingsversion",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "aliaser skal være unikke",
"delete_alert": "Følgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} vil blive slettet permanent:", "delete_alert": "Følgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} vil blive slettet permanent:",
"delete_confirm": "Er du sikker på, at du vil slette {entityName}?", "delete_confirm": "Er du sikker på, at du vil slette {entityName}?",
"delete_entity_desc": "{count, plural, one {Er du sikker på, at du vil slette denne {singularEntity}? Medmindre filen også slettes, vil denne {singularEntity} blive tilføjet igen, når scanningen udføres.} andet {Er du sikker på, at du vil slette disse {pluralEntity}? Medmindre filerne også slettes, vil disse {pluralEntity} blive tilføjet igen, når scanningen udføres.}}", "delete_entity_desc": "{count, plural, one {Er du sikker på, at du vil slette denne {singularEntity}? Medmindre filen også slettes, vil denne {singularEntity} blive tilføjet igen, når scanningen udføres.} andet {Er du sikker på, at du vil slette disse {pluralEntity}? Medmindre filerne også slettes, vil disse {pluralEntity} blive tilføjet igen, når scanningen udføres.}}",
@@ -1039,6 +1038,9 @@
"type": "Type", "type": "Type",
"updated_at": "Opdateret Den", "updated_at": "Opdateret Den",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "aliaser skal være unikke"
},
"videos": "Videoer", "videos": "Videoer",
"view_all": "Se alle", "view_all": "Se alle",
"weight": "Vægt", "weight": "Vægt",

View File

@@ -662,7 +662,6 @@
"details": "Details", "details": "Details",
"developmentVersion": "Entwicklungsversion", "developmentVersion": "Entwicklungsversion",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "Aliase müssen einzigartig sein",
"create_new_entity": "Neues {entity} erstellen", "create_new_entity": "Neues {entity} erstellen",
"delete_alert": "Folgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} werden dauerhaft gelöscht:", "delete_alert": "Folgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} werden dauerhaft gelöscht:",
"delete_confirm": "Möchten Sie {entityName} wirklich löschen?", "delete_confirm": "Möchten Sie {entityName} wirklich löschen?",
@@ -1118,6 +1117,9 @@
"type": "Typ", "type": "Typ",
"updated_at": "Aktualisiert am", "updated_at": "Aktualisiert am",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "Aliase müssen einzigartig sein"
},
"videos": "Videos", "videos": "Videos",
"view_all": "Alle ansehen", "view_all": "Alle ansehen",
"weight": "Gewicht", "weight": "Gewicht",

View File

@@ -699,7 +699,6 @@
"details": "Details", "details": "Details",
"developmentVersion": "Development Version", "developmentVersion": "Development Version",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "aliases must be unique",
"create_new_entity": "Create new {entity}", "create_new_entity": "Create new {entity}",
"delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:", "delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:",
"delete_confirm": "Are you sure you want to delete {entityName}?", "delete_confirm": "Are you sure you want to delete {entityName}?",
@@ -1164,6 +1163,11 @@
"type": "Type", "type": "Type",
"updated_at": "Updated At", "updated_at": "Updated At",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "aliases must be unique",
"date_invalid_form": "${path} must be in YYYY-MM-DD form",
"required": "${path} is a required field"
},
"videos": "Videos", "videos": "Videos",
"view_all": "View All", "view_all": "View All",
"play_count": "Play Count", "play_count": "Play Count",

View File

@@ -607,7 +607,6 @@
"details": "Detalles", "details": "Detalles",
"developmentVersion": "Versión de desarrollo", "developmentVersion": "Versión de desarrollo",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "Los alias deben ser únicos",
"delete_alert": "El/los siguiente/s {count, plural, one {{singularEntity}} other {{pluralEntity}}} se eliminará/n de forma permanente:", "delete_alert": "El/los siguiente/s {count, plural, one {{singularEntity}} other {{pluralEntity}}} se eliminará/n de forma permanente:",
"delete_confirm": "¿Estás seguro que deseas eliminar {entityName}?", "delete_confirm": "¿Estás seguro que deseas eliminar {entityName}?",
"delete_entity_desc": "{count, plural, one {¿Estás seguro que deseas eliminar esta {singularEntity}? Hasta que el archivo sea eliminado también, esta {singularEntity} se volverá a añadir cuando se lleve a cabo un escaneo.} other {¿Estás seguro que deseas eliminar {pluralEntity}? Hasta que los archivos sean eliminados del sistema de ficheros también, estas {pluralEntity} se volverán a añadir cuando se lleve a cabo un escaneo.}}", "delete_entity_desc": "{count, plural, one {¿Estás seguro que deseas eliminar esta {singularEntity}? Hasta que el archivo sea eliminado también, esta {singularEntity} se volverá a añadir cuando se lleve a cabo un escaneo.} other {¿Estás seguro que deseas eliminar {pluralEntity}? Hasta que los archivos sean eliminados del sistema de ficheros también, estas {pluralEntity} se volverán a añadir cuando se lleve a cabo un escaneo.}}",
@@ -1017,6 +1016,9 @@
"twitter": "Twitter", "twitter": "Twitter",
"updated_at": "Fecha de modificación", "updated_at": "Fecha de modificación",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "Los alias deben ser únicos"
},
"videos": "Vídeos", "videos": "Vídeos",
"weight": "Peso", "weight": "Peso",
"years_old": "años" "years_old": "años"

View File

@@ -666,7 +666,6 @@
"details": "Detailid", "details": "Detailid",
"developmentVersion": "Arendusversioon", "developmentVersion": "Arendusversioon",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "aliased peavad olema erilised",
"create_new_entity": "Loo uus {entity}", "create_new_entity": "Loo uus {entity}",
"delete_alert": "Järgnev {count, plural, one {{singularEntity}} other {{pluralEntity}}} kustutatakse lõplikult:", "delete_alert": "Järgnev {count, plural, one {{singularEntity}} other {{pluralEntity}}} kustutatakse lõplikult:",
"delete_confirm": "Kas oled kindel, et soovid kustutada {entityName}?", "delete_confirm": "Kas oled kindel, et soovid kustutada {entityName}?",
@@ -1124,6 +1123,9 @@
"type": "Tüüp", "type": "Tüüp",
"updated_at": "Viimati Uuendatud", "updated_at": "Viimati Uuendatud",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "aliased peavad olema erilised"
},
"videos": "Videod", "videos": "Videod",
"view_all": "Vaata Kõiki", "view_all": "Vaata Kõiki",
"weight": "Kaal", "weight": "Kaal",

View File

@@ -614,7 +614,6 @@
"details": "Lisätiedot", "details": "Lisätiedot",
"developmentVersion": "Kehitysversio", "developmentVersion": "Kehitysversio",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "Aliaksien pitää olla uniikkeja",
"create_new_entity": "Luo uus {entity}", "create_new_entity": "Luo uus {entity}",
"delete_alert": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} poistetaan pysyvästi:", "delete_alert": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} poistetaan pysyvästi:",
"delete_confirm": "Haluatko varmasti poistaa {entityName}?", "delete_confirm": "Haluatko varmasti poistaa {entityName}?",
@@ -1028,6 +1027,9 @@
"twitter": "Twitter", "twitter": "Twitter",
"updated_at": "Päivitetty", "updated_at": "Päivitetty",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "Aliaksien pitää olla uniikkeja"
},
"videos": "Videot", "videos": "Videot",
"view_all": "Näytä kaikki", "view_all": "Näytä kaikki",
"weight": "Paino", "weight": "Paino",

View File

@@ -666,7 +666,6 @@
"details": "Détails", "details": "Détails",
"developmentVersion": "Version de développement", "developmentVersion": "Version de développement",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "Les alias doivent être uniques",
"create_new_entity": "Créer un nouveau {entity}", "create_new_entity": "Créer un nouveau {entity}",
"delete_alert": "{count, plural, one {Le {singularEntity} suivant sera supprimé} other {Les {pluralEntity} suivants seront supprimés}} définitivement:", "delete_alert": "{count, plural, one {Le {singularEntity} suivant sera supprimé} other {Les {pluralEntity} suivants seront supprimés}} définitivement:",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer {entityName} ?", "delete_confirm": "Êtes-vous sûr de vouloir supprimer {entityName} ?",
@@ -1124,6 +1123,9 @@
"type": "Type", "type": "Type",
"updated_at": "Actualisé le", "updated_at": "Actualisé le",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "Les alias doivent être uniques"
},
"videos": "Vidéos", "videos": "Vidéos",
"view_all": "Tout voir", "view_all": "Tout voir",
"weight": "Poids", "weight": "Poids",

View File

@@ -668,7 +668,6 @@
"details": "Dettagli", "details": "Dettagli",
"developmentVersion": "Versione Sviluppo", "developmentVersion": "Versione Sviluppo",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "gli alias devono essere univoci",
"create_new_entity": "Crea nuovo/a {entity}", "create_new_entity": "Crea nuovo/a {entity}",
"delete_alert": "Il seguente/I seguenti {count, plural, one {{singularEntity}} other {{pluralEntity}}} sarà/saranno cancellati permanentemente:", "delete_alert": "Il seguente/I seguenti {count, plural, one {{singularEntity}} other {{pluralEntity}}} sarà/saranno cancellati permanentemente:",
"delete_confirm": "Sei sicuro di voler cancellare {entityName}?", "delete_confirm": "Sei sicuro di voler cancellare {entityName}?",
@@ -1126,6 +1125,9 @@
"type": "Tipo", "type": "Tipo",
"updated_at": "Aggiornato Al", "updated_at": "Aggiornato Al",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "gli alias devono essere univoci"
},
"videos": "Video", "videos": "Video",
"view_all": "Vedi Tutto", "view_all": "Vedi Tutto",
"weight": "Peso", "weight": "Peso",

View File

@@ -666,7 +666,6 @@
"details": "詳細", "details": "詳細",
"developmentVersion": "開発者バージョン", "developmentVersion": "開発者バージョン",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "別名は一意でなければいけません",
"create_new_entity": "{entity}を新規作成する", "create_new_entity": "{entity}を新規作成する",
"delete_alert": "次の{count, plural, one {{singularEntity}} other {{pluralEntity}}}は完全に削除されます:", "delete_alert": "次の{count, plural, one {{singularEntity}} other {{pluralEntity}}}は完全に削除されます:",
"delete_confirm": "本当に{entityName}を削除してよろしいですか?", "delete_confirm": "本当に{entityName}を削除してよろしいですか?",
@@ -1124,6 +1123,9 @@
"type": "タイプ", "type": "タイプ",
"updated_at": "更新日:", "updated_at": "更新日:",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "別名は一意でなければいけません"
},
"videos": "動画", "videos": "動画",
"view_all": "全て表示", "view_all": "全て表示",
"weight": "幅", "weight": "幅",

View File

@@ -661,7 +661,6 @@
"details": "세부사항", "details": "세부사항",
"developmentVersion": "개발 버전", "developmentVersion": "개발 버전",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "별칭은 유일해야 합니다",
"create_new_entity": "새로운 {entity} 생성", "create_new_entity": "새로운 {entity} 생성",
"delete_alert": "다음 {count, plural, one {{singularEntity}} other {{pluralEntity}}}이(가) 영구 삭제될 것입니다:", "delete_alert": "다음 {count, plural, one {{singularEntity}} other {{pluralEntity}}}이(가) 영구 삭제될 것입니다:",
"delete_confirm": "정말 {entityName}을 삭제하시겠습니까?", "delete_confirm": "정말 {entityName}을 삭제하시겠습니까?",
@@ -1107,6 +1106,9 @@
"type": "유형", "type": "유형",
"updated_at": "수정 날짜", "updated_at": "수정 날짜",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "별칭은 유일해야 합니다"
},
"videos": "비디오", "videos": "비디오",
"view_all": "모두 보기", "view_all": "모두 보기",
"weight": "몸무게", "weight": "몸무게",

View File

@@ -590,7 +590,6 @@
"details": "Details", "details": "Details",
"developmentVersion": "Ontwikkelingsversie", "developmentVersion": "Ontwikkelingsversie",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "aliases moeten uniek zijn",
"delete_alert": "De volgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} zal permanent verwijderd worden:", "delete_alert": "De volgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} zal permanent verwijderd worden:",
"delete_confirm": "Weet je zeker dat je {entityName} wilt verwijderen?", "delete_confirm": "Weet je zeker dat je {entityName} wilt verwijderen?",
"delete_entity_desc": "{count, plural, one {Weet u zeker dat u deze {singularEntity} wilt verwijderen? Tenzij het bestand ook wordt verwijderd, wordt deze {singularEntity} opnieuw toegevoegd wanneer de scan wordt uitgevoerd.} other {Weet u zeker dat u deze {pluralEntity} wilt verwijderen? Tenzij de bestanden ook worden verwijderd, worden deze {pluralEntity} opnieuw toegevoegd wanneer de scan wordt uitgevoerd.}}", "delete_entity_desc": "{count, plural, one {Weet u zeker dat u deze {singularEntity} wilt verwijderen? Tenzij het bestand ook wordt verwijderd, wordt deze {singularEntity} opnieuw toegevoegd wanneer de scan wordt uitgevoerd.} other {Weet u zeker dat u deze {pluralEntity} wilt verwijderen? Tenzij de bestanden ook worden verwijderd, worden deze {pluralEntity} opnieuw toegevoegd wanneer de scan wordt uitgevoerd.}}",
@@ -988,6 +987,9 @@
"twitter": "Twitter", "twitter": "Twitter",
"updated_at": "Bijgewerkt op", "updated_at": "Bijgewerkt op",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "aliases moeten uniek zijn"
},
"videos": "Video's", "videos": "Video's",
"view_all": "Alles weergeven", "view_all": "Alles weergeven",
"weight": "Gewicht", "weight": "Gewicht",

View File

@@ -666,7 +666,6 @@
"details": "Szczegóły", "details": "Szczegóły",
"developmentVersion": "Wersja deweloperska", "developmentVersion": "Wersja deweloperska",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "aliasy muszą być unikatowe",
"create_new_entity": "Dodaj {entity}", "create_new_entity": "Dodaj {entity}",
"delete_alert": "Następujące elementy {count, plural, one {{singularEntity}} other {{pluralEntity}}} zostaną trwale usunięte:", "delete_alert": "Następujące elementy {count, plural, one {{singularEntity}} other {{pluralEntity}}} zostaną trwale usunięte:",
"delete_confirm": "Czy na pewno chcesz usunąć {entityName}?", "delete_confirm": "Czy na pewno chcesz usunąć {entityName}?",
@@ -1124,6 +1123,9 @@
"type": "Typ", "type": "Typ",
"updated_at": "Zaktualizowano", "updated_at": "Zaktualizowano",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "aliasy muszą być unikatowe"
},
"videos": "Filmy wideo", "videos": "Filmy wideo",
"view_all": "Pokaż wszystko", "view_all": "Pokaż wszystko",
"weight": "Waga", "weight": "Waga",

View File

@@ -592,7 +592,6 @@
"details": "Detalhes", "details": "Detalhes",
"developmentVersion": "Versão de desenvolvimento", "developmentVersion": "Versão de desenvolvimento",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "apelidos devem ser únicos",
"delete_alert": "Os seguintes {count, plural, one {{singularEntity}} other {{pluralEntity}}} serão deletados permanentemente:", "delete_alert": "Os seguintes {count, plural, one {{singularEntity}} other {{pluralEntity}}} serão deletados permanentemente:",
"delete_confirm": "Tem certeza de que deseja excluir {entityName}?", "delete_confirm": "Tem certeza de que deseja excluir {entityName}?",
"delete_entity_desc": "{count, plural, one {Tem certeza de que deseja excluir este(a) {singularEntity}? A menos que o arquivo também esteja excluído, este(a) {singularEntity} será re-adicionado quando a escaneamento for executado.} other {Tem certeza de que deseja excluir estes(as) {pluralEntity}? A menos que os arquivos também estejam excluídos, estes(as) {pluralEntity} serão re-adicionados quando o escaneamento for executado.}}", "delete_entity_desc": "{count, plural, one {Tem certeza de que deseja excluir este(a) {singularEntity}? A menos que o arquivo também esteja excluído, este(a) {singularEntity} será re-adicionado quando a escaneamento for executado.} other {Tem certeza de que deseja excluir estes(as) {pluralEntity}? A menos que os arquivos também estejam excluídos, estes(as) {pluralEntity} serão re-adicionados quando o escaneamento for executado.}}",
@@ -1021,6 +1020,9 @@
"type": "Tipo", "type": "Tipo",
"updated_at": "Atualizado em", "updated_at": "Atualizado em",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "apelidos devem ser únicos"
},
"videos": "Vídeos", "videos": "Vídeos",
"view_all": "Ver todos", "view_all": "Ver todos",
"weight": "Peso", "weight": "Peso",

View File

@@ -661,7 +661,6 @@
"details": "Подробности", "details": "Подробности",
"developmentVersion": "Версия разработки", "developmentVersion": "Версия разработки",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "псевдонимы должны быть уникальными",
"create_new_entity": "Создать новую запись в {entity}", "create_new_entity": "Создать новую запись в {entity}",
"delete_alert": "Следующие {count, plural, one {{singularEntity}} other {{pluralEntity}}} будут удалены безвозвратно:", "delete_alert": "Следующие {count, plural, one {{singularEntity}} other {{pluralEntity}}} будут удалены безвозвратно:",
"delete_confirm": "Вы уверены что хотите удалить {entityName}?", "delete_confirm": "Вы уверены что хотите удалить {entityName}?",
@@ -1119,6 +1118,9 @@
"type": "Тип", "type": "Тип",
"updated_at": "Обновлено", "updated_at": "Обновлено",
"url": "Ссылка", "url": "Ссылка",
"validation": {
"aliases_must_be_unique": "псевдонимы должны быть уникальными"
},
"videos": "Видео", "videos": "Видео",
"view_all": "Показать все", "view_all": "Показать все",
"weight": "Вес", "weight": "Вес",

View File

@@ -666,7 +666,6 @@
"details": "Beskrivningar", "details": "Beskrivningar",
"developmentVersion": "Utvecklingsversion", "developmentVersion": "Utvecklingsversion",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "alias måste vara unik",
"create_new_entity": "Skapa ny {entity}", "create_new_entity": "Skapa ny {entity}",
"delete_alert": "De följande {count, plural, one {{singularEntity}} andra {{pluralEntity}}} kommer raderas permanent:", "delete_alert": "De följande {count, plural, one {{singularEntity}} andra {{pluralEntity}}} kommer raderas permanent:",
"delete_confirm": "Är du säker på att du vill radera {entityName}?", "delete_confirm": "Är du säker på att du vill radera {entityName}?",
@@ -1124,6 +1123,9 @@
"type": "Typ", "type": "Typ",
"updated_at": "Uppdaterad vid", "updated_at": "Uppdaterad vid",
"url": "URL", "url": "URL",
"validation": {
"aliases_must_be_unique": "alias måste vara unik"
},
"videos": "Videor", "videos": "Videor",
"view_all": "Visa Allt", "view_all": "Visa Allt",
"weight": "Vikt", "weight": "Vikt",

View File

@@ -547,7 +547,6 @@
"details": "Ayrıntılar", "details": "Ayrıntılar",
"developmentVersion": "Geliştirme Sürümü", "developmentVersion": "Geliştirme Sürümü",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "takma adlar benzersiz olmalıdır",
"delete_alert": "Bu {count, plural, one {{singularEntity}} other {{pluralEntity}}} kalıcı olarak silinecektir:", "delete_alert": "Bu {count, plural, one {{singularEntity}} other {{pluralEntity}}} kalıcı olarak silinecektir:",
"delete_confirm": "Bunu silmek istediğinizden emin misiniz: {entityName}?", "delete_confirm": "Bunu silmek istediğinizden emin misiniz: {entityName}?",
"delete_entity_desc": "{count, plural, one {Bunu silmek istediğinizden emin misiniz: {entityName}? Dosyayı bilgisayarınızdan silmediğiniz sürece, yeniden tarama işleminde bu {singularEntity} tekrar veritabanına eklenecektir.} other {Bunları silmek istediğinizden emin misiniz: {pluralEntity}? Dosyayı bilgisayarınızdan silmediğiniz sürece, yeniden tarama işleminde bu {pluralEntity} tekrar veritabanına eklenecektir.}}", "delete_entity_desc": "{count, plural, one {Bunu silmek istediğinizden emin misiniz: {entityName}? Dosyayı bilgisayarınızdan silmediğiniz sürece, yeniden tarama işleminde bu {singularEntity} tekrar veritabanına eklenecektir.} other {Bunları silmek istediğinizden emin misiniz: {pluralEntity}? Dosyayı bilgisayarınızdan silmediğiniz sürece, yeniden tarama işleminde bu {pluralEntity} tekrar veritabanına eklenecektir.}}",
@@ -891,6 +890,9 @@
"twitter": "Twitter", "twitter": "Twitter",
"updated_at": "Güncellenme Zamanı", "updated_at": "Güncellenme Zamanı",
"url": "Internet Adresi (URL)", "url": "Internet Adresi (URL)",
"validation": {
"aliases_must_be_unique": "takma adlar benzersiz olmalıdır"
},
"videos": "Videolar", "videos": "Videolar",
"weight": "Kilo", "weight": "Kilo",
"years_old": "yaşında" "years_old": "yaşında"

View File

@@ -666,7 +666,6 @@
"details": "简介", "details": "简介",
"developmentVersion": "开发版本", "developmentVersion": "开发版本",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "别名必须是唯一的",
"create_new_entity": "创建新的 {entity}", "create_new_entity": "创建新的 {entity}",
"delete_alert": "以下 {count, plural, one {{singularEntity}} other {{pluralEntity}}} 会被永久删除:", "delete_alert": "以下 {count, plural, one {{singularEntity}} other {{pluralEntity}}} 会被永久删除:",
"delete_confirm": "确定要删除 {entityName} 吗?", "delete_confirm": "确定要删除 {entityName} 吗?",
@@ -1124,6 +1123,9 @@
"type": "类别", "type": "类别",
"updated_at": "更新时间", "updated_at": "更新时间",
"url": "链接", "url": "链接",
"validation": {
"aliases_must_be_unique": "别名必须是唯一的"
},
"videos": "视频", "videos": "视频",
"view_all": "查看全部", "view_all": "查看全部",
"weight": "体重", "weight": "体重",

View File

@@ -666,7 +666,6 @@
"details": "細節", "details": "細節",
"developmentVersion": "開發版本", "developmentVersion": "開發版本",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "別名不可重複",
"create_new_entity": "建立新{entity}", "create_new_entity": "建立新{entity}",
"delete_alert": "以下{count, plural, one {{singularEntity}} other {{pluralEntity}}}將被永久刪除:", "delete_alert": "以下{count, plural, one {{singularEntity}} other {{pluralEntity}}}將被永久刪除:",
"delete_confirm": "你確定要刪除 {entityName} 嗎?", "delete_confirm": "你確定要刪除 {entityName} 嗎?",
@@ -1124,6 +1123,9 @@
"type": "種類", "type": "種類",
"updated_at": "更新於", "updated_at": "更新於",
"url": "連結", "url": "連結",
"validation": {
"aliases_must_be_unique": "別名不可重複"
},
"videos": "影片", "videos": "影片",
"view_all": "顯示全部", "view_all": "顯示全部",
"weight": "體重", "weight": "體重",

View File

@@ -9,7 +9,7 @@ export const stringGenderMap = new Map<string, GQL.GenderEnum>([
["Non-Binary", GQL.GenderEnum.NonBinary], ["Non-Binary", GQL.GenderEnum.NonBinary],
]); ]);
export const genderToString = (value?: GQL.GenderEnum | string) => { export const genderToString = (value?: GQL.GenderEnum | string | null) => {
if (!value) { if (!value) {
return undefined; return undefined;
} }