refactor: CropModal

feat: add toasts for collection and profile image changes
This commit is contained in:
Kentai Radiquum 2025-03-21 15:46:09 +05:00
parent fa1a5cbfe6
commit d8ebabb04e
Signed by: Radiquum
GPG key ID: 858E8EE696525EED
3 changed files with 258 additions and 123 deletions

View file

@ -3,56 +3,86 @@ import Cropper, { ReactCropperElement } from "react-cropper";
import "cropperjs/dist/cropper.css"; import "cropperjs/dist/cropper.css";
import { Button, Modal } from "flowbite-react"; import { Button, Modal } from "flowbite-react";
type Props = { type CropModalProps = {
src: string;
setSrc: (src: string) => void;
setTempSrc: (src: string) => void;
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; isActionsDisabled: boolean;
height: number; selectedImage: any | null;
width: number; croppedImage: any | null;
aspectRatio: number; setCropModalProps: (props: {
guides: boolean; isOpen: boolean;
quality: number; isActionsDisabled: boolean;
forceAspect?: boolean; selectedImage: any | null;
croppedImage: any | null;
}) => void;
cropParams: {
guides?: boolean;
width?: number;
height?: number;
quality?: number;
aspectRatio?: number;
forceAspect?: boolean;
};
}; };
export const CropModal: React.FC<Props> = (props) => { export const CropModal: React.FC<CropModalProps> = ({
isOpen,
setCropModalProps,
cropParams,
selectedImage,
croppedImage,
isActionsDisabled,
}) => {
const cropperRef = useRef<ReactCropperElement>(null); const cropperRef = useRef<ReactCropperElement>(null);
const getCropData = () => { const getCropData = () => {
if (typeof cropperRef.current?.cropper !== "undefined") { if (typeof cropperRef.current?.cropper !== "undefined") {
props.setSrc( const croppedImage = cropperRef.current?.cropper
cropperRef.current?.cropper .getCroppedCanvas({
.getCroppedCanvas({ width: cropParams.width,
width: props.width, height: cropParams.height,
height: props.height, maxWidth: cropParams.width,
maxWidth: props.width, maxHeight: cropParams.height,
maxHeight: props.height, })
}) .toDataURL(
.toDataURL("image/jpeg", props.quality) "image/jpeg",
); cropParams.quality || false ? cropParams.quality : 100
props.setTempSrc(""); );
setCropModalProps({
isOpen: true,
isActionsDisabled: false,
selectedImage: selectedImage,
croppedImage: croppedImage,
});
} }
}; };
return ( return (
<Modal <Modal
dismissible dismissible
show={props.isOpen} show={isOpen}
onClose={() => props.setIsOpen(false)} onClose={() => {
setCropModalProps({
isOpen: false,
isActionsDisabled: false,
selectedImage: null,
croppedImage: null,
});
}}
size={"7xl"} size={"7xl"}
> >
<Modal.Header>Обрезать изображение</Modal.Header> <Modal.Header>Обрезать изображение</Modal.Header>
<Modal.Body> <Modal.Body>
<Cropper <Cropper
src={props.src} src={selectedImage}
style={{ height: 400, width: "100%" }} style={{ height: 400, width: "100%" }}
responsive={true} responsive={true}
// Cropper.js options // Cropper.js options
initialAspectRatio={props.aspectRatio} initialAspectRatio={cropParams.aspectRatio || 1 / 1}
aspectRatio={props.forceAspect ? props.aspectRatio : undefined} aspectRatio={
guides={props.guides} cropParams.forceAspect || false ? cropParams.aspectRatio : undefined
}
guides={cropParams.guides || false}
ref={cropperRef} ref={cropperRef}
/> />
@ -69,23 +99,26 @@ export const CropModal: React.FC<Props> = (props) => {
<Modal.Footer> <Modal.Footer>
<Button <Button
color={"blue"} color={"blue"}
disabled={isActionsDisabled}
onClick={() => { onClick={() => {
getCropData(); getCropData();
props.setIsOpen(false);
}} }}
> >
Сохранить Сохранить
</Button> </Button>
<Button <Button
color={"red"} color={"red"}
disabled={isActionsDisabled}
onClick={() => { onClick={() => {
props.setSrc(null); setCropModalProps({
props.setTempSrc(null); isOpen: false,
// props.setImageData(null); isActionsDisabled: false,
props.setIsOpen(false); selectedImage: null,
croppedImage: null,
});
}} }}
> >
Удалить Отменить
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>

View file

@ -1,11 +1,11 @@
"use client"; "use client";
import { FileInput, Label, Modal } from "flowbite-react"; import { FileInput, Label, Modal, useThemeMode } from "flowbite-react";
import { Spinner } from "../Spinner/Spinner"; import { Spinner } from "../Spinner/Spinner";
import useSWR from "swr"; import useSWR from "swr";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { b64toBlob, unixToDate, useSWRfetcher } from "#/api/utils"; import { b64toBlob, tryCatchAPI, unixToDate, useSWRfetcher } from "#/api/utils";
import { ProfileEditPrivacyModal } from "./Profile.EditPrivacyModal"; import { ProfileEditPrivacyModal } from "./Profile.EditPrivacyModal";
import { ProfileEditStatusModal } from "./Profile.EditStatusModal"; import { ProfileEditStatusModal } from "./Profile.EditStatusModal";
import { ProfileEditSocialModal } from "./Profile.EditSocialModal"; import { ProfileEditSocialModal } from "./Profile.EditSocialModal";
@ -13,6 +13,7 @@ import { CropModal } from "../CropModal/CropModal";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import { useUserStore } from "#/store/auth"; import { useUserStore } from "#/store/auth";
import { ProfileEditLoginModal } from "./Profile.EditLoginModal"; import { ProfileEditLoginModal } from "./Profile.EditLoginModal";
import { toast } from "react-toastify";
export const ProfileEditModal = (props: { export const ProfileEditModal = (props: {
isOpen: boolean; isOpen: boolean;
@ -23,10 +24,7 @@ export const ProfileEditModal = (props: {
const [privacyModalOpen, setPrivacyModalOpen] = useState(false); const [privacyModalOpen, setPrivacyModalOpen] = useState(false);
const [statusModalOpen, setStatusModalOpen] = useState(false); const [statusModalOpen, setStatusModalOpen] = useState(false);
const [socialModalOpen, setSocialModalOpen] = useState(false); const [socialModalOpen, setSocialModalOpen] = useState(false);
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const [loginModalOpen, setLoginModalOpen] = useState(false); const [loginModalOpen, setLoginModalOpen] = useState(false);
const [avatarUri, setAvatarUri] = useState(null);
const [tempAvatarUri, setTempAvatarUri] = useState(null);
const [privacyModalSetting, setPrivacyModalSetting] = useState("none"); const [privacyModalSetting, setPrivacyModalSetting] = useState("none");
const [privacySettings, setPrivacySettings] = useState({ const [privacySettings, setPrivacySettings] = useState({
privacy_stats: 9, privacy_stats: 9,
@ -42,6 +40,14 @@ export const ProfileEditModal = (props: {
const [login, setLogin] = useState(""); const [login, setLogin] = useState("");
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const userStore = useUserStore(); const userStore = useUserStore();
const theme = useThemeMode();
const [avatarModalProps, setAvatarModalProps] = useState({
isOpen: false,
isActionsDisabled: false,
selectedImage: null,
croppedImage: null,
});
const privacy_stat_act_social_text = { const privacy_stat_act_social_text = {
0: "Все пользователи", 0: "Все пользователи",
@ -67,15 +73,17 @@ export const ProfileEditModal = (props: {
`${ENDPOINTS.user.settings.login.info}?token=${props.token}` `${ENDPOINTS.user.settings.login.info}?token=${props.token}`
); );
const handleFileRead = (e, fileReader) => { const handleAvatarPreview = (e: any) => {
const content = fileReader.result; const file = e.target.files[0];
setTempAvatarUri(content);
};
const handleFilePreview = (file) => {
const fileReader = new FileReader(); const fileReader = new FileReader();
fileReader.onloadend = (e) => { fileReader.onloadend = () => {
handleFileRead(e, fileReader); const content = fileReader.result;
setAvatarModalProps({
...avatarModalProps,
isOpen: true,
selectedImage: content,
});
e.target.value = "";
}; };
fileReader.readAsDataURL(file); fileReader.readAsDataURL(file);
}; };
@ -103,8 +111,8 @@ export const ProfileEditModal = (props: {
}, [loginData]); }, [loginData]);
useEffect(() => { useEffect(() => {
if (avatarUri) { async function _uploadAvatar() {
let block = avatarUri.split(";"); let block = avatarModalProps.croppedImage.split(";");
let contentType = block[0].split(":")[1]; let contentType = block[0].split(":")[1];
let realData = block[1].split(",")[1]; let realData = block[1].split(",")[1];
const blob = b64toBlob(realData, contentType); const blob = b64toBlob(realData, contentType);
@ -112,23 +120,68 @@ export const ProfileEditModal = (props: {
const formData = new FormData(); const formData = new FormData();
formData.append("image", blob, "cropped.jpg"); formData.append("image", blob, "cropped.jpg");
formData.append("name", "image"); formData.append("name", "image");
const uploadRes = fetch(
`${ENDPOINTS.user.settings.avatar}?token=${props.token}`, setAvatarModalProps(
{ (state) => (state = { ...state, isActionsDisabled: true })
);
const tid = toast.loading("Обновление аватара...", {
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
});
const { data, error } = await tryCatchAPI(
fetch(`${ENDPOINTS.user.settings.avatar}?token=${props.token}`, {
method: "POST", method: "POST",
body: formData, body: formData,
} })
).then((res) => { );
if (res.ok) {
mutate( if (error) {
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}` toast.update(tid, {
); render: "Ошибка обновления аватара",
userStore.checkAuth(); type: "error",
} autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setAvatarModalProps(
(state) => (state = { ...state, isActionsDisabled: false })
);
return;
}
toast.update(tid, {
render: "Аватар обновлён",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
}); });
setAvatarModalProps(
(state) =>
(state = {
isOpen: false,
isActionsDisabled: false,
selectedImage: null,
croppedImage: null,
})
);
mutate(
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}`
);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [avatarUri]); if (avatarModalProps.croppedImage) {
_uploadAvatar();
}
}, [avatarModalProps.croppedImage]);
return ( return (
<> <>
@ -139,10 +192,9 @@ export const ProfileEditModal = (props: {
> >
<Modal.Header>Редактирование профиля</Modal.Header> <Modal.Header>Редактирование профиля</Modal.Header>
<Modal.Body> <Modal.Body>
{prefLoading ? ( {prefLoading ?
<Spinner /> <Spinner />
) : ( : <div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 pb-4 border-b-2 border-gray-300 border-solid"> <div className="flex flex-col gap-2 pb-4 border-b-2 border-gray-300 border-solid">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -160,19 +212,18 @@ export const ProfileEditModal = (props: {
className="hidden" className="hidden"
accept="image/jpg, image/jpeg, image/png" accept="image/jpg, image/jpeg, image/png"
onChange={(e) => { onChange={(e) => {
handleFilePreview(e.target.files[0]); handleAvatarPreview(e);
setAvatarModalOpen(true);
}} }}
/> />
<div> <div>
<p className="text-lg">Изменить фото профиля</p> <p className="text-lg">Изменить фото профиля</p>
<p className="text-base text-gray-500 dark:text-gray-400"> <p className="text-base text-gray-500 dark:text-gray-400">
{prefData.is_change_avatar_banned {prefData.is_change_avatar_banned ?
? `Заблокировано до ${unixToDate( `Заблокировано до ${unixToDate(
prefData.ban_change_avatar_expires, prefData.ban_change_avatar_expires,
"full" "full"
)}` )}`
: "Загрузить с устройства"} : "Загрузить с устройства"}
</p> </p>
</div> </div>
</Label> </Label>
@ -197,12 +248,12 @@ export const ProfileEditModal = (props: {
> >
<p className="text-lg">Изменить никнейм</p> <p className="text-lg">Изменить никнейм</p>
<p className="text-base text-gray-500 dark:text-gray-400"> <p className="text-base text-gray-500 dark:text-gray-400">
{prefData.is_change_login_banned {prefData.is_change_login_banned ?
? `Заблокировано до ${unixToDate( `Заблокировано до ${unixToDate(
prefData.ban_change_login_expires, prefData.ban_change_login_expires,
"full" "full"
)}` )}`
: login} : login}
</p> </p>
</button> </button>
<button <button
@ -316,9 +367,9 @@ export const ProfileEditModal = (props: {
<div className="p-2 mt-2 cursor-not-allowed"> <div className="p-2 mt-2 cursor-not-allowed">
<p className="text-lg">Связанные аккаунты</p> <p className="text-lg">Связанные аккаунты</p>
<p className="text-base text-gray-500 dark:text-gray-400"> <p className="text-base text-gray-500 dark:text-gray-400">
{socialBounds.vk || socialBounds.google {socialBounds.vk || socialBounds.google ?
? "Аккаунт привязан к:" "Аккаунт привязан к:"
: "не привязан к сервисам"}{" "} : "не привязан к сервисам"}{" "}
{socialBounds.vk && "ВК"} {socialBounds.vk && "ВК"}
{socialBounds.vk && socialBounds.google && ", "} {socialBounds.vk && socialBounds.google && ", "}
{socialBounds.google && "Google"} {socialBounds.google && "Google"}
@ -326,7 +377,7 @@ export const ProfileEditModal = (props: {
</div> </div>
</div> </div>
</div> </div>
)} }
</Modal.Body> </Modal.Body>
</Modal> </Modal>
<ProfileEditPrivacyModal <ProfileEditPrivacyModal
@ -352,17 +403,15 @@ export const ProfileEditModal = (props: {
profile_id={props.profile_id} profile_id={props.profile_id}
/> />
<CropModal <CropModal
src={tempAvatarUri} {...avatarModalProps}
setSrc={setAvatarUri} cropParams={{
setTempSrc={setTempAvatarUri} aspectRatio: 1 / 1,
aspectRatio={1 / 1} forceAspect: true,
guides={true} guides: true,
quality={100} width: 600,
isOpen={avatarModalOpen} height: 600,
setIsOpen={setAvatarModalOpen} }}
forceAspect={true} setCropModalProps={setAvatarModalProps}
width={600}
height={600}
/> />
<ProfileEditLoginModal <ProfileEditLoginModal
isOpen={loginModalOpen} isOpen={loginModalOpen}

View file

@ -37,8 +37,6 @@ export const CreateCollectionPage = () => {
const [edit, setEdit] = useState(false); const [edit, setEdit] = useState(false);
const [imageUrl, setImageUrl] = useState<string>(null);
const [tempImageUrl, setTempImageUrl] = useState<string>(null);
const [isPrivate, setIsPrivate] = useState(false); const [isPrivate, setIsPrivate] = useState(false);
const [collectionInfo, setCollectionInfo] = useState({ const [collectionInfo, setCollectionInfo] = useState({
title: "", title: "",
@ -51,7 +49,17 @@ export const CreateCollectionPage = () => {
const [addedReleases, setAddedReleases] = useState([]); const [addedReleases, setAddedReleases] = useState([]);
const [addedReleasesIds, setAddedReleasesIds] = useState([]); const [addedReleasesIds, setAddedReleasesIds] = useState([]);
const [releasesEditModalOpen, setReleasesEditModalOpen] = useState(false); const [releasesEditModalOpen, setReleasesEditModalOpen] = useState(false);
const [cropModalOpen, setCropModalOpen] = useState(false);
// const [tempImageUrl, setTempImageUrl] = useState<string>(null);
// const [cropModalOpen, setCropModalOpen] = useState(false);
const [imageModalProps, setImageModalProps] = useState({
isOpen: false,
isActionsDisabled: false,
selectedImage: null,
croppedImage: null,
});
const [imageUrl, setImageUrl] = useState<string>(null);
const collection_id = searchParams.get("id") || null; const collection_id = searchParams.get("id") || null;
const mode = searchParams.get("mode") || null; const mode = searchParams.get("mode") || null;
@ -118,15 +126,29 @@ export const CreateCollectionPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userStore.user]); }, [userStore.user]);
const handleFileRead = (e, fileReader) => { useEffect(() => {
const content = fileReader.result; if (imageModalProps.croppedImage) {
setTempImageUrl(content); setImageUrl(imageModalProps.croppedImage);
}; setImageModalProps({
isOpen: false,
isActionsDisabled: false,
selectedImage: null,
croppedImage: null,
});
}
}, [imageModalProps.croppedImage]);
const handleFilePreview = (file) => { const handleImagePreview = (e: any) => {
const file = e.target.files[0];
const fileReader = new FileReader(); const fileReader = new FileReader();
fileReader.onloadend = (e) => { fileReader.onloadend = () => {
handleFileRead(e, fileReader); const content = fileReader.result;
setImageModalProps({
...imageModalProps,
isOpen: true,
selectedImage: content,
});
e.target.value = "";
}; };
fileReader.readAsDataURL(file); fileReader.readAsDataURL(file);
}; };
@ -203,13 +225,48 @@ export const CreateCollectionPage = () => {
const formData = new FormData(); const formData = new FormData();
formData.append("image", blob, "cropped.jpg"); formData.append("image", blob, "cropped.jpg");
formData.append("name", "image"); formData.append("name", "image");
await fetch(
`${ENDPOINTS.collection.editImage}/${data.collection.id}?token=${userStore.token}`, const tiid = toast.loading(
`Обновление обложки коллекции ${collectionInfo.title}...`,
{ {
method: "POST", position: "bottom-center",
body: formData, hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
} }
); );
const { data: imageData, error } = await tryCatchAPI(
fetch(
`${ENDPOINTS.collection.editImage}/${data.collection.id}?token=${userStore.token}`,
{
method: "POST",
body: formData,
}
)
);
if (error) {
toast.update(tiid, {
render: "Не удалось обновить постер коллекции",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
} else {
toast.update(tiid, {
render: "Постер коллекции обновлён",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
}
} }
toast.update(tid, { toast.update(tid, {
@ -328,8 +385,7 @@ export const CreateCollectionPage = () => {
className="hidden" className="hidden"
accept="image/jpg, image/jpeg, image/png" accept="image/jpg, image/jpeg, image/png"
onChange={(e) => { onChange={(e) => {
handleFilePreview(e.target.files[0]); handleImagePreview(e);
setCropModalOpen(true);
}} }}
/> />
</Label> </Label>
@ -439,18 +495,15 @@ export const CreateCollectionPage = () => {
setReleasesIds={setAddedReleasesIds} setReleasesIds={setAddedReleasesIds}
/> />
<CropModal <CropModal
src={tempImageUrl} {...imageModalProps}
setSrc={setImageUrl} cropParams={{
setTempSrc={setTempImageUrl} aspectRatio: 600 / 337,
// setImageData={setImageData} forceAspect: true,
aspectRatio={600 / 337} guides: true,
guides={false} width: 600,
quality={100} height: 337,
isOpen={cropModalOpen} }}
setIsOpen={setCropModalOpen} setCropModalProps={setImageModalProps}
forceAspect={true}
width={600}
height={337}
/> />
</> </>
); );