diff --git a/app/admin/(components)/Sidebar.tsx b/app/admin/(components)/Sidebar.tsx index 2f8e026..4ace8cd 100644 --- a/app/admin/(components)/Sidebar.tsx +++ b/app/admin/(components)/Sidebar.tsx @@ -1,7 +1,8 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import Image from "next/image"; import classNames from "classnames"; import { useRouter, useSearchParams } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; import HintDialog from "@/components/common/Dialog-new/Hint-Dialog-new/Dialog"; import { @@ -11,6 +12,7 @@ import { subscribeLinkURL, } from "@/admin/(consts)/sidebar"; import { + getLoginInfo, getSelectedThemeId, getStatus, removeThemeId, @@ -18,6 +20,7 @@ import { import { useSelectedThemeReset } from "@/components/atoms/selectedTheme.atom"; import { useDrawerState } from "@/components/atoms/drawer.atom"; import useModal from "@/hooks/useModal"; +import { QUERY_KEY } from "@/queries/getThemeList"; interface Theme { id: number; @@ -27,8 +30,6 @@ interface Theme { } interface Props { - adminCode: string; - shopName: string; categories: Theme[]; selectedTheme: Theme; handleClickSelected: (theme: Theme) => void; @@ -37,6 +38,7 @@ interface Props { export default function Sidebar(props: Props) { const router = useRouter(); const resetSelectedTheme = useSelectedThemeReset(); + const queryClient = useQueryClient(); const [drawer, setDrawer] = useDrawerState(); const { open } = useModal(); @@ -45,12 +47,16 @@ export default function Sidebar(props: Props) { const searchParams = useSearchParams(); const selectedThemeId = getSelectedThemeId(); const params = new URLSearchParams(searchParams.toString()).toString(); - const { - adminCode = "", - shopName = "", - categories, - handleClickSelected, - } = props; + const { categories, handleClickSelected } = props; + const [loginInfo, setLoginInfo] = useState({ + adminCode: "", + shopName: "", + }); + + useEffect(() => { + const { adminCode, shopName } = getLoginInfo(); // getLoginInfo로 값 가져오기 + setLoginInfo({ adminCode, shopName }); // 상태 업데이트 + }, []); // const handleLogout = () => { // removeAccessToken(); @@ -62,14 +68,14 @@ export default function Sidebar(props: Props) { `/admin?themeId=${encodeURIComponent(selectedThemeId)} ` ); - }, [selectedThemeId]); + }, [selectedThemeId, params]); const navigateToNewTheme = () => { resetSelectedTheme(); router.push("/admin"); setDrawer({ ...drawer, isOpen: false }); }; - const handleSelectTheme = (theme: Theme) => { + const handleSelectTheme = async (theme: Theme) => { if (drawer.isOpen && !drawer.isSameHint) { open(HintDialog, { type: "put", @@ -80,6 +86,7 @@ export default function Sidebar(props: Props) { }); } else { setDrawer({ ...drawer, isOpen: false }); + await queryClient.invalidateQueries(QUERY_KEY); handleClickSelected(theme); } }; @@ -100,7 +107,7 @@ export default function Sidebar(props: Props) {
- {shopName?.replaceAll(`"`, "")} + {loginInfo.shopName?.replaceAll(`"`, "")}
우리 지점 테마
@@ -164,7 +171,7 @@ export default function Sidebar(props: Props) {

관리자 코드

- {adminCode?.replaceAll(`"`, "")} + {loginInfo.adminCode?.replaceAll(`"`, "")}

diff --git a/app/admin/(components)/ThemeDrawer/helpers/imageHelpers.ts b/app/admin/(components)/ThemeDrawer/helpers/imageHelpers.ts index 537d534..fa26d25 100644 --- a/app/admin/(components)/ThemeDrawer/helpers/imageHelpers.ts +++ b/app/admin/(components)/ThemeDrawer/helpers/imageHelpers.ts @@ -1,11 +1,29 @@ import imageCompression from "browser-image-compression"; -export const compressImage = async (file: File) => { - const options = { - maxSizeMB: 5, - maxWidthOrHeight: 1920, - useWebWorker: true, - }; +export interface FileOptionsType { + maxSizeMB: number; + maxWidthOrHeight: number; + useWebWorker: boolean; +} +export const getCompressImage = async ( + file: File, + options: FileOptionsType +) => { + const compressedFile = await compressImage(file, options); + try { + if (compressedFile.type !== "image/png") { + const pngFile = await convertToPng(compressedFile); + return pngFile; + } else { + return compressedFile; + } + } catch (error) { + console.error("Image compression failed", error); + return file; + } +}; + +const compressImage = async (file: File, options: FileOptionsType) => { try { const compressedFile = await imageCompression(file, options); return compressedFile; // compressedFile 반환 @@ -14,7 +32,7 @@ export const compressImage = async (file: File) => { } }; -export const convertToPng = async (file: File): Promise => +const convertToPng = async (file: File): Promise => new Promise((resolve, reject) => { const img = new Image(); const reader = new FileReader(); diff --git a/app/admin/(components)/ThemeDrawer/hooks/useImages.ts b/app/admin/(components)/ThemeDrawer/hooks/useImages.ts index 20446a9..82127eb 100644 --- a/app/admin/(components)/ThemeDrawer/hooks/useImages.ts +++ b/app/admin/(components)/ThemeDrawer/hooks/useImages.ts @@ -14,7 +14,7 @@ import { useToastWrite } from "@/components/atoms/toast.atom"; import { getStatus } from "@/utils/storageUtil"; import { subscribeLinkURL } from "@/admin/(consts)/sidebar"; -import { compressImage, convertToPng } from "../helpers/imageHelpers"; +import { getCompressImage } from "../helpers/imageHelpers"; const useImages = ({ imageType, @@ -79,19 +79,12 @@ const useImages = ({ const files: File[] = []; const file = e.target.files[0]; if (file.size > 5 * 1024 * 1024) { - try { - const compressedFile = await compressImage(file); - - if (compressedFile.type !== "image/png") { - const pngFile = await convertToPng(compressedFile); - files.push(pngFile); - } else { - files.push(compressedFile); - } - } catch (error) { - console.error("Image compression failed", error); - files.push(file); - } + const options = { + maxSizeMB: 5, + maxWidthOrHeight: 1920, + useWebWorker: true, + }; + files.push(await getCompressImage(file, options)); } else { files.push(file); } diff --git a/app/admin/(components)/ThemeInfo/Container.tsx b/app/admin/(components)/ThemeInfo/Container.tsx index eb31974..e50767c 100644 --- a/app/admin/(components)/ThemeInfo/Container.tsx +++ b/app/admin/(components)/ThemeInfo/Container.tsx @@ -11,7 +11,7 @@ import ThemeDrawer from "../ThemeDrawer/Container"; import ThemeInfoTitle from "./ThemeInfoTitle"; import ThemeInfoHint from "./ThemeInfoHint"; -import ThemeImage from "./ThemeImage"; +import ThemeImage from "./ThemeTimerImage"; export default function ThemeInfo() { const { open } = useModal(); diff --git a/app/admin/(components)/ThemeInfo/ThemeImage.tsx b/app/admin/(components)/ThemeInfo/ThemeImage.tsx deleted file mode 100644 index ff5f4e3..0000000 --- a/app/admin/(components)/ThemeInfo/ThemeImage.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import Image from "next/image"; -import React, { useRef, useState } from "react"; - -import Tooltip from "@/admin/(components)/Tooltip/Container"; -import Dialog from "@/components/common/Dialog-new/Image-Dialog-new/Dialog"; -import PreviewDialog from "@/components/common/Dialog-new/Preview-Dialog-new/PreviewDialog"; -import useModal from "@/hooks/useModal"; - -export default function ThemeImage() { - const QuestionProps = { - src: "/images/svg/icon_question.svg", - alt: "gallery_image", - width: 24, - height: 24, - }; - - const previewProps = { - src: "/images/svg/icon_preview.svg", - alt: "NEXT ROOM", - width: 120, - height: 120, - }; - const { open } = useModal(); - - const imgInputRef = useRef(null); - const handleAddImageBtnClick = () => { - // 숨겨진 input 클릭 트리거 - // imgInputRef.current?.click(); - open(Dialog, { type: "put" }); - }; - const handlePreviewImageBtnClick = () => { - // 숨겨진 input 클릭 트리거 - // imgInputRef.current?.click(); - open(PreviewDialog, { type: "put" }); - }; - const [isHovered, setIsHovered] = useState(false); - - return ( -
-
- 타이머 배경 -
- setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className="tooptip-button" - {...QuestionProps} - /> - {isHovered && } -
-
-
-
- -
- -
-
- - - -
-
- ); -} diff --git a/app/admin/(components)/ThemeInfo/ThemeTimerImage.tsx b/app/admin/(components)/ThemeInfo/ThemeTimerImage.tsx new file mode 100644 index 0000000..f8fdb93 --- /dev/null +++ b/app/admin/(components)/ThemeInfo/ThemeTimerImage.tsx @@ -0,0 +1,131 @@ +import Image from "next/image"; +import React, { ChangeEvent, useEffect, useRef, useState } from "react"; + +import Dialog from "@/components/common/Dialog-new/Image-Dialog-new/Dialog"; +import PreviewDialog from "@/components/common/Dialog-new/Preview-Dialog-new/PreviewDialog"; +import useModal from "@/hooks/useModal"; +import { useTimerImageWrite } from "@/components/atoms/timerImage.atom"; +import { useSelectedTheme } from "@/components/atoms/selectedTheme.atom"; +import { defaultTimerImage, QuestionIconProps } from "@/admin/(consts)/sidebar"; +import DeleteDialog from "@/components/common/Dialog-new/Timer-Image-Delete-Dialog/DeleteDialog"; +import Tooltip from "@/admin/(components)/Tooltip/Container"; + +import { getCompressImage } from "../ThemeDrawer/helpers/imageHelpers"; + +export default function ThemeTimerImage() { + const [selectedTheme, setSelectedTheme] = useSelectedTheme(); + const setTimerImage = useTimerImageWrite(); + + const [timerImageUrl, setTimerImageUrl] = useState(defaultTimerImage); + useEffect(() => { + if (selectedTheme.themeImageUrl) { + setTimerImageUrl(selectedTheme.themeImageUrl); + setSelectedTheme((prev) => ({ + ...prev, + useTimerUrl: true, + themeImageUrl: selectedTheme.themeImageUrl, + })); + return; + } + setTimerImageUrl(defaultTimerImage); + }, [selectedTheme.themeImageUrl]); + + const TimerImageProps = { + src: timerImageUrl || "", + alt: "NEXT ROOM", + width: 120, + height: 120, + }; + + const { open } = useModal(); + + const addImageInputRef = useRef(null); + const fileReset = () => { + if (addImageInputRef.current) { + addImageInputRef.current.value = ""; + } + }; + + const handleFileInputChange = async (e: ChangeEvent) => { + if (!e.target.files) { + return; + } + const file: File = e.target.files[0]; + if (file.size > 500 * 1024) { + const options = { + maxSizeMB: 0.5, + maxWidthOrHeight: 1000, + useWebWorker: true, + }; + const compressedFile = await getCompressImage(file, options); + setTimerImage({ timerImage: compressedFile }); + } else { + setTimerImage({ timerImage: file }); + } + + if (file) { + open(Dialog); + } + fileReset(); + }; + const handleAddTimerImageBtnClick = () => { + addImageInputRef.current?.click(); + }; + const handlePreviewBtnClick = () => { + open(PreviewDialog); + }; + + const handleDelTimerImageBtnClick = () => { + open(DeleteDialog); + }; + const [isHovered, setIsHovered] = useState(false); + + return ( +
+
+ 타이머 배경 + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className="tooptip-button" + /> + {isHovered && } +
+
+
+ + {selectedTheme.useTimerUrl && ( +
+ +
+ )} +
+ + {selectedTheme.useTimerUrl ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/app/admin/(consts)/sidebar.ts b/app/admin/(consts)/sidebar.ts index e92545f..5a85af4 100644 --- a/app/admin/(consts)/sidebar.ts +++ b/app/admin/(consts)/sidebar.ts @@ -43,20 +43,6 @@ export const smallXProps = { height: 16, }; -export const previewProps = { - src: "/images/svg/image.png", - alt: "x icon", - width: 315, - height: 682, -}; - -export const statusBarProps = { - src: "/images/svg/status_bar.svg", - alt: "status_bar", - width: 315, - height: 40, -}; - export const timerPreviewProps = { src: "/images/svg/timer_preview.svg", alt: "timer_preview", @@ -71,6 +57,22 @@ export const settingProps = { height: 24, }; +export const QuestionIconProps = { + src: "/images/svg/icon_question.svg", + alt: "gallery_image", + width: 24, + height: 24, +}; + +export const timerPreviewLineProps = { + src: "/images/svg/timer_preview_entire.svg", + alt: "TIMER_LINE_IMAGE", + width: 158, + height: 340, +}; + +export const defaultTimerImage = "/images/svg/icon_preview.svg"; +export const defaultTimerImagePreview = "/images/svg/timer_preview.svg"; export const timerTooltipProps = { src: "/images/png/tooltip.png", alt: "tooltip", diff --git a/app/admin/(style)/admin.modules.sass b/app/admin/(style)/admin.modules.sass index 394624b..28218a1 100644 --- a/app/admin/(style)/admin.modules.sass +++ b/app/admin/(style)/admin.modules.sass @@ -149,7 +149,6 @@ width: 100vw height: 100vh background-color: $color-black60 - z-index: 100 .modal-1 position: fixed left: 0 diff --git a/app/admin/(style)/themeInfo.modules.sass b/app/admin/(style)/themeInfo.modules.sass index 2840481..f523f19 100644 --- a/app/admin/(style)/themeInfo.modules.sass +++ b/app/admin/(style)/themeInfo.modules.sass @@ -13,7 +13,8 @@ @include title24SB color: $color-white position: relative - + div + cursor: default .drawer-open width: calc(100% - 520px) @@ -22,7 +23,7 @@ position: absolute top: 126px left: -40px - width: 100vw + width: calc(100% + 80px) height: 1px background-color: $color-white5 @@ -40,6 +41,7 @@ position: absolute right: 0 top: 30px + cursor: pointer .theme-infomation-text @include body14M @@ -324,6 +326,7 @@ span @include title16SB margin-right: 2px + cursor: default img vertical-align: bottom diff --git a/app/admin/Admin.tsx b/app/admin/Admin.tsx index b91483e..88dee7e 100644 --- a/app/admin/Admin.tsx +++ b/app/admin/Admin.tsx @@ -21,15 +21,19 @@ type Theme = { function Admin() { const { data: categories = [], isLoading } = useGetThemeList(); - const isLoggedIn = useCheckSignIn(); const [selectedTheme, setSelectedTheme] = useSelectedTheme(); - const { adminCode, shopName } = getLoginInfo(); const [toast, setToast] = useToastInfo(); const router = useRouter(); + useEffect(() => { + if (!isLoading && categories.length > 0 && selectedTheme.id === 0) { + setSelectedTheme(categories[categories.length - 1]); + } + }, [isLoading]); + const handleClickSelected = (theme: Theme) => { setSelectedTheme(theme); setSelectedThemeId(theme.id); @@ -49,18 +53,13 @@ function Admin() { }, [toast, setToast]); const SidebarViewProps = { - adminCode, - shopName, categories, selectedTheme, handleClickSelected, isOpen: toast.isOpen, + isLoading, }; - if (!isLoggedIn || isLoading) { - return ; - } - return ; } diff --git a/app/admin/AdminView.tsx b/app/admin/AdminView.tsx index 1e956b1..73a5394 100644 --- a/app/admin/AdminView.tsx +++ b/app/admin/AdminView.tsx @@ -7,6 +7,7 @@ import Toast from "@/components/common/Toast/Toast"; import NotiDialog from "@/components/common/Dialog-new/Noti-Dialog-new/Dialog"; import useModal from "@/hooks/useModal"; import { getLocalStorage } from "@/utils/storageUtil"; +import Loader from "@/components/Loader/Loader"; interface Theme { id: number; @@ -16,24 +17,25 @@ interface Theme { } interface Props { - adminCode: string; - shopName: string; categories: Theme[]; selectedTheme: Theme; isOpen: boolean; + isLoading: boolean; handleClickSelected: (theme: Theme) => void; } function AdminView(props: Props) { - const { isOpen } = props; - const { open } = useModal(); + const { isOpen, isLoading } = props; + const { open, closeAll } = useModal(); const isHideDialog = getLocalStorage("hideDialog"); useEffect(() => { + closeAll(); if (!isHideDialog) { open(NotiDialog, { type: "put" }); } }, []); + if (isLoading) return ; return (
diff --git a/app/components/atoms/selectedTheme.atom.ts b/app/components/atoms/selectedTheme.atom.ts index 118ed5c..81206a9 100644 --- a/app/components/atoms/selectedTheme.atom.ts +++ b/app/components/atoms/selectedTheme.atom.ts @@ -11,6 +11,8 @@ interface SelectedTheme { title: string; timeLimit: number; hintLimit: number; + themeImageUrl?: string; + useTimerUrl?: boolean; } export const InitialSelectedTheme: SelectedTheme = { diff --git a/app/components/atoms/timerImage.atom.ts b/app/components/atoms/timerImage.atom.ts new file mode 100644 index 0000000..05a1bd5 --- /dev/null +++ b/app/components/atoms/timerImage.atom.ts @@ -0,0 +1,18 @@ +import { + atom, + useRecoilValue, + useRecoilState, + useSetRecoilState, +} from "recoil"; + +interface TimerImageType { + timerImage: File | undefined; +} +const timerImage = atom({ + key: "timerImage", + default: { timerImage: undefined }, +}); + +export const useTimerImage = () => useRecoilState(timerImage); +export const useTimerImageValue = () => useRecoilValue(timerImage); +export const useTimerImageWrite = () => useSetRecoilState(timerImage); diff --git a/app/components/common/Dialog-new/Image-Dialog-new/Dialog.tsx b/app/components/common/Dialog-new/Image-Dialog-new/Dialog.tsx index e17845c..a240916 100644 --- a/app/components/common/Dialog-new/Image-Dialog-new/Dialog.tsx +++ b/app/components/common/Dialog-new/Image-Dialog-new/Dialog.tsx @@ -1,82 +1,45 @@ -import React, { forwardRef, useRef } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; +import React, { FormEvent, forwardRef, useRef } from "react"; import Image from "next/image"; -import { usePutTheme } from "@/mutations/putTheme"; -import { useDeleteTheme } from "@/mutations/deleteTheme"; -import { - useSelectedTheme, - useSelectedThemeReset, -} from "@/components/atoms/selectedTheme.atom"; -import { - useCreateThemeReset, - useCreateThemeValue, -} from "@/components/atoms/createTheme.atom"; +import { useSelectedTheme } from "@/components/atoms/selectedTheme.atom"; import useClickOutside from "@/hooks/useClickOutside"; -import { deleteProps, xProps } from "@/admin/(consts)/sidebar"; +import { xProps } from "@/admin/(consts)/sidebar"; import useModal from "@/hooks/useModal"; import ModalPortal from "@/components/common/Dialog-new/ModalPortal"; - -import DialogBody from "./DialogBody"; - import "@/components/common/Dialog-new/dialog.sass"; +import useTimerImageUpload from "@/mutations/useTimerImageUpload"; +import { useTimerImageValue } from "@/components/atoms/timerImage.atom"; -interface DialogProps { - type?: string | ""; -} - -interface FormValues { - id: number; - title: string; - timeLimit: number; - hintLimit: number; -} +import DialogBody from "./DialogBody"; -const Dialog = forwardRef((props) => { - const { open, close } = useModal(); - const { type = "" } = props; +const Dialog = forwardRef(() => { + const { close } = useModal(); const formRef = useRef(null); - const handleOpenDeleteModal = (event: React.MouseEvent) => { - event.stopPropagation(); - open(Dialog, { type: "delete" }); - }; - - const { handleSubmit } = useForm(); const [selectedTheme, setSelectedTheme] = useSelectedTheme(); - const createTheme = useCreateThemeValue(); - const resetCreateTheme = useCreateThemeReset(); - const resetSelectedTheme = useSelectedThemeReset(); - const isDisabled = - type === "put" - ? (String(createTheme.title) === String(selectedTheme.title) && - Number(createTheme.timeLimit) === Number(selectedTheme.timeLimit) && - Number(createTheme.hintLimit) === Number(selectedTheme.hintLimit)) || - !(createTheme.title && createTheme.timeLimit && createTheme.hintLimit) - : !(createTheme.title && createTheme.timeLimit && createTheme.hintLimit); - - const { mutateAsync: putTheme } = usePutTheme(); - const { mutateAsync: deleteTheme } = useDeleteTheme(); + const { timerImage } = useTimerImageValue(); + const { handleProcess } = useTimerImageUpload(); - const onSubmit: SubmitHandler = () => { + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); const { id } = selectedTheme; const submitData = { - ...createTheme, - id, + themeId: id, + timerImageFile: timerImage, }; - - if (type === "put") { - putTheme(submitData); - setSelectedTheme(submitData); - } else if (type === "delete") { - deleteTheme({ id }); - resetSelectedTheme(); + try { + const imageUrl = await handleProcess(submitData); + setSelectedTheme((prev) => ({ + ...prev, + useTimerUrl: true, + themeImageUrl: imageUrl, + })); + } catch (error) { + console.error(error); } - close(); - resetCreateTheme(); - return close(); + close(); }; useClickOutside(formRef, close); @@ -84,20 +47,22 @@ const Dialog = forwardRef((props) => { return (
e.stopPropagation()} >
-

타이머 배경 수정

+

타이머 배경 올리기

-

힌트폰에 곧바로 적용됩니다

+

+ 힌트폰에 곧바로 적용됩니다 +

- - +
); }); diff --git a/app/components/common/Dialog-new/Timer-Image-Delete-Dialog/DeleteDialog.tsx b/app/components/common/Dialog-new/Timer-Image-Delete-Dialog/DeleteDialog.tsx new file mode 100644 index 0000000..34b604d --- /dev/null +++ b/app/components/common/Dialog-new/Timer-Image-Delete-Dialog/DeleteDialog.tsx @@ -0,0 +1,60 @@ +import React, { forwardRef, useRef } from "react"; +import Image from "next/image"; + +import useClickOutside from "@/hooks/useClickOutside"; +import { xProps } from "@/admin/(consts)/sidebar"; +import useModal from "@/hooks/useModal"; +import "@/components/common/Dialog-new/dialog.sass"; +import { useDeleteTimerImage } from "@/mutations/deleteTimerImage"; +import ModalPortal from "@/components/common/Dialog-new/ModalPortal"; +import { useSelectedTheme } from "@/components/atoms/selectedTheme.atom"; + +import DialogBody from "./DialogBody"; + +const DeleteDialog = forwardRef(() => { + const { close } = useModal(); + const divRef = useRef(null); + const [selectedTheme, setSelectedTheme] = useSelectedTheme(); + + const { mutateAsync: deleteTimerImage } = useDeleteTimerImage(); + + const handleSubmit = async () => { + const { id } = selectedTheme; + await deleteTimerImage(id); + setSelectedTheme((prev) => ({ + ...prev, + useTimerUrl: false, + themeImageUrl: "", + })); + + return close(); + }; + + useClickOutside(divRef, close); + + return ( + +
+
+

정말로 삭제하시겠어요?

+ +
+ +
+
+ + +
+
+
+
+ ); +}); + +export default DeleteDialog; diff --git a/app/components/common/Dialog-new/Timer-Image-Delete-Dialog/DialogBody.tsx b/app/components/common/Dialog-new/Timer-Image-Delete-Dialog/DialogBody.tsx new file mode 100644 index 0000000..a088f08 --- /dev/null +++ b/app/components/common/Dialog-new/Timer-Image-Delete-Dialog/DialogBody.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export default function DialogBody() { + return ( +
+
타이머 배경은 기본 이미지로 되돌아갑니다.
+
+ ); +} diff --git a/app/components/common/Dialog-new/dialog.sass b/app/components/common/Dialog-new/dialog.sass index 8332b29..26e8cc1 100644 --- a/app/components/common/Dialog-new/dialog.sass +++ b/app/components/common/Dialog-new/dialog.sass @@ -23,6 +23,7 @@ justify-content: center // 중앙 정렬 h2 @include title20SB + cursor: default .close-button @@ -69,6 +70,7 @@ flex-direction: column align-items: center gap: 16px + cursor: default &__noti-content width: 521px @@ -112,15 +114,17 @@ margin-top: 16px display: flex justify-content: space-between - .delete-button width: 100px - - .action-buttons margin-left: auto display: flex gap: 10px + .timer-preview-image-footer-text + display: flex + justify-content: center + align-items: center + cursor: default .delete z-index: 600 @@ -134,26 +138,23 @@ left: 50% transform: translate(-50%, -50%) display: flex - gap: 16px + flex-direction: column + gap: 14px .preview_image position: relative width: 315px height: 682px - .preview - - border-radius: 13px - object-fit: contain - position: absolute + border-radius: 13px + overflow: hidden .status_bar position: absolute + width: 100% + height: 100% .mobile_preview - position: absolute - bottom: 32px - left: 50% - - transform: translateX( -50%) - + object-fit: cover + width: 100% + height: 100% .preview-text @include caption12M @@ -174,6 +175,41 @@ @include caption12M color: #378EFF +.preview-dialog-caption + width: 100% + display: flex + align-items: center + justify-content: left + @include body14M + cursor: default + +.preview-dialog-box + display: flex + gap: 16px + .timer-dimmed-box + position: absolute + width: 100% + height: 100% + background-color: $color-black60 + +.timer-preview-image-box + position: relative + width: 158px + height: 340px + border-radius: 6.88px + border: 1px solid $color-white20 + overflow: hidden + .timer-preview-image + object-fit: cover + .timer-dimmed-box + position: absolute + width: 100% + height: 100% + background-color: $color-black60 + .timer-preview-line + position: absolute + top: 0 + left: 0 .dont-show-again display: flex diff --git a/app/components/common/Toast/toast.sass b/app/components/common/Toast/toast.sass index 9b6dd98..dbcbbde 100644 --- a/app/components/common/Toast/toast.sass +++ b/app/components/common/Toast/toast.sass @@ -10,7 +10,7 @@ .toast-message width: 388px - background-color: $color-black + background-color: $color-white padding: 24px border-radius: 8px box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) @@ -20,11 +20,10 @@ @include body14R .toast-title - color: $color-white - + color: $color-black .toast-body - color: $color-white70 + color: $color-black // 애니메이션 효과 @keyframes fadeIn diff --git a/app/mutations/deleteTimerImage.ts b/app/mutations/deleteTimerImage.ts new file mode 100644 index 0000000..5cecb6d --- /dev/null +++ b/app/mutations/deleteTimerImage.ts @@ -0,0 +1,49 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; + +import { useToastWrite } from "@/components/atoms/toast.atom"; +import { apiClient } from "@/lib/reactQueryProvider"; +import { QUERY_KEY } from "@/queries/getHintList"; +import { MutationConfigOptions } from "@/types"; + +type Response = void; + +const MUTATION_KEY = ["DeleteTimerImage"]; +const deleteTimerImage = async (themeId: number) => { + const URL_PATH = `/v1/theme/timer/${themeId}`; + const res = await apiClient.delete>(URL_PATH); + + return res.data; +}; + +export const useDeleteTimerImage = (configOptions?: MutationConfigOptions) => { + const queryClient = useQueryClient(); + const setToast = useToastWrite(); + + const info = useMutation({ + mutationKey: MUTATION_KEY, + mutationFn: (req) => deleteTimerImage(req), + ...configOptions?.options, + onSuccess: () => { + queryClient.invalidateQueries(QUERY_KEY); + setToast({ + isOpen: true, + title: "타이머 배경을 삭제했습니다.", + text: "", + }); + // console.log("성공 시 실행") + }, + onSettled: () => { + // console.log("항상 실행"); + }, + onError: (error) => { + setToast({ + isOpen: true, + title: `${(error as any)?.response?.data?.message || error}`, + text: "", + }); + }, + }); + + return info; +}; diff --git a/app/mutations/useTimerImageUpload.ts b/app/mutations/useTimerImageUpload.ts new file mode 100644 index 0000000..bee7738 --- /dev/null +++ b/app/mutations/useTimerImageUpload.ts @@ -0,0 +1,157 @@ +import { AxiosError, AxiosResponse } from "axios"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiClient } from "@/lib/reactQueryProvider"; +import { useToastInfo } from "@/components/atoms/toast.atom"; +import { QUERY_KEY } from "@/queries/getThemeList"; +import extractFilename from "@/utils/helper"; + +interface PreSignedUrlRequest { + themeId: number; +} + +interface PreSignedUrlResponse { + code: number; + message: string; + data: { + themeId: number; + imageUrl: string; + }; +} + +interface UploadParams { + url: string; + file: File; +} + +interface AxiosSameCodeError { + code: number; + message: string; +} + +interface TimerImageData { + themeId: number; + timerImageFile?: File; + imageUrl?: string; +} + +const getPreSignedUrl = async ( + params: PreSignedUrlRequest +): Promise => { + const { data } = await apiClient.get(`/v1/theme/timer/url/${params.themeId}`); + return data; +}; + +const uploadToS3 = async ({ url, file }: UploadParams): Promise => { + const response = await fetch(url, { + method: "PUT", + headers: { + "content-type": "image/png", + }, + body: file, + }); + + if (!response.ok) { + throw new Error(`Upload failed with status: ${response.status}`); + } +}; + +const postTimerImage = (data: TimerImageData) => + apiClient.post("/v1/theme/timer", data); + +const useTimerImageUpload = () => { + const [, setToast] = useToastInfo(); + const queryClient = useQueryClient(); + const presignedMutation = useMutation< + PreSignedUrlResponse, + AxiosError, + PreSignedUrlRequest + >({ + mutationFn: async (params) => { + return getPreSignedUrl(params); + }, + onError: (error) => { + setToast({ + isOpen: true, + title: error.message, + text: "presigned request fail", + }); + }, + }); + + const uploadMutation = useMutation({ + mutationFn: uploadToS3, + onError: (error) => { + setToast({ + isOpen: true, + title: error.message, + text: "", + }); + }, + }); + + const timerImageMutation = useMutation< + AxiosResponse, + AxiosError, + TimerImageData + >({ + mutationFn: (data) => postTimerImage(data), + onSuccess: async () => { + await queryClient.invalidateQueries(QUERY_KEY); + setToast({ + isOpen: true, + title: "타이머 배경을 등록했습니다.", + text: "힌트폰에서 세부 조정할 수 있습니다.", + }); + }, + onError: (error) => { + if (error.response) { + setToast({ + isOpen: true, + title: error.response.data.message, + text: "", + }); + throw new Error(error.response.data.message); + } + }, + }); + + const handleProcess = async ({ themeId, timerImageFile }: TimerImageData) => { + try { + const presignedResponse = await presignedMutation.mutateAsync({ + themeId, + }); + + const { imageUrl } = presignedResponse.data; + if (imageUrl) { + await uploadMutation.mutateAsync({ + url: imageUrl, + file: timerImageFile!, + }); + } + + const data: TimerImageData = { + themeId: themeId, + imageUrl: extractFilename(imageUrl), + }; + + await timerImageMutation.mutateAsync(data); + return imageUrl; + } catch (error) { + if (error instanceof Error) { + setToast({ + isOpen: true, + title: error.message, + text: "", + }); + } + throw error; + } + }; + + return { + handleProcess, + }; +}; + +export default useTimerImageUpload; diff --git a/app/queries/getThemeList.ts b/app/queries/getThemeList.ts index 3e69519..78236a7 100644 --- a/app/queries/getThemeList.ts +++ b/app/queries/getThemeList.ts @@ -71,6 +71,6 @@ export const useGetThemeList = (configOptions?: QueryConfigOptions) => { ...info, isInitialLoading: info.isLoading, isRefetching: info.isFetching && !info.isLoading, - isLoading: info.isLoading || info.isFetching, + isLoading: info.isLoading, }; }; diff --git a/package-lock.json b/package-lock.json index 0b5e31d..54aabb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "devDependencies": { "@svgr/webpack": "^8.0.1", "@types/http-proxy": "^1.17.15", + "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.13", "@types/styled-components": "^5.1.26", "@typescript-eslint/eslint-plugin": "^5.61.0", @@ -55,6 +56,7 @@ "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "husky": "^8.0.3", + "js-cookie": "^3.0.5", "prettier": "^2.8.8" } }, @@ -3720,6 +3722,12 @@ "@types/node": "*" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6841,6 +6849,15 @@ "node": ">= 0.4" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/public/images/svg/timer_preview_entire.svg b/public/images/svg/timer_preview_entire.svg new file mode 100644 index 0000000..b53b9f8 --- /dev/null +++ b/public/images/svg/timer_preview_entire.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +