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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 632 additions and 651 deletions

View file

@ -21,6 +21,7 @@ import {
useSystemStatus,
} from "src/core/StashService";
import flattenMessages from "./utils/flattenMessages";
import * as yup from "yup";
import Mousetrap from "mousetrap";
import MousetrapPause from "mousetrap-pause";
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();

View file

@ -38,6 +38,7 @@ import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
@ -79,44 +80,50 @@ export const GalleryEditPanel: React.FC<IProps> = ({
isNew || (gallery?.files?.length === 0 && !gallery?.folder);
const schema = yup.object({
title: titleRequired
? yup.string().required()
: yup.string().optional().nullable(),
details: yup.string().optional().nullable(),
url: yup.string().optional().nullable(),
date: yup.string().optional().nullable(),
rating100: yup.number().optional().nullable(),
studio_id: yup.string().optional().nullable(),
performer_ids: yup.array(yup.string().required()).optional().nullable(),
tag_ids: yup.array(yup.string().required()).optional().nullable(),
scene_ids: yup.array(yup.string().required()).optional().nullable(),
title: titleRequired ? yup.string().required() : yup.string().ensure(),
url: yup.string().ensure(),
date: yup
.string()
.ensure()
.test({
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(),
scene_ids: yup.array(yup.string().required()).defined(),
details: yup.string().ensure(),
});
const initialValues = {
title: gallery?.title ?? "",
details: gallery?.details ?? "",
url: gallery?.url ?? "",
date: gallery?.date ?? "",
rating100: gallery?.rating100 ?? null,
studio_id: gallery?.studio?.id,
studio_id: gallery?.studio?.id ?? null,
performer_ids: (gallery?.performers ?? []).map((p) => p.id),
tag_ids: (gallery?.tags ?? []).map((t) => t.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,
enableReinitialize: true,
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) {
formik.setFieldValue("rating100", v);
}
@ -166,24 +173,13 @@ export const GalleryEditPanel: React.FC<IProps> = ({
setQueryableScrapers(newQueryableScrapers);
}, [Scrapers]);
function getGalleryInput(
input: InputValues
): GQL.GalleryCreateInput | GQL.GalleryUpdateInput {
return {
id: isNew ? undefined : gallery?.id ?? "",
...input,
};
}
async function onSave(
input: GQL.GalleryCreateInput | GQL.GalleryUpdateInput
) {
async function onSave(input: GQL.GalleryCreateInput) {
setIsLoading(true);
try {
if (isNew) {
const result = await createGallery({
variables: {
input: input as GQL.GalleryCreateInput,
input,
},
});
if (result.data?.galleryCreate) {
@ -202,7 +198,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
} else {
const result = await updateGallery({
variables: {
input: input as GQL.GalleryUpdateInput,
input: {
id: gallery.id!,
...input,
},
},
});
if (result.data?.galleryUpdate) {
@ -216,7 +215,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
}
),
});
formik.resetForm({ values: formik.values });
formik.resetForm();
}
}
} catch (e) {
@ -271,7 +270,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
return;
}
const currentGallery = getGalleryInput(formik.values);
const currentGallery = {
id: gallery.id!,
...formik.values,
};
return (
<GalleryScrapeDialog
@ -384,7 +386,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
function renderTextField(field: string, title: string, placeholder?: string) {
return (
<Form.Group controlId={title} as={Row}>
<Form.Group controlId={field} as={Row}>
{FormUtils.renderLabel({
title,
})}
@ -419,7 +421,9 @@ export const GalleryEditPanel: React.FC<IProps> = ({
<Button
className="edit-button"
variant="primary"
disabled={!formik.dirty}
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
@ -561,8 +565,8 @@ export const GalleryEditPanel: React.FC<IProps> = ({
<Form.Control
as="textarea"
className="gallery-description text-input"
onChange={(newValue: React.ChangeEvent<HTMLTextAreaElement>) =>
formik.setFieldValue("details", newValue.currentTarget.value)
onChange={(e) =>
formik.setFieldValue("details", e.currentTarget.value)
}
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 { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
interface IProps {
image: GQL.ImageDataFragment;
@ -42,31 +43,44 @@ export const ImageEditPanel: React.FC<IProps> = ({
const [updateImage] = useImageUpdate();
const schema = yup.object({
title: yup.string().optional().nullable(),
rating100: yup.number().optional().nullable(),
url: yup.string().optional().nullable(),
date: yup.string().optional().nullable(),
studio_id: yup.string().optional().nullable(),
performer_ids: yup.array(yup.string().required()).optional().nullable(),
tag_ids: yup.array(yup.string().required()).optional().nullable(),
title: yup.string().ensure(),
url: yup.string().ensure(),
date: yup
.string()
.ensure()
.test({
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 = {
title: image.title ?? "",
rating100: image.rating100 ?? null,
url: image?.url ?? "",
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),
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,
enableReinitialize: true,
validationSchema: schema,
onSubmit: (values) => onSave(getImageInput(values)),
onSubmit: (values) => onSave(values),
});
function setRating(v: number) {
@ -95,19 +109,15 @@ export const ImageEditPanel: React.FC<IProps> = ({
}
});
function getImageInput(input: InputValues): GQL.ImageUpdateInput {
return {
id: image.id,
...input,
};
}
async function onSave(input: GQL.ImageUpdateInput) {
async function onSave(input: InputValues) {
setIsLoading(true);
try {
const result = await updateImage({
variables: {
input,
input: {
id: image.id,
...input,
},
},
});
if (result.data?.imageUpdate) {
@ -117,7 +127,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
{ entity: intl.formatMessage({ id: "image" }).toLocaleLowerCase() }
),
});
formik.resetForm({ values: formik.values });
formik.resetForm();
}
} catch (e) {
Toast.error(e);
@ -127,7 +137,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
function renderTextField(field: string, title: string, placeholder?: string) {
return (
<Form.Group controlId={title} as={Row}>
<Form.Group controlId={field} as={Row}>
{FormUtils.renderLabel({
title,
})}
@ -161,7 +171,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
<Button
className="edit-button"
variant="primary"
disabled={!formik.dirty}
disabled={!formik.dirty || !isEqual(formik.errors, {})}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@
* Overhauled and improved HLS streaming. ([#3274](https://github.com/stashapp/stash/pull/3274))
### 🐛 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 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))

View file

@ -646,7 +646,6 @@
"details": "Detaily",
"developmentVersion": "Vývojářská verze",
"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_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í.}}",
@ -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_screenshots": "Screenshoty markerů"
}
},
"validation": {
"aliases_must_be_unique": "aliasy musí být unikátní"
}
}

View file

@ -614,7 +614,6 @@
"details": "Detaljer",
"developmentVersion": "Udviklingsversion",
"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_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.}}",
@ -1039,6 +1038,9 @@
"type": "Type",
"updated_at": "Opdateret Den",
"url": "URL",
"validation": {
"aliases_must_be_unique": "aliaser skal være unikke"
},
"videos": "Videoer",
"view_all": "Se alle",
"weight": "Vægt",

View file

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

View file

@ -699,7 +699,6 @@
"details": "Details",
"developmentVersion": "Development Version",
"dialogs": {
"aliases_must_be_unique": "aliases must be unique",
"create_new_entity": "Create new {entity}",
"delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:",
"delete_confirm": "Are you sure you want to delete {entityName}?",
@ -1164,6 +1163,11 @@
"type": "Type",
"updated_at": "Updated At",
"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",
"view_all": "View All",
"play_count": "Play Count",

View file

@ -607,7 +607,6 @@
"details": "Detalles",
"developmentVersion": "Versión de desarrollo",
"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_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.}}",
@ -1017,6 +1016,9 @@
"twitter": "Twitter",
"updated_at": "Fecha de modificación",
"url": "URL",
"validation": {
"aliases_must_be_unique": "Los alias deben ser únicos"
},
"videos": "Vídeos",
"weight": "Peso",
"years_old": "años"

View file

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

View file

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

View file

@ -666,7 +666,6 @@
"details": "Détails",
"developmentVersion": "Version de développement",
"dialogs": {
"aliases_must_be_unique": "Les alias doivent être uniques",
"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_confirm": "Êtes-vous sûr de vouloir supprimer {entityName} ?",
@ -1124,6 +1123,9 @@
"type": "Type",
"updated_at": "Actualisé le",
"url": "URL",
"validation": {
"aliases_must_be_unique": "Les alias doivent être uniques"
},
"videos": "Vidéos",
"view_all": "Tout voir",
"weight": "Poids",

View file

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

View file

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

View file

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

View file

@ -590,7 +590,6 @@
"details": "Details",
"developmentVersion": "Ontwikkelingsversie",
"dialogs": {
"aliases_must_be_unique": "aliases moeten uniek zijn",
"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_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",
"updated_at": "Bijgewerkt op",
"url": "URL",
"validation": {
"aliases_must_be_unique": "aliases moeten uniek zijn"
},
"videos": "Video's",
"view_all": "Alles weergeven",
"weight": "Gewicht",

View file

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

View file

@ -592,7 +592,6 @@
"details": "Detalhes",
"developmentVersion": "Versão de desenvolvimento",
"dialogs": {
"aliases_must_be_unique": "apelidos devem ser únicos",
"delete_alert": "Os seguintes {count, plural, one {{singularEntity}} other {{pluralEntity}}} serão deletados permanentemente:",
"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.}}",
@ -1021,6 +1020,9 @@
"type": "Tipo",
"updated_at": "Atualizado em",
"url": "URL",
"validation": {
"aliases_must_be_unique": "apelidos devem ser únicos"
},
"videos": "Vídeos",
"view_all": "Ver todos",
"weight": "Peso",

View file

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

View file

@ -666,7 +666,6 @@
"details": "Beskrivningar",
"developmentVersion": "Utvecklingsversion",
"dialogs": {
"aliases_must_be_unique": "alias måste vara unik",
"create_new_entity": "Skapa ny {entity}",
"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}?",
@ -1124,6 +1123,9 @@
"type": "Typ",
"updated_at": "Uppdaterad vid",
"url": "URL",
"validation": {
"aliases_must_be_unique": "alias måste vara unik"
},
"videos": "Videor",
"view_all": "Visa Allt",
"weight": "Vikt",

View file

@ -547,7 +547,6 @@
"details": "Ayrıntılar",
"developmentVersion": "Geliştirme Sürümü",
"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_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.}}",
@ -891,6 +890,9 @@
"twitter": "Twitter",
"updated_at": "Güncellenme Zamanı",
"url": "Internet Adresi (URL)",
"validation": {
"aliases_must_be_unique": "takma adlar benzersiz olmalıdır"
},
"videos": "Videolar",
"weight": "Kilo",
"years_old": "yaşında"

View file

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

View file

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

View file

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