diff --git a/app/admin-old/page.tsx b/app/admin-old/page.tsx deleted file mode 100644 index 302d0557..00000000 --- a/app/admin-old/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Home from "@/home/Home"; -// import Login from "@/login/Login"; - -export default function adminLoginPage() { - return ; -} diff --git a/app/admin/(components)/CreateTheme/Container.tsx b/app/admin/(components)/CreateTheme/Container.tsx index 80aeb11e..dc3c45e1 100644 --- a/app/admin/(components)/CreateTheme/Container.tsx +++ b/app/admin/(components)/CreateTheme/Container.tsx @@ -1,9 +1,11 @@ import { FormEvent } from "react"; - import "../../(style)/createTheme.modules.sass"; +import { useRouter } from "next/navigation"; + import { usePostTheme } from "@/mutations/postTheme"; import { useCreateThemeValue } from "@/components/atoms/createTheme.atom"; import { useSelectedThemeWrite } from "@/components/atoms/selectedTheme.atom"; +import { setSelectedThemeId } from "@/utils/storageUtil"; import CreateThemeTitle from "./CreateThemeTitle"; import CreateThemeBody from "./CreateThemeBody"; @@ -13,7 +15,7 @@ export default function CreateTheme() { const createTheme = useCreateThemeValue(); const setSelectedTheme = useSelectedThemeWrite(); const { mutateAsync: postTheme } = usePostTheme(); - + const router = useRouter(); const handleKeyDownSubmit = async (e: FormEvent) => { e.preventDefault(); const isDisabled = @@ -24,10 +26,16 @@ export default function CreateTheme() { if (isDisabled) { return; } - const response = await postTheme(createTheme); - const { id } = response.data.data; - if (id) { - setSelectedTheme(createTheme); + try { + const response = await postTheme(createTheme); + const { id } = response.data.data; + router.push(`/admin?themeId=${encodeURIComponent(id)}`); + if (id) { + setSelectedTheme(createTheme); + setSelectedThemeId(id); + } + } catch (err) { + console.error("Login failed:", err); } }; diff --git a/app/admin/(components)/Sidebar.tsx b/app/admin/(components)/Sidebar.tsx index a6739563..c6e19058 100644 --- a/app/admin/(components)/Sidebar.tsx +++ b/app/admin/(components)/Sidebar.tsx @@ -1,9 +1,10 @@ -import React 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/Hint-Dialog-new/Dialog"; +import HintDialog from "@/components/common/Dialog-new/Hint-Dialog-new/Dialog"; import { logoProps, plusDisableProps, @@ -11,15 +12,16 @@ import { subscribeLinkURL, } from "@/admin/(consts)/sidebar"; import { + getLoginInfo, getSelectedThemeId, getStatus, removeAccessToken, removeThemeId, -} from "@/utils/localStorage"; +} from "@/utils/storageUtil"; import { useSelectedThemeReset } from "@/components/atoms/selectedTheme.atom"; -import { useIsLoggedInWrite } from "@/components/atoms/account.atom"; import { useDrawerState } from "@/components/atoms/drawer.atom"; import useModal from "@/hooks/useModal"; +import { QUERY_KEY } from "@/queries/getThemeList"; interface Theme { id: number; @@ -29,8 +31,6 @@ interface Theme { } interface Props { - adminCode: string; - shopName: string; categories: Theme[]; selectedTheme: Theme; handleClickSelected: (theme: Theme) => void; @@ -39,7 +39,8 @@ interface Props { export default function Sidebar(props: Props) { const router = useRouter(); const resetSelectedTheme = useSelectedThemeReset(); - // const setIsLoggedIn = useIsLoggedInWrite(); + const queryClient = useQueryClient(); + const [drawer, setDrawer] = useDrawerState(); const { open } = useModal(); @@ -47,24 +48,41 @@ 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(); - // setIsLoggedIn(false); - // }; + const handleLogout = () => { + removeAccessToken(); + resetSelectedTheme(); + setLoginInfo({ + adminCode: "", + shopName: "", + }); + // router.push("/login"); + window.location.href = "/login"; + }; + useEffect(() => { + if (selectedThemeId && selectedThemeId !== "0") + router.push( + `/admin?themeId=${encodeURIComponent(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", @@ -75,6 +93,7 @@ export default function Sidebar(props: Props) { }); } else { setDrawer({ ...drawer, isOpen: false }); + await queryClient.invalidateQueries(QUERY_KEY); handleClickSelected(theme); } }; @@ -93,9 +112,18 @@ export default function Sidebar(props: Props) {
- +
+ +
+ +
- {shopName?.replaceAll(`"`, "")} + {loginInfo.shopName?.replaceAll(`"`, "")}
우리 지점 테마
@@ -111,7 +139,7 @@ export default function Sidebar(props: Props) {
diff --git a/app/admin/(components)/ThemeDrawer/helpers/imageHelpers.ts b/app/admin/(components)/ThemeDrawer/helpers/imageHelpers.ts index 537d5346..fa26d255 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/useEditHint.ts b/app/admin/(components)/ThemeDrawer/hooks/useEditHint.ts index a5def036..dc0e3955 100644 --- a/app/admin/(components)/ThemeDrawer/hooks/useEditHint.ts +++ b/app/admin/(components)/ThemeDrawer/hooks/useEditHint.ts @@ -1,6 +1,6 @@ import { useState, useEffect, FormEvent, useRef } from "react"; -import HintDialog from "@/components/common/Hint-Dialog-new/Dialog"; +import HintDialog from "@/components/common/Dialog-new/Hint-Dialog-new/Dialog"; import { InitialSelectedHint, SelectedHintType, diff --git a/app/admin/(components)/ThemeDrawer/hooks/useImages.ts b/app/admin/(components)/ThemeDrawer/hooks/useImages.ts index f1a52dcc..82127eb2 100644 --- a/app/admin/(components)/ThemeDrawer/hooks/useImages.ts +++ b/app/admin/(components)/ThemeDrawer/hooks/useImages.ts @@ -11,10 +11,10 @@ import { import { useCreateHint } from "@/components/atoms/createHint.atom"; import { useSelectedHint } from "@/components/atoms/selectedHint.atom"; import { useToastWrite } from "@/components/atoms/toast.atom"; -import { getStatus } from "@/utils/localStorage"; +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 da324416..e50767c1 100644 --- a/app/admin/(components)/ThemeInfo/Container.tsx +++ b/app/admin/(components)/ThemeInfo/Container.tsx @@ -4,14 +4,14 @@ import classNames from "classnames"; import "../../(style)/themeInfo.modules.sass"; import useModal from "@/hooks/useModal"; -import Dialog from "@/components/common/Dialog-new/Dialog"; +import Dialog from "@/components/common/Dialog-new/Theme-Dialog/Dialog"; import { useDrawerState } from "@/components/atoms/drawer.atom"; import ThemeDrawer from "../ThemeDrawer/Container"; import ThemeInfoTitle from "./ThemeInfoTitle"; -import ThemeInfoBody from "./ThemeInfoBody"; import ThemeInfoHint from "./ThemeInfoHint"; +import ThemeImage from "./ThemeTimerImage"; export default function ThemeInfo() { const { open } = useModal(); @@ -46,7 +46,7 @@ export default function ThemeInfo() { })} > - + {drawer.isOpen && ( void; -} -export default function ThemeInfoBody({ handleOpenModal }: Props) { - const selectedTheme = useSelectedThemeValue(); - - const themeInfo = [ - { - title: "탈출 제한 시간", - content: `${selectedTheme.timeLimit}분`, - }, - { - title: "사용 가능한 힌트", - content: `${selectedTheme.hintLimit}개`, - }, - ]; - - return ( -
-
- {themeInfo.map((info) => ( - - ))} -
-
- ); -} diff --git a/app/admin/(components)/ThemeInfo/ThemeInfoHint.tsx b/app/admin/(components)/ThemeInfo/ThemeInfoHint.tsx index 15282d9f..8c11c5ce 100644 --- a/app/admin/(components)/ThemeInfo/ThemeInfoHint.tsx +++ b/app/admin/(components)/ThemeInfo/ThemeInfoHint.tsx @@ -3,7 +3,7 @@ import classNames from "classnames"; import { useGetHintList } from "@/queries/getHintList"; import { useSelectedThemeValue } from "@/components/atoms/selectedTheme.atom"; -import HintDialog from "@/components/common/Hint-Dialog-new/Dialog"; +import HintDialog from "@/components/common/Dialog-new/Hint-Dialog-new/Dialog"; import { SelectedHintType, useSelectedHint, diff --git a/app/admin/(components)/ThemeInfo/ThemeInfoTitle.tsx b/app/admin/(components)/ThemeInfo/ThemeInfoTitle.tsx index 6dd11634..9b133574 100644 --- a/app/admin/(components)/ThemeInfo/ThemeInfoTitle.tsx +++ b/app/admin/(components)/ThemeInfo/ThemeInfoTitle.tsx @@ -1,18 +1,32 @@ +import Image from "next/image"; import React from "react"; +import { settingProps } from "@/admin/(consts)/sidebar"; import { useSelectedThemeValue } from "@/components/atoms/selectedTheme.atom"; - interface Props { handleOpenModal: () => void; } -export default function ThemeInfoTitle({ handleOpenModal }: Props) { +export default function ThemeInfoTitleNew({ handleOpenModal }: Props) { const selectedTheme = useSelectedThemeValue(); + return (
-
-
{selectedTheme.title}
-
+
{selectedTheme.title}
+
+ 탈출 제한 시간 + + {selectedTheme.timeLimit}분 + +
+ 사용 가능한 힌트 + + {selectedTheme.hintLimit}개 +
+ +
); } diff --git a/app/admin/(components)/ThemeInfo/ThemeTimerImage.tsx b/app/admin/(components)/ThemeInfo/ThemeTimerImage.tsx new file mode 100644 index 00000000..5218912a --- /dev/null +++ b/app/admin/(components)/ThemeInfo/ThemeTimerImage.tsx @@ -0,0 +1,147 @@ +import Image from "next/image"; +import React, { ChangeEvent, useEffect, useRef, useState } from "react"; +import Lottie from "react-lottie-player"; + +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"; +import loaderJson from "../../../../public/lottie/loader.json"; + +export default function ThemeTimerImage() { + const [selectedTheme, setSelectedTheme] = useSelectedTheme(); + const setTimerImage = useTimerImageWrite(); + + const [isTimerImageLoading, setIsTimerImageLoading] = useState(false); + 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) { + setIsTimerImageLoading(true); + const options = { + maxSizeMB: 0.5, + maxWidthOrHeight: 1000, + useWebWorker: true, + }; + const compressedFile = await getCompressImage(file, options); + setIsTimerImageLoading(false); + setTimerImage({ timerImage: compressedFile }); + } else { + setTimerImage({ timerImage: file }); + } + + if (file) { + open(Dialog); + } + fileReset(); + setIsTimerImageLoading(false); + }; + 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 && } +
+
+
+
+ {isTimerImageLoading && ( + + )} +
+ + {selectedTheme.useTimerUrl && ( +
+ +
+ )} +
+ + {selectedTheme.useTimerUrl ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/app/admin/(components)/Tooltip/Container.tsx b/app/admin/(components)/Tooltip/Container.tsx new file mode 100644 index 00000000..b63b316f --- /dev/null +++ b/app/admin/(components)/Tooltip/Container.tsx @@ -0,0 +1,24 @@ +import Image from "next/image"; +import React from "react"; + +import "../../(style)/tooltip.modules.sass"; +import { arrowProps, timerTooltipProps } from "@/admin/(consts)/sidebar"; + +export default function Container() { + return ( +
+ +
+ 타이머 배경이란? +

+ 힌트폰 타이머에 테마별로 원하는 배경을 넣어 우리 매장만의 힌트폰을 + 제공할 수 있습니다. 타이머 배경을 설정하지 않으면 기본 이미지로 + 나타납니다. +

+
+ +
+
+
+ ); +} diff --git a/app/admin/(consts)/sidebar.ts b/app/admin/(consts)/sidebar.ts index 9e9e1b16..c37454e6 100644 --- a/app/admin/(consts)/sidebar.ts +++ b/app/admin/(consts)/sidebar.ts @@ -35,3 +35,64 @@ export const deleteProps = { export const subscribeLinkURL = "https://sponge-wood-68d.notion.site/ec2a28c470094769bc787acb74760da5"; + +export const timerImageLinkURL = + "https://www.notion.so/186febdc0ad180248728f1cfaf9bee20?pvs=4"; + +export const smallXProps = { + src: "/images/svg/icon_X.svg", + alt: "x icon", + width: 16, + height: 16, +}; + +export const timerPreviewProps = { + src: "/images/svg/timer_preview.svg", + alt: "timer_preview", + width: 284, + height: 555, +}; + +export const settingProps = { + src: "/images/svg/icon_setting.svg", + alt: "icon_setting", + width: 24, + 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", + width: 108, + height: 224, +}; + +export const arrowProps = { + src: "/images/svg/arrow.svg", + alt: "arrow", + width: 20, + height: 14, +}; + +export const notiImageProps = { + src: "/images/png/noti_image.png", + alt: "noti_image", + width: 160, + height: 295, +}; diff --git a/app/admin/(style)/admin.modules.sass b/app/admin/(style)/admin.modules.sass index 394624be..ec2f7857 100644 --- a/app/admin/(style)/admin.modules.sass +++ b/app/admin/(style)/admin.modules.sass @@ -16,6 +16,8 @@ background-color: $color-main padding: 24px border-right: 1px solid $color-white12 + cursor: default + &__top flex-shrink: 0 @@ -24,10 +26,42 @@ align-items: center margin-bottom: 24px + &__shop-info-img-box + position: relative + width: 36px + height: 36px + margin-right: 6px + border: 1px solid transparent + padding-top: 1px + padding-left: 1px + button + display: none + div + display: none + + &__shop-info-img-box:hover + border-radius: 10px + border: 1px solid $color-white20 + button + display: block + position: absolute + top: 40px + left: 0 + width: 129px + height: 36px + color: $color-white + @include body14M + background-color: $color-sub1 + border: 1px solid $color-white5 + border-radius: 8px + div + display: block + width: 100% + height: 4px + &__shop-logo width: 32px height: 32px - margin-right: 6px border-radius: 8px border: 1px solid $color-white8 @@ -149,7 +183,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 05929b03..8139acd8 100644 --- a/app/admin/(style)/themeInfo.modules.sass +++ b/app/admin/(style)/themeInfo.modules.sass @@ -6,89 +6,50 @@ width: 100% height: 100% transition: width 0.4s ease + &__title height: fit-content - padding: 23px 0 - @include title24M + padding: 30px 0 + @include title24SB color: $color-white - .theme-infomation-fit - width: fit-content - display: flex - justify-content: left - align-items: center - gap: 4px - cursor: pointer - &:hover - color: $color-white50 - .image - background-image: url(/images/svg/icon_ArrowDownMini_hover.svg) - &:active - color: $color-white20 - .image - background-image: url(/images/svg/icon_ArrowDownMini_active.svg) - .title - width: fit-content - .image - width: 24px - height: 24px - background-image: url(/images/svg/icon_ArrowDownMini_default.svg) - background-repeat: no-repeat - background-position: center - background-size: cover - - - &__body - height: fit-content - display: flex - flex-direction: column - gap: 12px + position: relative + div + cursor: default .drawer-open width: calc(100% - 520px) +.decoration-line + position: absolute + top: 126px + left: -40px + width: calc(100% + 80px) + height: 1px + background-color: $color-white5 + +.theme-infomation-text-box + margin-top: 17px + display: flex + align-items: center + gap: 8px +.dot + width: 3px + height: 3px + border-radius: 50% + background-color: white +.setting-button + position: absolute + right: 0 + top: 30px + cursor: pointer + .theme-infomation-text @include body14M color: $color-white50 - cursor: default -.theme-infomation-container - display: flex - gap: 16px -.theme-infomation-box - position: relative - width: 50% - height: 82px - padding: 17px 24px 17px 20px - border: 1px solid $color-white8 - box-sizing: border-box - border-radius: 8px - display: flex - flex-direction: column - gap: 4px - cursor: pointer - div - cursor: pointer - &:hover - background-color: $color-white5 - .theme-infomation-modify-text - color: $color-white - display: block - &:active - background-color: $color-white8 - .theme-infomation-modify-text - display: block - color: $color-white .theme-infomation-content-text - @include title18M - color: $color-white - -.theme-infomation-modify-text - display: none - position: absolute - right: 24px - top: 31px @include body14M - + color: $color-white .theme-hint width: 100% @@ -351,4 +312,56 @@ object-fit: contain border-radius: 8px max-height: 300px - max-width: 100% \ No newline at end of file + max-width: 100% + + +.theme_image + &__container + margin-top: 38px + + + background-color: $color-white5 +.theme-image-title + display: flex + align-items: center + span + @include title16SB + margin-right: 2px + cursor: default + img + vertical-align: bottom + + + +.theme-images + display: flex + gap: 16px + margin-top: 17px + position: relative + align-items: flex-end + &:hover + .theme-image-box:hover .theme-image-dimmed + visibility: visible + cursor: pointer +.theme-image-dimmed + position: absolute + top: 0 + width: 100% + height: 100% + background-color: $color-black60 + border-radius: 8px + display: flex + justify-content: center + align-items: center + visibility: hidden +.theme-image-box + width: 120px + height: 120px + position: relative + img + border: 1px solid $color-white20 + object-fit: cover + border-radius: 12px + +.theme-image-loader-box + position: absolute \ No newline at end of file diff --git a/app/admin/(style)/tooltip.modules.sass b/app/admin/(style)/tooltip.modules.sass new file mode 100644 index 00000000..553c3fb4 --- /dev/null +++ b/app/admin/(style)/tooltip.modules.sass @@ -0,0 +1,41 @@ +@import '../../style/variables' +@import '../../style/mixins' + +.arrow + position: absolute + top: -12px + left: 139px + +.tooltip-container + position: fixed + top: 200px + left: 260px + z-index: 999 + +.content-container + display: hidden + background-color: white + width: 298px + height: 421px + border-radius: 12px + padding: 24px + + &__title + @include title16SB + color: $color-black + + &__content + @include body14R + color: $color-sub2 + margin: 8px 0 16px + + &__image + width: 250px + height: 250px + background-color: #0000001F + border-radius: 12px + padding: 12px 0 14px + display: flex + flex-direction: column + align-items: center + diff --git a/app/admin/Admin.tsx b/app/admin/Admin.tsx index 08f171c4..88dee7e3 100644 --- a/app/admin/Admin.tsx +++ b/app/admin/Admin.tsx @@ -1,11 +1,11 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import useCheckSignIn from "@/hooks/useCheckSignIn"; import Loader from "@/components/Loader/Loader"; -import { getLoginInfo, setSelectedThemeId } from "@/utils/localStorage"; +import { getLoginInfo, setSelectedThemeId } from "@/utils/storageUtil"; import { useSelectedTheme } from "@/components/atoms/selectedTheme.atom"; import { useGetThemeList } from "@/queries/getThemeList"; import { useToastInfo } from "@/components/atoms/toast.atom"; @@ -20,21 +20,19 @@ type Theme = { }; function Admin() { - const { data: categories = [] } = useGetThemeList(); - + 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 (categories.length > 0 && selectedTheme.id === 0) { + if (!isLoading && categories.length > 0 && selectedTheme.id === 0) { setSelectedTheme(categories[categories.length - 1]); } - }, [categories, selectedTheme, setSelectedTheme]); + }, [isLoading]); const handleClickSelected = (theme: Theme) => { setSelectedTheme(theme); @@ -55,18 +53,13 @@ function Admin() { }, [toast, setToast]); const SidebarViewProps = { - adminCode, - shopName, categories, selectedTheme, handleClickSelected, isOpen: toast.isOpen, + isLoading, }; - if (!isLoggedIn) { - return ; - } - return ; } diff --git a/app/admin/AdminView.tsx b/app/admin/AdminView.tsx index 84b1c40b..73a53942 100644 --- a/app/admin/AdminView.tsx +++ b/app/admin/AdminView.tsx @@ -1,9 +1,13 @@ -import React from "react"; +import React, { useEffect } from "react"; import "./(style)/admin.modules.sass"; import Sidebar from "@/admin/(components)/Sidebar"; import ContentArea from "@/admin/(components)/ContentArea"; 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; @@ -13,16 +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 { 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/CustomModal/CustomModal.tsx b/app/components/CustomModal/CustomModal.tsx deleted file mode 100644 index 01880d97..00000000 --- a/app/components/CustomModal/CustomModal.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from "react"; - -import CustomModalView from "./CustomModalView"; -import { CustomTypeModalProps } from "./CustomModal.type"; - -function CustomModal(props: CustomTypeModalProps) { - const { - open = false, - handleClose = () => { - return; - }, - id = "", - content = {} as CustomTypeModalProps["content"], - } = props; - return ( - - ); -} - -export default CustomModal; diff --git a/app/components/CustomModal/CustomModal.type.ts b/app/components/CustomModal/CustomModal.type.ts deleted file mode 100644 index 16d09e24..00000000 --- a/app/components/CustomModal/CustomModal.type.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface CustomTypeModalProps { - id: string; - content: { - title: string; - describe: string; - btnName: string; - handleModal: () => void; - }; - open: boolean; - handleClose: () => void; -} diff --git a/app/components/CustomModal/CustomModalView.styled.ts b/app/components/CustomModal/CustomModalView.styled.ts deleted file mode 100644 index ed6b3d19..00000000 --- a/app/components/CustomModal/CustomModalView.styled.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { styled } from "styled-components"; -import { Grid } from "@mui/material"; - -export const Container = styled.div` - display: flex; - padding: 30px 0; - justify-content: center; - flex-direction: column; - background-color: #fff; -`; - -export const Title = styled.div` - font-size: 1.75rem; - font-weight: 400; - margin: 32px auto; -`; - -export const Description = styled.div` - font-size: 1rem; - font-weight: 400; - margin: 32px auto; -`; - -export const ContentsWrapper = styled.div` - display: flex; - justify-content: center; -`; - -export const GridItem = styled(Grid)<{ margin?: string }>` - margin-bottom: 30px; - ${({ margin }) => `margin: ${margin};`} -`; diff --git a/app/components/CustomModal/CustomModalView.tsx b/app/components/CustomModal/CustomModalView.tsx deleted file mode 100644 index ae4d5698..00000000 --- a/app/components/CustomModal/CustomModalView.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import { Button, Modal } from "@mui/material"; - -import * as S from "./CustomModalView.styled"; -import { CustomTypeModalProps } from "./CustomModal.type"; - -function CustomModalView(props: CustomTypeModalProps) { - const { open, content, handleClose } = props; - - return ( - - - {content.title} - {content.describe} - - - - - - - - - ); -} - -export default CustomModalView; diff --git a/app/components/DeleteHintDialog/DeleteHintDialog.tsx b/app/components/DeleteHintDialog/DeleteHintDialog.tsx deleted file mode 100644 index 1527d240..00000000 --- a/app/components/DeleteHintDialog/DeleteHintDialog.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useDeleteHint } from "@/mutations/deleteHint"; - -import { useIsOpenDeleteDialogState } from "../atoms/hints.atom"; - -import DeleteHintDialogView from "./DeleteHintDialogView"; - -function DeleteHintDialog() { - const [isOpenDeleteDialogState, setIsOpenDeleteDialogState] = - useIsOpenDeleteDialogState(); - const { isOpen, id } = isOpenDeleteDialogState; - const { mutateAsync: deleteHint } = useDeleteHint(); - - const handleClose = () => { - setIsOpenDeleteDialogState({ isOpen: false, id: 0 }); - }; - - const handleDelete = () => { - deleteHint({ id }); - handleClose(); - }; - - const DeleteHintDialogProps = { - open: isOpen, - handleClose, - handleDelete, - }; - - return ; -} - -export default DeleteHintDialog; diff --git a/app/components/DeleteHintDialog/DeleteHintDialogView.tsx b/app/components/DeleteHintDialog/DeleteHintDialogView.tsx deleted file mode 100644 index e1f59c88..00000000 --- a/app/components/DeleteHintDialog/DeleteHintDialogView.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from "@mui/material"; -import React from "react"; - -interface Props { - open: boolean; - handleClose: () => void; - handleDelete: () => void; -} - -const CANCEL = "취소"; -const DELETE = "삭제하기"; - -function DeleteHintDialogView(props: Props) { - const { open, handleClose, handleDelete } = props; - return ( - - 힌트를 삭제하시겠어요? - - - 지워진 내용은 되돌릴 수 없습니다. - - - - - - - - ); -} - -export default DeleteHintDialogView; diff --git a/app/components/DeleteHintDialog/index.ts b/app/components/DeleteHintDialog/index.ts deleted file mode 100644 index c4a8f0a2..00000000 --- a/app/components/DeleteHintDialog/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as DeleteHintDialog } from "./DeleteHintDialog"; -export { default as DeleteHintDialogView } from "./DeleteHintDialogView"; diff --git a/app/components/DeleteThemeDialog/DeleteThemeDialog.tsx b/app/components/DeleteThemeDialog/DeleteThemeDialog.tsx deleted file mode 100644 index c41fe947..00000000 --- a/app/components/DeleteThemeDialog/DeleteThemeDialog.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useDeleteTheme } from "@/mutations/deleteTheme"; - -import DeleteThemeDialogView from "./DeleteThemeDialogView"; - -interface Props { - open: boolean; - handleSnackOpen: () => void; - handleClose: () => void; - id: number; -} - -function DeleteThemeDialog(props: Props) { - const { open, handleSnackOpen, handleClose, id } = props; - const { mutateAsync: deleteTheme } = useDeleteTheme(); - - const handleDelete = () => { - deleteTheme({ id }); - handleSnackOpen(); - handleClose(); - }; - - const DeleteThemeDialogProps = { - open, - handleClose, - handleDelete, - }; - - return ; -} - -export default DeleteThemeDialog; diff --git a/app/components/DeleteThemeDialog/DeleteThemeDialogView.tsx b/app/components/DeleteThemeDialog/DeleteThemeDialogView.tsx deleted file mode 100644 index cb418c6f..00000000 --- a/app/components/DeleteThemeDialog/DeleteThemeDialogView.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from "@mui/material"; -import React from "react"; - -interface Props { - open: boolean; - handleClose: () => void; - handleDelete: () => void; -} - -const CANCEL = "취소"; -const DELETE = "삭제하기"; - -function DeleteThemeDialogView(props: Props) { - const { open, handleClose, handleDelete } = props; - return ( - - - 테마를 삭제하시겠어요? - - - - 지워진 내용은 되돌릴 수 없습니다. - - - - - - - - ); -} - -export default DeleteThemeDialogView; diff --git a/app/components/DeleteThemeDialog/index.ts b/app/components/DeleteThemeDialog/index.ts deleted file mode 100644 index ccc029e4..00000000 --- a/app/components/DeleteThemeDialog/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as DeleteThemeDialog } from "./DeleteThemeDialog"; -export { default as DeleteThemeDialogView } from "./DeleteThemeDialogView"; diff --git a/app/components/HintItem/HintItem.tsx b/app/components/HintItem/HintItem.tsx deleted file mode 100644 index f58603be..00000000 --- a/app/components/HintItem/HintItem.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react"; - -import HintItemView from "./HintItemView"; - -type Props = { - id: number; - hintCode: string; - contents: string; - answer: string; - progress: number; - onClick: () => void; -}; - -export type HintData = { - progress: number; - hintCode: string; - contents: string; - answer: string; -}; - -function HintItem(props: Props) { - const { id, hintCode, contents, answer, progress, onClick } = props; - - const hintData = { - progress, - hintCode, - contents, - answer, - }; - - const HintManageListItemProps = { - id, - hintData, - onClick, - }; - - return ; -} - -export default HintItem; diff --git a/app/components/HintItem/HintItemView.styled.ts b/app/components/HintItem/HintItemView.styled.ts deleted file mode 100644 index 0bdeaaf3..00000000 --- a/app/components/HintItem/HintItemView.styled.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Stack } from "@mui/material"; -import { styled } from "styled-components"; - -export const SummaryStack = styled(Stack)` - width: 100%; - align-items: center; -`; - -export const CodeProgressWrapper = styled.div` - display: flex; - align-items: center; - width: 360px; - height: 30px; -`; - -export const IconText = styled.div` - display: flex; - width: 168px; - justify-content: baseline; - align-items: center; - color: #6750a4; - - svg { - margin-right: 15px; - fill: #6750a4; - } -`; - -export const SummaryText = styled.div` - display: flex; - width: 100%; - max-width: 600px; - align-items: center; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -export const DetailIconText = styled.div` - display: flex; - flex: 30px auto; - width: 100%; - margin: 15px 0; - - font-size: 14px; - font-weight: 400; - line-height: 24px; - - svg { - display: block; - margin-right: 25px; - fill: #aea9b1; - } - - & + & { - margin-top: 30px; - } -`; - -export const ButtonsStack = styled(Stack)` - justify-content: end; - align-items: center; -`; - -export const ItemWrapper = styled.div` - display: flex; - - width: 100%; - min-height: 48px; - gap: 8px; - - font-size: ${({ theme }) => theme.fontSize.sm}; - font-weight: ${({ theme }) => theme.fontWeight.medium}; - color: ${({ theme }) => theme.color.white70}; - line-height: 16.71px; - - border-bottom: 1px solid ${({ theme }) => theme.color.white20}; - - &:hover { - cursor: pointer; - background-color: ${({ theme }) => theme.color.white10}; - } - - .numberBox { - display: flex; - align-items: center; - min-width: 96px; - color: ${({ theme }) => theme.color.white}; - } - - .textBox { - display: flex; - flex: 1; - align-items: center; - /* width: 448px; */ - padding: 12px 0; - white-space: nowrap; - overflow: hidden; - - span { - overflow: hidden; - text-overflow: ellipsis; - } - } -`; diff --git a/app/components/HintItem/HintItemView.tsx b/app/components/HintItem/HintItemView.tsx deleted file mode 100644 index 01dac6c0..00000000 --- a/app/components/HintItem/HintItemView.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; - -import * as S from "./HintItemView.styled"; -import { HintData } from "./HintItem"; - -type Props = { - id: number; - hintData: HintData; - onClick: () => void; -}; - -function HintItemView(props: Props) { - const { hintData, id, onClick } = props; - - return ( - -
{hintData.hintCode}
-
{hintData.progress}%
-
- {hintData.contents} -
-
- {hintData.answer} -
-
- ); -} - -export default HintItemView; diff --git a/app/components/HintItem/index.ts b/app/components/HintItem/index.ts deleted file mode 100644 index 105e828e..00000000 --- a/app/components/HintItem/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as HintItem } from "./HintItem"; -export { default as HintItemView } from "./HintItemView"; diff --git a/app/components/HintList/HintList.styled.ts b/app/components/HintList/HintList.styled.ts deleted file mode 100644 index 129f5c65..00000000 --- a/app/components/HintList/HintList.styled.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Button } from "@mui/material"; -import { css, keyframes, styled } from "styled-components"; - -export const HintListWrapper = styled.div` - margin-top: 60px !important; -`; - -export const Header = styled.div` - display: flex; - font-size: ${(props) => props.theme.fontSize.xs}; - font-weight: ${(props) => props.theme.fontWeight.medium}; - color: #ffffff60; - height: 34px; - gap: 8px; - - .smallHeader { - min-width: 96px; - } - - .largeHeader { - /* width: calc(((100% - (96px * 2)) / 2) - 8px); */ - /* width: 448px; */ - flex: 1; - } -`; - -export const Empty = styled.button` - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 230px; - color: ${(props) => props.theme.color.white}; - font-size: ${(props) => props.theme.fontSize.sm}; - background-color: #ffffff10; - border: 0; - cursor: pointer; - - img { - margin-right: 8px; - } -`; - -const riseUpAnimation = keyframes` - from { - bottom: -100px; - } - to { - bottom: 40px; - } -`; - -const downAnimation = keyframes` - - from { - bottom: 40px; - } - to { - bottom: -100px; - } -`; - -export const FloatButton = styled(Button)<{ active?: boolean }>` - position: fixed !important; - color: #000 !important; - background-color: #fff !important; - font-weight: 600 !important; - width: 183px; - height: 40px; - bottom: -100px; - left: calc((100% - 360px) / 2 + 360px); - transform: translateX(-50%); - font-weight: 600; - width: 215px; - - ${(props) => - props.active - ? css` - animation: ${riseUpAnimation} 300ms forwards 300ms ease-out; - bottom: -100px; - ` - : css` - animation: ${downAnimation} 300ms forwards 300ms ease-out; - bottom: 0px; - `} -`; diff --git a/app/components/HintList/HintList.tsx b/app/components/HintList/HintList.tsx deleted file mode 100644 index 31f7e9ea..00000000 --- a/app/components/HintList/HintList.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, { useEffect, useMemo, useState, useCallback } from "react"; -import Image from "next/image"; -import { ListItemIcon, ListItemText } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; - -import { useGetHintList } from "@/queries/getHintList"; - -import { HintItem } from "../HintItem"; -import HintManager from "../HintManager/HintManager"; -import { DeleteHintDialog } from "../DeleteHintDialog"; -import { useSelectedThemeValue } from "../atoms/selectedTheme.atom"; -import { useActiveHintState } from "../atoms/activeHint.atom"; -import Dialog from "../common/Dialog/Dialog"; -import Loader from "../Loader/Loader"; - -import * as S from "./HintList.styled"; - -function HintList() { - const [isMakeEnabled, setIsMakeEnabled] = useState(false); - const [isModifyEnableds, setIsModifyEnableds] = useState([]); - const { id: themeId } = useSelectedThemeValue(); - const { data: hints = [], isLoading = false } = useGetHintList({ themeId }); - const hintsLength = hints.length; - const [activeHint, setActiveHint] = useActiveHintState(); - const [dialogOpen, setDialogOpen] = useState(false); - - useEffect(() => { - setIsModifyEnableds([]); - setIsMakeEnabled(false); - }, [themeId]); - - const getOpenedModify = (id: number) => - !!isModifyEnableds.find((modifyEnables) => modifyEnables === id); - - const closeModify = (id: number) => { - const enableds = isModifyEnableds.filter((prevId) => prevId !== id); - setActiveHint({ isOpen: false, type: "put" }); - setIsModifyEnableds(enableds); - }; - - const handleCreateHint = useCallback(() => { - if (activeHint.isOpen) { - setDialogOpen(true); - } else { - setIsMakeEnabled(true); - } - }, [activeHint]); - - const handleModify = (id: number) => { - if (getOpenedModify(id)) { - setActiveHint({ isOpen: false, type: "put" }); - closeModify(id); - } else { - setIsModifyEnableds((prev) => [...prev, id]); - setActiveHint({ isOpen: true, type: "put" }); - } - }; - - const $AddHintButton = useMemo(() => { - if (hintsLength > 0 || isMakeEnabled) { - return null; - } - - return ( - setIsMakeEnabled(true)}> - 새로운 힌트 추가하기 - 새로운 힌트 추가하기 - - ); - }, [hintsLength, isMakeEnabled]); - - const $AddHintFloatingButton = useMemo( - () => ( - 0 && !isMakeEnabled} - > - - - - 새로운 힌트 추가하기 - - ), - [handleCreateHint, hintsLength, isMakeEnabled] - ); - - if (isLoading) { - return ; - } - - return ( - - -
힌트코드
-
진행률
-
힌트 내용
-
정답
-
- {$AddHintButton} - {$AddHintFloatingButton} - setIsMakeEnabled(false)} - type="make" - /> - {[...hints] - .reverse() - .map(({ id, hintCode, contents, answer, progress }) => ( -
- handleModify(id)} - /> - closeModify(id)} - type="modify" - hintData={{ hintCode, contents, answer, progress }} - /> -
- ))} - - { - setIsMakeEnabled(true); - setIsModifyEnableds([]); - setActiveHint({ isOpen: false, type: "put" }); - }} - open={dialogOpen} - handleDialogClose={() => setDialogOpen(false)} - type={activeHint.type === "post" ? "hintPost" : "hintPut"} - /> -
- ); -} - -export default HintList; diff --git a/app/components/HintManager/HintManager.tsx b/app/components/HintManager/HintManager.tsx deleted file mode 100644 index c78745f8..00000000 --- a/app/components/HintManager/HintManager.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import _ from "lodash"; -import React, { useEffect, useRef, useState } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; - -import { usePutHint } from "@/mutations/putHint"; -import { usePostHint } from "@/mutations/postHint"; - -import { useSelectedThemeValue } from "../atoms/selectedTheme.atom"; -import { - useIsActiveHintItemState, - useIsOpenDeleteDialogStateWrite, -} from "../atoms/hints.atom"; -import Dialog from "../common/Dialog/Dialog"; - -import HintManagerView from "./HintManagerView"; - -const MAKE = "make"; - -type Props = { - active: boolean; - close: () => void; - type: "make" | "modify"; - id?: number; - hintData?: FormValues; - // dialogOpen: () => void; -}; - -interface FormValues { - progress: number; - hintCode: string; - contents: string; - answer: string; -} - -function HintManager(props: Props) { - const { id, active, close, type, hintData } = props; - const [open, setOpen] = useState(false); - - const [submitDisable, setSubmitDisable] = useState(false); - const { - register, - handleSubmit, - reset, - setValue, - watch, - formState: { errors }, - } = useForm(); - - const { mutateAsync: postHint } = usePostHint(); - const { mutateAsync: putHint } = usePutHint(); - const { id: themeId } = useSelectedThemeValue(); - const formRef = useRef(null); - - const setIsOpenDeleteDialogState = useIsOpenDeleteDialogStateWrite(); - - const [errorMsg, setErrorMsg] = useState(""); - const [isActiveHintItemState, setIsActiveHintItemState] = - useIsActiveHintItemState(); - - useEffect(() => { - if (!hintData) return; - const { progress, hintCode, contents, answer } = hintData; - - const previousValues: FormValues = { hintCode, contents, answer, progress }; - const names = Object.keys(previousValues) as (keyof FormValues)[]; - - names.forEach((name) => { - const value = previousValues[name]; - if (value) { - setValue(name, value); - } - }); - }, [hintData, setValue]); - - useEffect(() => { - if (!hintData) return; - const { progress, hintCode, contents, answer } = hintData; - - const subscription = watch((value) => { - const { - progress: inputProgress = "", - hintCode: inputHintCode = "", - contents: inputContents = "", - answer: inputAnswer = "", - } = value; - if ( - progress !== inputProgress || - hintCode !== inputHintCode || - contents !== inputContents || - answer !== inputAnswer - ) { - // setSubmitDisable(false); - } else { - // setSubmitDisable(true); - } - }); - - return () => subscription.unsubscribe(); - }, [hintData, watch]); - - useEffect(() => { - if (!open) { - setErrorMsg(""); - } - }, [open, reset]); - - const formValue = watch(); - useEffect(() => { - if ( - !formValue.progress || - !(formValue.hintCode.length === 4) || - !formValue.contents.trim() || - !formValue.answer.trim() - ) { - setSubmitDisable(true); - } else { - setSubmitDisable(false); - } - }, [formValue]); - - const openDeleteDialog = () => { - if (!id) return; - setIsOpenDeleteDialogState({ isOpen: true, id }); - }; - - const key = `${type}-${id}`; - - const onSubmit: SubmitHandler = _.debounce((data: any) => { - const { progress, hintCode, contents, answer } = data; - - if (!(progress && hintCode && contents && answer)) { - // TODO: add error message - - console.error("please check code"); - return; - } - - if (type === MAKE) { - postHint({ - progress: Number(progress), - hintCode, - contents, - answer, - themeId, - }); - } else { - putHint({ - progress: Number(progress), - hintCode, - contents, - answer, - id: Number(id), - }); - } - reset(); - close(); - }, 300); - - const isCurrentHintActive = isActiveHintItemState === id; - - const activateForm = (event: React.MouseEvent) => { - event.stopPropagation(); - if (!id) return; - - setIsActiveHintItemState(id); - }; - - const stopEvent = (event: React.MouseEvent) => { - event.stopPropagation(); - }; - - const cancelInput = (event: React.MouseEvent) => { - event.stopPropagation(); - - if ( - type !== MAKE && - hintData && - hintData.progress === formValue.progress && - hintData.hintCode === formValue.hintCode && - hintData.contents === formValue.contents && - hintData.answer === formValue.answer - ) { - close(); - } else { - setOpen(true); - } - }; - - const deactivateForm = (event: MouseEvent) => { - const isOutsideForm = - formRef.current && !formRef.current.contains(event.target as Node); - - if (isOutsideForm && isCurrentHintActive) { - setIsActiveHintItemState(0); - } - }; - - useEffect(() => { - document.addEventListener("click", deactivateForm); - - return () => { - document.removeEventListener("click", deactivateForm); - }; - }, []); - - const formProps = { - active, - key, - component: "form", - noValidate: true, - autoComplete: "off", - onSubmit: handleSubmit(onSubmit), - onClick: activateForm, - ref: formRef, - }; - - const progressInputProps = { - placeholder: hintData?.progress || "진행률", - type: "number", - helperText: errors?.progress && errors?.progress.message, - error: Boolean(errors?.progress), - onClick: activateForm, - ...register("progress", { - pattern: { - value: /^(100|[1-9][0-9]?|0)$/, - message: "1부터 100까지의 정수만 입력 가능합니다.", - }, - }), - onBlur: (e: React.FocusEvent) => { - if (!/^(100|[1-9][0-9]?|0)$/.test(e.target.value)) { - setErrorMsg("1부터 100까지의 정수만 입력 가능합니다."); - } else { - setErrorMsg(""); - } - }, - endAdornment: <>%, - }; - - const hintCodeInputProps = { - placeholder: hintData?.hintCode || "힌트코드", - helperText: errors?.hintCode && errors?.hintCode.message, - type: "number", - onClick: activateForm, - - ...register("hintCode", { - pattern: { - value: /^\d{4}$/, - message: "4자리 숫자만 입력 가능합니다.", - }, - onBlur: (e: React.FocusEvent) => { - if (!/^\d{4}$/.test(e.target.value)) { - setErrorMsg("힌트 코드는 4자리 숫자만 입력 가능합니다."); - } else { - setErrorMsg(""); - } - }, - }), - }; - - const contentsInputProps = { - placeholder: hintData?.contents || "힌트내용", - multiline: true, - onClick: activateForm, - rows: 5, - ...register("contents"), - }; - - const answerInputProps = { - placeholder: hintData?.answer || "정답", - multiline: true, - onClick: activateForm, - rows: 5, - ...register("answer"), - }; - - const deleteButtonProps = { - variant: "text", - onClick: (event: React.MouseEvent) => { - event.stopPropagation(); - if (type === MAKE) { - close(); - } else { - openDeleteDialog(); - } - }, - }; - - const makeHintButtonProps = { - type: "submit", - variant: "contained", - disabled: submitDisable, - onClick: stopEvent, - }; - - const wrapperProps = { - onClick: cancelInput, - }; - - const makeHintProps = { - answerInputProps, - contentsInputProps, - progressInputProps, - hintCodeInputProps, - formProps, - deleteButtonProps, - makeHintButtonProps, - isCurrentHintActive, - wrapperProps, - errorMsg, - }; - - return ( - <> - - setOpen(false)} - type={type === MAKE ? "hintPost" : "hintPut"} - handleBtn={close} - /> - - ); -} - -export default HintManager; diff --git a/app/components/HintManager/HintManagerView.styled.ts b/app/components/HintManager/HintManagerView.styled.ts deleted file mode 100644 index ddb23f98..00000000 --- a/app/components/HintManager/HintManagerView.styled.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Box } from "@mui/material"; -import { styled } from "styled-components"; - -export const SummaryText = styled.div` - display: flex; - width: 100%; - max-width: 600px; - align-items: center; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding: 2px; -`; - -export const StyledBox = styled(Box)<{ active?: boolean }>` - width: 100%; - max-height: ${(props) => (props.active ? "230px" : "0")}; - overflow: hidden; - transition: max-height 0.3s ease-in-out; -`; - -export const Wrapper = styled.div<{ selected?: boolean }>` - width: 100%; - padding: 8px; - position: relative; - box-sizing: border-box; - background-color: ${({ theme }) => theme.color.white10}; - - ${({ selected }) => - selected && - ` - border: 1px solid #B5E6D2; - border-radius: 8px; - `} -`; - -export const InputsWrapper = styled.div` - display: flex; - - width: 100%; - height: 173px; - gap: 8px; - - .inputBox { - width: 96px; - height: 36px; - background-color: #ffffff14; - color: #fff; - & input[type="number"]::-webkit-inner-spin-button, - & input[type="number"]::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; - } - - & input[type="number"] { - -moz-appearance: textfield; /* Firefox에서 화살표를 숨기기 위한 설정 */ - } - } - - .TextareaBox { - width: 448px; - height: 128px; - background-color: #ffffff14; - color: #fff; - align-items: unset; - } -`; - -export const FunctionButtonsWrapper = styled.div` - display: flex; - padding-right: 10px; - justify-content: end; - align-items: end; - gap: 8px; -`; - -export const ErrorMsgWrapper = styled.div` - position: absolute; - color: red; - margin: 0; - padding: 0; - font-size: 12px; - text-align: right; - left: 15px; -`; diff --git a/app/components/HintManager/HintManagerView.tsx b/app/components/HintManager/HintManagerView.tsx deleted file mode 100644 index 8f333bf2..00000000 --- a/app/components/HintManager/HintManagerView.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; -import { Button, Input } from "@mui/material"; - -import * as S from "./HintManagerView.styled"; - -interface Props { - errorMsg: string; - progressInputProps: Record; - hintCodeInputProps: Record; - contentsInputProps: Record; - answerInputProps: Record; - deleteButtonProps: Record; - formProps: Record; - makeHintButtonProps: Record; - isCurrentHintActive: boolean; - wrapperProps: { onClick: (event: React.MouseEvent) => void }; -} - -const DELETE = "삭제하기"; -const MAKE_HINT = "저장하기"; - -function HintManagerView(props: Props) { - const { - progressInputProps, - hintCodeInputProps, - contentsInputProps, - answerInputProps, - deleteButtonProps, - makeHintButtonProps, - formProps, - isCurrentHintActive, - wrapperProps, - errorMsg, - } = props; - - return ( - - - - - - - - - {errorMsg} - - - - - - - ); -} - -export default HintManagerView; diff --git a/app/components/MakeThemePage/MakeThemePage.tsx b/app/components/MakeThemePage/MakeThemePage.tsx deleted file mode 100644 index f570d90f..00000000 --- a/app/components/MakeThemePage/MakeThemePage.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; -import { useRouter } from "next/navigation"; - -import { usePostTheme } from "@/mutations/postTheme"; -import { usePutTheme } from "@/mutations/putTheme"; -import { useSelectedTheme } from "@/components/atoms/selectedTheme.atom"; -import { useModalState } from "@/components/atoms/modalState.atom"; -import { useGetThemeList } from "@/queries/getThemeList"; -import useChannelTalk from "@/hooks/useChannelTalk"; - -import Dialog from "../common/Dialog/Dialog"; - -import MakeThemeModalView from "./MakeThemePageView"; - -interface FormValues { - id: number | undefined; - title: string; - timeLimit: number; - hintLimit: number; -} - -function MakeThemePage() { - const [modalState, setModalState] = useModalState(); - const { data: categories = [] } = useGetThemeList(); - const [open, setOpen] = useState(false); - useChannelTalk(); - - const [selectedTheme, setSelectedTheme] = useSelectedTheme(); - const router = useRouter(); - - const { - register, - handleSubmit, - setValue, - watch, - reset, - formState: { errors }, - trigger, - } = useForm(); - - const formValue = watch(); - const handleClose = () => { - setSelectedTheme(categories[categories.length - 1]); - if ( - modalState.type === "post" && - !(formValue.title || formValue.timeLimit || formValue.hintLimit) - ) { - setModalState({ ...modalState, isOpen: false }); - } else { - setOpen(!open); - } - }; - useEffect(() => { - reset(); - if (modalState.type === "put") { - setValue("title", selectedTheme.title); - setValue("timeLimit", selectedTheme.timeLimit); - setValue("hintLimit", selectedTheme.hintLimit); - } - }, [selectedTheme, setValue, modalState.type, reset]); - - useEffect(() => { - if (formValue.title && formValue.timeLimit && formValue.hintLimit) { - trigger(); - } - }, [formValue.hintLimit, formValue.timeLimit, formValue.title, trigger]); - - const { mutateAsync: postTheme } = usePostTheme(); - const { mutateAsync: putTheme } = usePutTheme(); - - const onSubmit: SubmitHandler = (data) => { - const submitData = { - id: selectedTheme.id, - title: data.title, - timeLimit: data.timeLimit, - hintLimit: data.hintLimit, - }; - - if (modalState.type === "put") { - putTheme(submitData); - setModalState({ ...modalState, isOpen: false }); - router.push(`/admin?themeId=${encodeURIComponent(selectedTheme.id)}`); - } else { - postTheme(data); - setModalState({ ...modalState, isOpen: false }); - if (data.id) { - router.push(`/admin?themeId=${encodeURIComponent(data.id)}`); - } - } - }; - - const formProps = { - component: "form", - noValidate: true, - autoComplete: "off", - onSubmit: handleSubmit(onSubmit), - }; - - const themeNameProps = { - id: "title", - label: "테마 이름", - placeholder: "입력해 주세요.", - message: "손님에게는 보이지 않아요.", - ...register("title", { - required: "테마 이름은 필수값입니다", - pattern: { - value: /^.+$/, - message: "테마 이름은 필수값입니다", - }, - }), - }; - const timeLimitProps = { - id: "timeLimit", - label: "시간", - placeholder: "선택하기", - type: "number", - message: "손님이 사용할 타이머에 표시됩니다. (분 단위로 입력해 주세요.)", - ...register("timeLimit", { - required: "시간을 입력해주세요.", - pattern: { - value: /^[1-9]\d*$/, - message: "1분 이상으로 입력해 주세요.", - }, - }), - }; - - const hintLimitProps = { - id: "hintLimit", - label: "힌트 개수", - type: "number", - message: "이 테마에서 사용할 수 있는 힌트 수를 입력해 주세요.", - ...register("hintLimit", { - required: "힌트 수를 입력해 주세요.", - pattern: { - value: /^[1-9]\d*$/, - message: "1개 이상으로 입력해 주세요.", - }, - }), - }; - - const MakeThemeModalViewProps = { - handleClose, - formValue, - selectedTheme, - modalState, - formProps, - themeNameProps, - timeLimitProps, - hintLimitProps, - titleError: errors.title, - timeLimitError: errors.timeLimit, - hintLimitError: errors.hintLimit, - }; - - return ( - <> - - - router.push(`/admin?themeId=${encodeURIComponent(selectedTheme.id)}`) - } - open={open} - handleDialogClose={() => setOpen(false)} - type={modalState.type === "post" ? "themePost" : "themePut"} - /> - - ); -} - -export default MakeThemePage; diff --git a/app/components/MakeThemePage/MakeThemePageView.styled.ts b/app/components/MakeThemePage/MakeThemePageView.styled.ts deleted file mode 100644 index 5bd05324..00000000 --- a/app/components/MakeThemePage/MakeThemePageView.styled.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Button, Grid, Card, TextField } from "@mui/material"; -import { styled } from "styled-components"; - -export const CardWrap = styled(Card)` - width: 512px; - height: 390px; - display: flex; - flex-direction: column; - padding: 20px; - border-radius: 8px; - background-color: rgba(255, 255, 255, 0.2); -`; - -export const ErrorMention = styled.p` - font-size: ${(props) => props.theme.fontSize.sm}; - font-weight: ${(props) => props.theme.fontWeight.light}; - margin: 4px 16px 20px; - color: #f2b8b5; -`; - -export const Description = styled.p` - font-size: ${(props) => props.theme.fontSize.sm}; - font-weight: ${(props) => props.theme.fontWeight.light}; - margin: 4px 16px 20px; - line-height: 20px; - color: rgba(255, 255, 255, 1); -`; - -export const TextWrapper = styled.div` - display: flex; - flex-direction: column; - margin-bottom: 20px; -`; - -export const ButtonContainer = styled(Grid)` - display: flex; - justify-content: flex-end; -`; - -export const CancleButton = styled(Button)``; - -export const SubmitButton = styled(Button)` - width: 97px; -`; - -export const StyledNumberInput = styled(TextField)` - & input[type="number"]::-webkit-inner-spin-button, - & input[type="number"]::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; - } - - & input[type="number"] { - -moz-appearance: textfield; /* Firefox에서 화살표를 숨기기 위한 설정 */ - } -`; diff --git a/app/components/MakeThemePage/MakeThemePageView.tsx b/app/components/MakeThemePage/MakeThemePageView.tsx deleted file mode 100644 index ccdc91e4..00000000 --- a/app/components/MakeThemePage/MakeThemePageView.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Grid } from "@mui/material"; -import { FieldError } from "react-hook-form"; - -import * as S from "./MakeThemePageView.styled"; - -interface Modal { - isOpen: boolean; - type: "post" | "put"; -} -interface FormValues { - id: number | undefined; - title: string; - timeLimit: number; - hintLimit: number; -} - -type Props = { - handleClose: () => void; - formValue: FormValues; - modalState: Modal; - formProps: Record; - timeLimitProps: Record; - themeNameProps: Record; - hintLimitProps: Record; - titleError: FieldError | undefined; - timeLimitError: FieldError | undefined; - hintLimitError: FieldError | undefined; -}; - -function MakeThemePageView(props: Props) { - const ADD_BTN = "추가하기"; - const MODIFY_BTN = "수정하기"; - const { - handleClose, - formValue, - modalState, - formProps, - themeNameProps, - timeLimitProps, - hintLimitProps, - titleError, - timeLimitError, - hintLimitError, - } = props; - - return ( -
- - - - {titleError ? ( - - {titleError.message} - - ) : ( - {themeNameProps.message} - )} - - {timeLimitError ? ( - - {timeLimitError.message} - - ) : ( - {timeLimitProps.message} - )} - - {hintLimitError ? ( - - {hintLimitError.message} - - ) : ( - {hintLimitProps.message} - )} - - - - - 취소 - - - - - {modalState.type === "post" ? ADD_BTN : MODIFY_BTN} - - - - -
- ); -} - -export default MakeThemePageView; diff --git a/app/components/RequireAuth/RequireAuth.tsx b/app/components/RequireAuth/RequireAuth.tsx index aeec501b..36c10b69 100644 --- a/app/components/RequireAuth/RequireAuth.tsx +++ b/app/components/RequireAuth/RequireAuth.tsx @@ -3,88 +3,41 @@ import React, { ReactNode, useEffect, useMemo, useState } from "react"; import { useRouter, usePathname } from "next/navigation"; -import { useTokenRefresh } from "@/mutations/useRefresh"; -import { useGetThemeList } from "@/queries/getThemeList"; -import { - useCurrentTheme, - useCurrentThemeReset, -} from "@/components/atoms/currentTheme.atom"; -import { useSelectedThemeReset } from "@/components/atoms/selectedTheme.atom"; import { useIsLoggedIn } from "@/components/atoms/account.atom"; -import { getSelectedThemeId } from "@/utils/localStorage"; -import * as S from "@/home/HomeView.styled"; -import Header from "@/components/common/Header/Header"; -import MainDrawer from "@/components/common/Drawer/Drawer"; import Mobile from "../Mobile/Mobile"; interface RequireAuthProps { children: ReactNode; } -function RequireAuth({ - children, -}: RequireAuthProps): React.ReactElement | null { +function RequireAuth({ children }: RequireAuthProps) { const [isLoggedIn, setIsLoggedIn] = useIsLoggedIn(); - const [currentTheme, setCurrentTheme] = useCurrentTheme(); - const resetCurrentTheme = useCurrentThemeReset(); - const resetSelectedTheme = useSelectedThemeReset(); + const router = useRouter(); const pathname = usePathname(); - const allowUnauthPaths = useMemo(() => ["/", "/trial", "/signup"], []); + const allowUnauthPaths = useMemo(() => ["/", "/signup"], []); const [isMobile, setIsMobile] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const { data: categories = [] } = useGetThemeList(); - const { refreshToken, error } = useTokenRefresh(); + useEffect(() => { if (typeof window !== "undefined") { const { userAgent } = window.navigator; const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i; setIsMobile(mobileRegex.test(userAgent)); - setIsLoading(false); } }, []); useEffect(() => { - if (categories.length > 0) { - setCurrentTheme(categories.map(({ id, title }) => ({ id, title }))); - } else { - resetCurrentTheme(); - resetSelectedTheme(); - } - }, [categories, setCurrentTheme]); - useEffect(() => { - const selectedThemeId = getSelectedThemeId(); - if (!isLoggedIn && !allowUnauthPaths.includes(pathname)) { router.push("/login"); - } else if (isLoggedIn && pathname === "/") { + } else if (isLoggedIn) { router.push(pathname); - } else if (isLoggedIn && currentTheme.length === 0) { - router.push("/admin"); - } else if (selectedThemeId !== "0" && isLoggedIn) { - router.push(`/admin?themeId=${selectedThemeId}`); } - }, [isLoggedIn, currentTheme, router, allowUnauthPaths, pathname]); - - if (isLoading) { - return <>; - } + }, [isLoggedIn, pathname]); if (isMobile && !allowUnauthPaths.includes(pathname)) return ; - if (!isLoggedIn) return <>{children}; - if (isLoggedIn && (pathname === "/" || "/admin")) return <>{children}; - - return ( - - - -
- {children} - - - ); + return <>{children}; } export default RequireAuth; diff --git a/app/components/ThemeDetail/ThemeDetail.styled.ts b/app/components/ThemeDetail/ThemeDetail.styled.ts deleted file mode 100644 index 9dd7a002..00000000 --- a/app/components/ThemeDetail/ThemeDetail.styled.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { styled } from "styled-components"; -import { Box, Button } from "@mui/material"; - -export const Wrapper = styled(Box)` - display: flex; - margin: 0 auto; - flex-direction: column; - - flex: 1; /* 남은 공간을 차지 */ -`; - -export const Title = styled.div` - width: 100%; - font-size: ${(props) => props.theme.fontSize.lg}; - font-weight: ${(props) => props.theme.fontWeight.bold}; - white-space: pre-wrap; - word-break: break-word; -`; - -export const MiddleTitle = styled.div` - font-size: ${(props) => props.theme.fontSize.sm}; - opacity: 0.7; -`; - -export const UpdateButton = styled(Button)` - width: 147px; - height: 40px; - font-size: ${(props) => props.theme.fontSize.sm}; -`; diff --git a/app/components/ThemeDetail/ThemeDetail.tsx b/app/components/ThemeDetail/ThemeDetail.tsx deleted file mode 100644 index da7a693b..00000000 --- a/app/components/ThemeDetail/ThemeDetail.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { useState } from "react"; - -import { useSelectedThemeValue } from "../atoms/selectedTheme.atom"; -import DeleteDialog from "../common/DeleteDialog/DeleteDialog"; -import Dialog from "../common/Dialog/Dialog"; -import { useActiveHintState } from "../atoms/activeHint.atom"; -import { useModalStateWrite } from "../atoms/modalState.atom"; - -import ThemeDetailView from "./ThemeDetailView"; - -function ThemeDetail() { - const selectedTheme = useSelectedThemeValue(); - const [open, setOpen] = useState(false); - const [activeHint, setActiveHint] = useActiveHintState(); - const [dialogOpen, setDialogOpen] = useState(false); - const setModalState = useModalStateWrite(); - - return ( - <> - setOpen(true)} - handleDialogOpen={() => setDialogOpen(true)} - /> - setOpen(false)} - id={selectedTheme.id} - type="themeDelete" - /> - { - setModalState({ isOpen: true, type: "put" }); - setActiveHint({ isOpen: false, type: "put" }); - }} - open={dialogOpen} - handleDialogClose={() => setDialogOpen(false)} - type={activeHint.type === "post" ? "hintPost" : "hintPut"} - /> - - ); -} - -export default ThemeDetail; diff --git a/app/components/ThemeDetail/ThemeDetailView.tsx b/app/components/ThemeDetail/ThemeDetailView.tsx deleted file mode 100644 index 68f3071f..00000000 --- a/app/components/ThemeDetail/ThemeDetailView.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useState } from "react"; -import { Stack, Grid, IconButton, Menu, MenuItem } from "@mui/material"; -import MoreVertIcon from "@mui/icons-material/MoreVert"; -import EditIcon from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { useRouter } from "next/navigation"; - -import { useActiveHintStateValue } from "@/components/atoms/activeHint.atom"; -import { useModalStateWrite } from "@/components/atoms/modalState.atom"; -import { useSelectedThemeValue } from "@/components/atoms/selectedTheme.atom"; - -import HintList from "../HintList/HintList"; - -import * as S from "./ThemeDetail.styled"; - -type Props = { - handleOpen: () => void; - handleDialogOpen: () => void; -}; - -function ThemeDetailView(props: Props) { - const { handleOpen, handleDialogOpen } = props; - const selectedTheme = useSelectedThemeValue(); - const setModalState = useModalStateWrite(); - const activeHint = useActiveHintStateValue(); - const router = useRouter(); - const [menuState, setMenuState] = useState(false); - const toggleOnModalState = () => { - if (activeHint.isOpen) { - handleDialogOpen(); - } else { - router.push("/theme"); - setModalState({ isOpen: true, type: "put" }); - } - }; - - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - - const openMenu = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - const closeMenu = () => { - setAnchorEl(null); - setMenuState(!menuState); - }; - - const handleMenu = () => { - handleOpen(); - closeMenu(); - }; - - return ( - - - {selectedTheme.title} - - - 탈출 제한 시간 - {selectedTheme.timeLimit}분 - - 사용 가능 힌트 - {selectedTheme.hintLimit}개 - - - - } - > - 테마 정보 수정 - - - - - - - - - - 테마 삭제 - - - - - - - - ); -} - -export default ThemeDetailView; diff --git a/app/components/atoms/selectedTheme.atom.ts b/app/components/atoms/selectedTheme.atom.ts index 118ed5c2..81206a97 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 00000000..05a1bd56 --- /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/Hint-Dialog-new/Dialog.tsx b/app/components/common/Dialog-new/Hint-Dialog-new/Dialog.tsx similarity index 94% rename from app/components/common/Hint-Dialog-new/Dialog.tsx rename to app/components/common/Dialog-new/Hint-Dialog-new/Dialog.tsx index 47ba0837..c1c45be8 100644 --- a/app/components/common/Hint-Dialog-new/Dialog.tsx +++ b/app/components/common/Dialog-new/Hint-Dialog-new/Dialog.tsx @@ -5,13 +5,12 @@ import Image from "next/image"; import useClickOutside from "@/hooks/useClickOutside"; import { xProps } from "@/admin/(consts)/sidebar"; import useModal from "@/hooks/useModal"; -import DialogBody from "@/components/common/Hint-Dialog-new/DialogBody"; +import DialogBody from "@/components/common/Dialog-new/Hint-Dialog-new/DialogBody"; import "@/components/common/Dialog-new/dialog.sass"; import { useDeleteHint } from "@/mutations/deleteHint"; import { useSelectedHint } from "@/components/atoms/selectedHint.atom"; import { useDrawerState } from "@/components/atoms/drawer.atom"; - -import ModalPortal from "./ModalPortal"; +import ModalPortal from "@/components/common/Dialog-new/ModalPortal"; interface DialogProps { type?: string | ""; @@ -91,8 +90,4 @@ const Dialog = forwardRef((props) => { ); }); -Dialog.defaultProps = { - type: "", -}; - export default Dialog; diff --git a/app/components/common/Hint-Dialog-new/DialogBody.tsx b/app/components/common/Dialog-new/Hint-Dialog-new/DialogBody.tsx similarity index 100% rename from app/components/common/Hint-Dialog-new/DialogBody.tsx rename to app/components/common/Dialog-new/Hint-Dialog-new/DialogBody.tsx diff --git a/app/components/common/Dialog-new/Image-Dialog-new/Dialog.tsx b/app/components/common/Dialog-new/Image-Dialog-new/Dialog.tsx new file mode 100644 index 00000000..68294511 --- /dev/null +++ b/app/components/common/Dialog-new/Image-Dialog-new/Dialog.tsx @@ -0,0 +1,84 @@ +import React, { FormEvent, forwardRef, useRef } from "react"; +import Image from "next/image"; + +import { useSelectedTheme } from "@/components/atoms/selectedTheme.atom"; +import useClickOutside from "@/hooks/useClickOutside"; +import useModal from "@/hooks/useModal"; +import ModalPortal from "@/components/common/Dialog-new/ModalPortal"; +import "@/components/common/Dialog-new/dialog.sass"; +import useTimerImageUpload from "@/mutations/useTimerImageUpload"; +import { useTimerImageValue } from "@/components/atoms/timerImage.atom"; +import { XImageProps } from "@/admin/(components)/ThemeDrawer/consts/themeDrawerProps"; + +import DialogBody from "./DialogBody"; + +const Dialog = forwardRef(() => { + const { close } = useModal(); + const formRef = useRef(null); + + const [selectedTheme, setSelectedTheme] = useSelectedTheme(); + const { timerImage } = useTimerImageValue(); + const { handleProcess } = useTimerImageUpload(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const { id } = selectedTheme; + + const submitData = { + themeId: id, + timerImageFile: timerImage, + }; + try { + const imageUrl = await handleProcess(submitData); + setSelectedTheme((prev) => ({ + ...prev, + useTimerUrl: true, + themeImageUrl: imageUrl, + })); + } catch (error) { + console.error(error); + } + + close(); + }; + + useClickOutside(formRef, close); + + return ( + +
e.stopPropagation()} + > +
+

타이머 배경 올리기

+ +
+ +
+

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

+
+ + +
+
+ +
+ ); +}); + +export default Dialog; diff --git a/app/components/common/Dialog-new/Image-Dialog-new/DialogBody.tsx b/app/components/common/Dialog-new/Image-Dialog-new/DialogBody.tsx new file mode 100644 index 00000000..5ef7ad90 --- /dev/null +++ b/app/components/common/Dialog-new/Image-Dialog-new/DialogBody.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import Image from "next/image"; + +import { useTimerImageValue } from "@/components/atoms/timerImage.atom"; +import { + defaultTimerImage, + timerPreviewLineProps, +} from "@/admin/(consts)/sidebar"; + +export default function DialogBody() { + const { timerImage } = useTimerImageValue(); + const url = URL.createObjectURL(timerImage!); + const uploadImageProps = { + src: url || defaultTimerImage || "", + alt: "TIMER_IMAGE", + width: 158, + height: 340, + }; + + return ( +
+ 배경 적용 미리보기 +
+
+ + +
+

+ *예시 이미지입니다. 앱에서 세부 설정을 진행해 주세요. +

+
+ ); +} diff --git a/app/components/common/Dialog-new/Noti-Dialog-new/Dialog.tsx b/app/components/common/Dialog-new/Noti-Dialog-new/Dialog.tsx new file mode 100644 index 00000000..b0054d6a --- /dev/null +++ b/app/components/common/Dialog-new/Noti-Dialog-new/Dialog.tsx @@ -0,0 +1,67 @@ +import React, { forwardRef, useRef } from "react"; + +import useClickOutside from "@/hooks/useClickOutside"; +import useModal from "@/hooks/useModal"; +import ModalPortal from "@/components/common/Dialog-new/ModalPortal"; +import { setLocalStorage } from "@/utils/storageUtil"; +import { timerImageLinkURL } from "@/admin/(consts)/sidebar"; + +import DialogBody from "./DialogBody"; + +import "@/components/common/Dialog-new/dialog.sass"; + +interface DialogProps { + type?: string | ""; +} + +const Dialog = forwardRef((props) => { + const { close } = useModal(); + const checkboxRef = useRef(null); + const { type = "" } = props; + const formRef = useRef(null); + const handleViewDetailBtn = () => { + window.open(timerImageLinkURL, "_blank", "noopener, noreferrer"); + }; + const handleCloseBtn = () => { + if (checkboxRef.current?.checked) { + setLocalStorage("hideDialog", "true"); + } + close(); + }; + + useClickOutside(formRef, close); + + return ( + +
e.stopPropagation()} + > +
+

새로운 기능을 소개합니다✨

+
+ +
+
+ + +
+
+ + +
+
+ +
+ ); +}); + +export default Dialog; diff --git a/app/components/common/Dialog-new/Noti-Dialog-new/DialogBody.tsx b/app/components/common/Dialog-new/Noti-Dialog-new/DialogBody.tsx new file mode 100644 index 00000000..374df419 --- /dev/null +++ b/app/components/common/Dialog-new/Noti-Dialog-new/DialogBody.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import Image from "next/image"; + +import { notiImageProps } from "@/admin/(consts)/sidebar"; + +export default function DialogBody() { + const previewProps = { + src: "/images/svg/preview.svg", + alt: "NEXT ROOM", + width: 158, + height: 340, + }; + + return ( +
+ +
+
+ 타이머 배경 +
+

+ 타이머에 원하는 배경을 넣어보세요 +

+

+ 기본 배경 대신 방탈출 테마 포스터를 등록하여 타이머 배경을 커스텀 할 + 수 있습니다. +
각 테마의 독특한 분위기를 더욱 살리고, 플레이어들에게 몰입감을 + 제공해보세요. +

+
+
+ ); +} diff --git a/app/components/common/Dialog-new/Preview-Dialog-new/PreviewDialog.tsx b/app/components/common/Dialog-new/Preview-Dialog-new/PreviewDialog.tsx new file mode 100644 index 00000000..377eb4cf --- /dev/null +++ b/app/components/common/Dialog-new/Preview-Dialog-new/PreviewDialog.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef, useRef } from "react"; +import Image from "next/image"; + +import useClickOutside from "@/hooks/useClickOutside"; +import useModal from "@/hooks/useModal"; +import ModalPortal from "@/components/common/Dialog-new/ModalPortal"; +import "@/components/common/Dialog-new/dialog.sass"; +import { smallXProps, timerPreviewLineProps } from "@/admin/(consts)/sidebar"; +import { useSelectedThemeValue } from "@/components/atoms/selectedTheme.atom"; + +const PreviewDialog = forwardRef(() => { + const selectedTheme = useSelectedThemeValue(); + const { close } = useModal(); + + const timerPreviewProps = { + src: selectedTheme.themeImageUrl!, + alt: "TIMER_PREVIEW_IMAGE", + width: 315, + height: 682, + }; + + const divRef = useRef(null); + + useClickOutside(divRef, close); + + return ( + +
+

+ *예시 이미지입니다. 앱에서 세부 설정을 진행해 주세요. +

+
+
+
+ + +
+ +
+
+ + ); +}); + +export default PreviewDialog; diff --git a/app/components/common/Dialog-new/Dialog.tsx b/app/components/common/Dialog-new/Theme-Dialog/Dialog.tsx similarity index 85% rename from app/components/common/Dialog-new/Dialog.tsx rename to app/components/common/Dialog-new/Theme-Dialog/Dialog.tsx index 681527da..8823aa11 100644 --- a/app/components/common/Dialog-new/Dialog.tsx +++ b/app/components/common/Dialog-new/Theme-Dialog/Dialog.tsx @@ -1,6 +1,7 @@ import React, { forwardRef, useRef } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import Image from "next/image"; +import { useRouter } from "next/navigation"; import { usePutTheme } from "@/mutations/putTheme"; import { useDeleteTheme } from "@/mutations/deleteTheme"; @@ -15,9 +16,9 @@ import { import useClickOutside from "@/hooks/useClickOutside"; import { deleteProps, xProps } from "@/admin/(consts)/sidebar"; import useModal from "@/hooks/useModal"; -import DialogDeleteBody from "@/components/common/Dialog-new/DialogDeleteBody"; +import DialogDeleteBody from "@/components/common/Dialog-new/Theme-Dialog/DialogDeleteBody"; +import ModalPortal from "@/components/common/Dialog-new/ModalPortal"; -import ModalPortal from "./ModalPortal"; import DialogBody from "./DialogBody"; import "@/components/common/Dialog-new/dialog.sass"; @@ -58,8 +59,8 @@ const Dialog = forwardRef((props) => { const { mutateAsync: putTheme } = usePutTheme(); const { mutateAsync: deleteTheme } = useDeleteTheme(); - - const onSubmit: SubmitHandler = () => { + const router = useRouter(); + const onSubmit: SubmitHandler = async () => { const { id } = selectedTheme; const submitData = { @@ -68,12 +69,21 @@ const Dialog = forwardRef((props) => { }; if (type === "put") { - putTheme(submitData); - setSelectedTheme(submitData); + try { + await putTheme(submitData); + setSelectedTheme(submitData); + } catch (error) { + console.error("Error updating theme:", error); + } } else if (type === "delete") { - deleteTheme({ id }); - resetSelectedTheme(); + try { + await deleteTheme({ id }); + router.push(`/admin`); + } catch (error) { + console.error("Error deleting theme:", error); + } } + close(); resetCreateTheme(); @@ -124,8 +134,4 @@ const Dialog = forwardRef((props) => { ); }); -Dialog.defaultProps = { - type: "", -}; - export default Dialog; diff --git a/app/components/common/Dialog-new/DialogBody.tsx b/app/components/common/Dialog-new/Theme-Dialog/DialogBody.tsx similarity index 100% rename from app/components/common/Dialog-new/DialogBody.tsx rename to app/components/common/Dialog-new/Theme-Dialog/DialogBody.tsx diff --git a/app/components/common/Dialog-new/DialogDeleteBody.tsx b/app/components/common/Dialog-new/Theme-Dialog/DialogDeleteBody.tsx similarity index 100% rename from app/components/common/Dialog-new/DialogDeleteBody.tsx rename to app/components/common/Dialog-new/Theme-Dialog/DialogDeleteBody.tsx 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 00000000..34b604de --- /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 00000000..a088f08a --- /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 cbc49c65..d6d29474 100644 --- a/app/components/common/Dialog-new/dialog.sass +++ b/app/components/common/Dialog-new/dialog.sass @@ -19,9 +19,11 @@ justify-content: space-between align-items: center margin-bottom: 12px - + &.noti + justify-content: center // 중앙 정렬 h2 @include title20SB + cursor: default .close-button @@ -42,22 +44,332 @@ .text padding-bottom: 10px + &__preview-content + position: relative + + .preview + width: 315px + height: 682px + border-radius: 13px + object-fit: contain + position: absolute + .status_bar + position: absolute + .mobile_preview + position: absolute + &__footer 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 .put z-index: 100 - \ No newline at end of file + + +.theme-preview-modal + position: fixed + top: 50% + left: 50% + transform: translate(-50%, -50%) + display: flex + flex-direction: column + gap: 14px + + .preview_image + position: relative + width: 315px + height: 682px + border-radius: 13px + overflow: hidden + .status_bar + position: absolute + width: 100% + height: 100% + .mobile_preview + object-fit: cover + width: 100% + height: 100% + +.preview-text + @include caption12M + color: $color-white70 + position: absolute + top: 20px + left: 20px + + +.preview-caption + width: 100% + height: 38px + display: flex + background-color:#378EFF24 + align-items: center + justify-content: center + border-radius: 4px + @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 + align-items: center + gap: 8px + padding: 8px + color: $color-white70 + @include body14M + cursor: pointer + user-select: none + + input[type="checkbox"] + appearance: none + width: 16px + height: 16px + border: 1.5px solid $color-white12 + border-radius: 3px + background-color: transparent + cursor: pointer + position: relative + + &:checked + background-color: #666 + border-color: #666 + + &::after + content: '' + position: absolute + left: 5px + top: 2px + width: 4px + height: 8px + border: solid white + border-width: 0 2px 2px 0 + transform: rotate(45deg) + + label + cursor: pointer + color: rgba(255, 255, 255, 0.7) + font-size: 14px + +.timer-image-modal + position: fixed + top: 50% + left: 50% + transform: translate(-50%, -50%) + + background-color: $color-sub1 + border-radius: 16px + width: 577px + &__header + display: flex + justify-content: space-between + align-items: center + + padding: 24px 24px 12px 32px + height: 68px + &.noti + justify-content: center // 중앙 정렬 + h2 + @include title20SB + cursor: default + + &__image-content + width: 513px + height: 434px + padding: 20px + margin: 20px 32px 16px 32px + background-color: $color-white5 + border-radius: 8px + position: relative + display: flex + flex-direction: column + align-items: center + gap: 16px + cursor: default + + &__noti-content + width: 521px + height: 333px + position: relative + padding: 20px + margin: 24px 0 28px + background-color: $color-white5 + border-radius: 12px + position: relative + display: flex + flex-direction: column + align-items: center + gap: 16px + + img + position: absolute + bottom: 0 + left: 50px + + &__feature-description + position: absolute + width: 261px + top: 133px + left: 236px + &__title + @include body14M + background-color: $color-white12 + display: flex + align-items: center + width: fit-content + height: 26px + padding: 2px 12px + border-radius: 6px + + &__summary + @include title18SB + margin: 16px 0 + + &__detail + @include body14R + color: $color-white70 + + &__footer + padding: 16px 32px 24px 32px + 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 + @include body14M + cursor: default + +.new-feature-modal + position: fixed + top: 50% + left: 50% + transform: translate(-50%, -50%) + + background-color: $color-sub1 + border-radius: 16px + width: 577px + &__header + display: flex + justify-content: space-between + align-items: center + + padding: 24px 24px 12px 32px + height: 68px + &.noti + justify-content: center // 중앙 정렬 + h2 + @include title20SB + cursor: default + + &__noti-content + height: 333px + position: relative + padding: 20px + margin: 12px 28px + background-color: $color-white5 + border-radius: 12px + position: relative + display: flex + flex-direction: column + align-items: center + gap: 16px + + img + position: absolute + bottom: 0 + left: 50px + + &__feature-description + position: absolute + width: 261px + top: 133px + left: 236px + &__title + @include body14M + background-color: $color-white12 + display: flex + align-items: center + width: fit-content + height: 26px + padding: 2px 12px + border-radius: 6px + + &__summary + @include title18SB + margin: 16px 0 + + &__detail + @include body14R + color: $color-white70 + + &__footer + padding: 16px 32px 24px 32px + 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 + @include body14M + cursor: default diff --git a/app/components/common/Drawer/Drawer.tsx b/app/components/common/Drawer/Drawer.tsx index 016cb4e0..1e97869a 100644 --- a/app/components/common/Drawer/Drawer.tsx +++ b/app/components/common/Drawer/Drawer.tsx @@ -18,7 +18,7 @@ import { useSelectedTheme, } from "@/components/atoms/selectedTheme.atom"; import { Theme, Themes } from "@/queries/getThemeList"; -import { getLoginInfo } from "@/utils/localStorage"; +import { getLoginInfo } from "@/utils/storageUtil"; import Dialog from "@/components/common/Dialog/Dialog"; import * as S from "./DrawerView.styled"; diff --git a/app/components/common/EmptyHome/EmptyHomeView.tsx b/app/components/common/EmptyHome/EmptyHomeView.tsx index 5555c647..f0b58a82 100644 --- a/app/components/common/EmptyHome/EmptyHomeView.tsx +++ b/app/components/common/EmptyHome/EmptyHomeView.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/navigation"; import { HOME_TITLE } from "@/consts/components/home"; import { useModalStateWrite } from "@/components/atoms/modalState.atom"; -import { getLoginInfo } from "@/utils/localStorage"; +import { getLoginInfo } from "@/utils/storageUtil"; import * as S from "./EmptyHomeView.styled"; diff --git a/app/components/common/Header/Header.tsx b/app/components/common/Header/Header.tsx index 67631e3d..481c981a 100644 --- a/app/components/common/Header/Header.tsx +++ b/app/components/common/Header/Header.tsx @@ -1,7 +1,7 @@ import React, { useState, MouseEvent } from "react"; import { useIsLoggedInWrite } from "@/components/atoms/account.atom"; -import { removeAccessToken } from "@/utils/localStorage"; +import { removeAccessToken } from "@/utils/storageUtil"; import HeaderView from "./HeaderView"; diff --git a/app/components/common/Hint-Dialog-new/ModalPortal.tsx b/app/components/common/Hint-Dialog-new/ModalPortal.tsx deleted file mode 100644 index 2ca31f45..00000000 --- a/app/components/common/Hint-Dialog-new/ModalPortal.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { ReactNode } from "react"; -import ReactDom from "react-dom"; - -interface Props { - children: ReactNode; -} - -const ModalPortal = ({ children }: Props) => { - const el = document.getElementById("modal-root") as HTMLElement; - - return ReactDom.createPortal(children, el); -}; - -export default ModalPortal; diff --git a/app/components/common/Hint-Dialog-new/dialog.sass b/app/components/common/Hint-Dialog-new/dialog.sass deleted file mode 100644 index 5ae9b314..00000000 --- a/app/components/common/Hint-Dialog-new/dialog.sass +++ /dev/null @@ -1,59 +0,0 @@ -@import '../../../style/variables' -@import '../../../style/mixins' -@import '../../../style/button' - - -// 변수 정의 -.theme-info-modal - position: fixed - top: 50% - left: 50% - transform: translate(-50%, -50%) - - background-color: $color-sub1 - border-radius: 16px - padding: 24px 32px - width: 577px - &__header - display: flex - justify-content: space-between - align-items: center - margin-bottom: 12px - - h2 - @include title20SB - - - .close-button - position: absolute - top: 24px - right: 24px - background: none - border: none - cursor: pointer - - &__content - padding-top: 10px - .info-grid - margin-top: 8px - display: grid - grid-template-columns: 1fr 1fr - gap: 10px - .text - padding-bottom: 10px - - &__footer - margin-top: 16px - display: flex - justify-content: space-between - - .action-buttons - margin-left: auto - display: flex - gap: 10px - -.delete - z-index: 600 -.put - z-index: 100 - \ No newline at end of file diff --git a/app/components/common/Toast/toast.sass b/app/components/common/Toast/toast.sass index 9b6dd98e..dbcbbded 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/home/Home.tsx b/app/home/Home.tsx deleted file mode 100644 index 5c0b0a69..00000000 --- a/app/home/Home.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -import React, { useEffect, useState } from "react"; - -import { useGetThemeList } from "@/queries/getThemeList"; -import useCheckSignIn from "@/hooks/useCheckSignIn"; -import { useSnackBarInfo } from "@/components/atoms/snackBar.atom"; -import SnackBar from "@/components/SnackBar/SnackBar"; -import Loader from "@/components/Loader/Loader"; -import useChannelTalk from "@/hooks/useChannelTalk"; - -import HomeView from "./HomeView"; - -function Home() { - const { data: categories = [] } = useGetThemeList(); - const [open, setOpen] = useState(false); - const [snackInfo, setSnackBarInfo] = useSnackBarInfo(); - useChannelTalk(); - - const handleDialog = () => { - setOpen(!open); - }; - - const isSignIn = useCheckSignIn(); - - useEffect(() => { - if (snackInfo.isOpen) { - setTimeout(() => { - setSnackBarInfo({ ...snackInfo, isOpen: false }); - }, 3000); - } - }, [setSnackBarInfo, snackInfo]); - - const themeAllProps = { - categories, - handleDialog, - }; - - if (!isSignIn) return ; - - return ( - <> - - setSnackBarInfo({ ...snackInfo, isOpen: false })} - /> - - ); -} - -export default Home; diff --git a/app/home/HomeView.styled.ts b/app/home/HomeView.styled.ts deleted file mode 100644 index 3a29cc2a..00000000 --- a/app/home/HomeView.styled.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { styled } from "styled-components"; -import { Box } from "@mui/material"; - -import { MAIN_GRID_WIDTH } from "@/consts/styles/common"; - -export const Wrapper = styled(Box)` - display: flex; - min-width: 840px; - height: 100vh; - overflow-y: hidden; - overflow-x: scroll; -`; - -export const Cont = styled(Box)` - flex-grow: 1; - height: 100%; - /* min-width: calc(${MAIN_GRID_WIDTH} + 2px); */ - margin: 0; - padding: 0 16px 80px 80px; - overflow-y: auto; - min-width: 840px; - overflow-x: scroll; -`; -export const TopNav = styled.div` - display: flex; - justify-content: end; - height: 68px; - padding: 18px 48px; - div { - background-color: #fff; - border-radius: 50%; - width: 32px; - height: 32px; - } -`; - -export const Title = styled.div` - font-size: 4rem; -`; - -export const Body = styled.div` - width: ${MAIN_GRID_WIDTH}; - height: 100%; - margin: 0 auto; - overflow-y: auto; -`; diff --git a/app/home/HomeView.tsx b/app/home/HomeView.tsx deleted file mode 100644 index 44b74326..00000000 --- a/app/home/HomeView.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from "react"; - -import EmptyHome from "@/components/common/EmptyHome/EmptyHome"; -import HintList from "@/components/ThemeDetail/ThemeDetail"; -import { Themes } from "@/queries/getThemeList"; - -type Props = { - categories: Themes; -}; - -function HomeView(props: Props) { - const { categories } = props; - - if (categories.length < 1) { - return ; - } - return ; -} -export default HomeView; diff --git a/app/home/layout.tsx b/app/home/layout.tsx deleted file mode 100644 index faedb675..00000000 --- a/app/home/layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ReactNode } from "react"; - -export default function RootLayout({ children }: { children: ReactNode }) { - return
{children}
; -} diff --git a/app/home/page.tsx b/app/home/page.tsx deleted file mode 100644 index c61134d8..00000000 --- a/app/home/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from "react"; - -import Home from "./Home"; - -function HomePage() { - return ; -} - -export default HomePage; diff --git a/app/hooks/useAnalytics.ts b/app/hooks/useAnalytics.ts index 2bcfaf94..a308b900 100644 --- a/app/hooks/useAnalytics.ts +++ b/app/hooks/useAnalytics.ts @@ -1,13 +1,51 @@ -import { getAnalytics, logEvent as firebaseLogEvent } from "firebase/analytics"; +import { + getAnalytics, + logEvent as firebaseLogEvent, + isSupported, +} from "firebase/analytics"; +import { useEffect, useState } from "react"; import { isDevMode } from "@/consts/env"; const useAnalytics = () => { - const analytics = getAnalytics(); + const [analytics, setAnalytics] = useState | null>(null); + + useEffect(() => { + if (typeof window !== "undefined") { + isSupported() + .then((supported) => { + if (supported) { + const analyticsInstance = getAnalytics(); + setAnalytics(analyticsInstance); + } else { + console.warn( + "Firebase Analytics is not supported in this environment." + ); + } + }) + .catch((error) => { + console.error("Failed to check Firebase Analytics support:", error); + }); + } + }, []); const logEvent = (eventName: string, eventParam: Record) => { if (isDevMode) return; - firebaseLogEvent(analytics, eventName, eventParam); + + if (analytics) { + try { + firebaseLogEvent(analytics, eventName, eventParam); + } catch (error) { + console.error("Failed to log event:", error); + } + } else { + console.warn( + "Analytics instance is not initialized. Event not logged:", + eventName + ); + } }; return { logEvent }; diff --git a/app/hooks/useCheckSignIn.ts b/app/hooks/useCheckSignIn.ts index c44e0dd2..fdd4c289 100644 --- a/app/hooks/useCheckSignIn.ts +++ b/app/hooks/useCheckSignIn.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { apiClient } from "@/lib/reactQueryProvider"; -import { getLoginInfo } from "@/utils/localStorage"; +import { getLoginInfo } from "@/utils/storageUtil"; import { useIsLoggedIn } from "@/components/atoms/account.atom"; import { getSubscriptionPlan } from "@/queries/getSubscriptionPlan"; diff --git a/app/hooks/useClickOutside.ts b/app/hooks/useClickOutside.ts index b2a3452b..83ff2264 100644 --- a/app/hooks/useClickOutside.ts +++ b/app/hooks/useClickOutside.ts @@ -9,7 +9,7 @@ import { useEffect, RefObject } from "react"; * @param {boolean} ignoreSiblings - 형제 요소 클릭 시에도 닫힐지 여부 */ function useClickOutside( - ref: RefObject, + ref: React.RefObject, handler: (event: MouseEvent) => void, isActive = true, targetIndex = 0, diff --git a/app/landing/Landing.tsx b/app/landing/Landing.tsx index 027202e0..8e19454b 100644 --- a/app/landing/Landing.tsx +++ b/app/landing/Landing.tsx @@ -5,7 +5,7 @@ import { useRouter, usePathname } from "next/navigation"; import { useIsLoggedInWrite } from "@/components/atoms/account.atom"; import { useAsPathStateWrite } from "@/components/atoms/signup.atom"; -import { removeAccessToken } from "@/utils/localStorage"; +import { removeAccessToken } from "@/utils/storageUtil"; import useCheckSignIn from "@/hooks/useCheckSignIn"; import useChannelTalk from "@/hooks/useChannelTalk"; diff --git a/app/lib/reactQueryProvider.tsx b/app/lib/reactQueryProvider.tsx index 67e2cc65..67ac3877 100644 --- a/app/lib/reactQueryProvider.tsx +++ b/app/lib/reactQueryProvider.tsx @@ -1,71 +1,127 @@ "use client"; -import axios from "axios"; import { PropsWithChildren, useState } from "react"; +import axios from "axios"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { getLoginInfo, removeLocalStorageAll } from "@/utils/localStorage"; -import { useIsLoggedInWrite } from "@/components/atoms/account.atom"; -import { useSnackBarWrite } from "@/components/atoms/snackBar.atom"; +import { + getLoginInfo, + removeLocalStorageAll, + setLoginInfo, +} from "@/utils/storageUtil"; +// Axios 클라이언트 설정 export const apiClient = axios.create({ withCredentials: true, }); +let isRefreshing = false; +let failedQueue: any[] = []; + +// 대기 중인 요청 처리 +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (token) { + prom.resolve(token); + } else { + prom.reject(error); + } + }); + failedQueue = []; +}; + apiClient.interceptors.request.use( (config) => { const { accessToken } = getLoginInfo(); - if (accessToken) { config.headers.Authorization = `Bearer ${accessToken.replace( /^"(.*)"$/, "$1" )}`; } - return config; }, (error) => Promise.reject(error) ); -type ErrorResponse = { - response: { - data: { - code: number; - message: string; - }; - status: number; - }; -}; +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + const { response } = error; + const originalRequest = error.config; -export default function ReactQueryProvider({ children }: PropsWithChildren) { - const setIsLoggedIn = useIsLoggedInWrite(); - const setSnackBar = useSnackBarWrite(); - - apiClient.interceptors.response.use( - (response) => response, - (error) => { - const { response } = error as ErrorResponse; - if (response?.data?.message) { - setSnackBar({ - isOpen: true, - message: `${(error as any)?.response?.data?.message || error}`, - }); - } + if (response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; - if (response && response.status === 401) { - delete apiClient.defaults.headers.Authorization; - delete apiClient.defaults.headers.common.Authorization; - removeLocalStorageAll(); - setIsLoggedIn(false); + if (!isRefreshing) { + isRefreshing = true; + + try { + const loginInfo = getLoginInfo(); + const { refreshToken, accessToken } = loginInfo; + + if (!refreshToken || accessToken === "undefined") { + throw new Error("리프레시 토큰이 없습니다."); + } + + const { data } = await axios.post( + "/v1/auth/reissue", + { + refreshToken: refreshToken.replace(/^"(.*)"$/, "$1"), + accessToken: accessToken.replace(/^"(.*)"$/, "$1"), + }, + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + const { data: response } = data; + + setLoginInfo({ + ...loginInfo, + refreshToken: response.refreshToken, + accessToken: response.accessToken, + }); + apiClient.defaults.headers.Authorization = `Bearer ${response.accessToken}`; + + processQueue(null, response.accessToken); + return apiClient(originalRequest); + } catch (refreshError) { + if (typeof window !== "undefined") { + const currentPath = window.location.pathname; + if (currentPath !== "/signup") { + processQueue(refreshError, null); + removeLocalStorageAll(); + window.location.href = "/login"; + } + } + + throw refreshError; + } finally { + isRefreshing = false; + } } - return Promise.reject(error); + return new Promise((resolve, reject) => { + failedQueue.push({ + resolve: (token: string) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + resolve(apiClient(originalRequest)); + }, + reject: (err: any) => reject(err), + }); + }); } - ); - const [queryClient] = useState(new QueryClient({})); + return Promise.reject(error); + } +); + +export default function ReactQueryProvider({ children }: PropsWithChildren) { + const [queryClient] = useState(() => new QueryClient()); return ( diff --git a/app/login/Login.tsx b/app/login/Login.tsx index 975f74e8..8e26f2a7 100644 --- a/app/login/Login.tsx +++ b/app/login/Login.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useEffect } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import { useRouter } from "next/navigation"; @@ -8,9 +8,9 @@ import { ADMIN_EMAIL, ADMIN_PASSWORD } from "@/consts/components/login"; import { useIsLoggedInValue } from "@/components/atoms/account.atom"; import { usePostLogin } from "@/mutations/postLogin"; import useCheckSignIn from "@/hooks/useCheckSignIn"; -import Loader from "@/components/Loader/Loader"; import useChannelTalk from "@/hooks/useChannelTalk"; import { setCookie } from "@/utils/cookie"; +import { useGetThemeList } from "@/queries/getThemeList"; import LoginView from "./LoginView"; @@ -41,12 +41,28 @@ function Login() { useCheckSignIn(); useChannelTalk(); - const router = useRouter(); const formValue = watch(); - const onSubmit: SubmitHandler = (data) => { - postLogin(data); + const { data: themeList, isLoading: isThemeLoading } = useGetThemeList(); + const router = useRouter(); + + const onSubmit: SubmitHandler = async (data) => { + try { + await postLogin(data); + } catch (error) { + console.error("Login failed:", error); + } }; + + useEffect(() => { + if (themeList && themeList.length > 0) { + const defaultThemeId = themeList[0].id; + router.push(`/admin?themeId=${defaultThemeId}`); + } else { + router.push(`/admin`); + } + }, [isThemeLoading]); + const formProps = { component: "form", noValidate: true, @@ -122,10 +138,6 @@ function Login() { contectProps, }; - if (isLoggedIn) { - return ; - } - return ; } diff --git a/app/mutations/deleteTimerImage.ts b/app/mutations/deleteTimerImage.ts new file mode 100644 index 00000000..5cecb6d9 --- /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/postLogin.ts b/app/mutations/postLogin.ts index cd4c6e75..ac900260 100644 --- a/app/mutations/postLogin.ts +++ b/app/mutations/postLogin.ts @@ -1,10 +1,10 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AxiosError, AxiosResponse } from "axios"; import { useSnackBarWrite } from "@/components/atoms/snackBar.atom"; import { apiClient } from "@/lib/reactQueryProvider"; import { ApiError, ApiResponse, MutationConfigOptions } from "@/types"; -import { setLoginInfo } from "@/utils/localStorage"; +import { setLoginInfo } from "@/utils/storageUtil"; import { useIsLoggedInWrite } from "@/components/atoms/account.atom"; interface Request { @@ -31,13 +31,14 @@ export const postLogin = async (data: Request) => { URL_PATH, data ); - return res.data; }; export const usePostLogin = (configOptions?: MutationConfigOptions) => { const setIsLoggedIn = useIsLoggedInWrite(); const setSnackBar = useSnackBarWrite(); + const queryClient = useQueryClient(); + const info = useMutation, Request, void>({ mutationKey: MUTATION_KEY, mutationFn: (req) => postLogin(req), @@ -54,11 +55,10 @@ export const usePostLogin = (configOptions?: MutationConfigOptions) => { accessTokenExpiresIn: data.accessTokenExpiresIn, }); setIsLoggedIn(true); + + queryClient.invalidateQueries({ queryKey: ["/v1/theme"] }); } }, - onSettled: () => { - // console.log("항상 실행"); - }, onError: (error) => { setSnackBar({ isOpen: true, diff --git a/app/mutations/postTheme.ts b/app/mutations/postTheme.ts index 990e29b0..4820f403 100644 --- a/app/mutations/postTheme.ts +++ b/app/mutations/postTheme.ts @@ -1,14 +1,10 @@ -import { useRouter } from "next/navigation"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AxiosRequestConfig, AxiosResponse, AxiosResponseHeaders } from "axios"; -import { useSelectedThemeWrite } from "@/components/atoms/selectedTheme.atom"; -import { useCreateThemeValue } from "@/components/atoms/createTheme.atom"; import { useToastWrite } from "@/components/atoms/toast.atom"; import { apiClient } from "@/lib/reactQueryProvider"; import { QUERY_KEY } from "@/queries/getThemeList"; import { MutationConfigOptions } from "@/types"; -import { setSelectedThemeId } from "@/utils/localStorage"; interface Request { title: string; @@ -42,9 +38,6 @@ export const postTheme = async ( export const usePostTheme = (configOptions?: MutationConfigOptions) => { const queryClient = useQueryClient(); const setToast = useToastWrite(); - const router = useRouter(); - const setSelectedTheme = useSelectedThemeWrite(); - const createTheme = useCreateThemeValue(); const info = useMutation< AxiosResponse, @@ -57,12 +50,6 @@ export const usePostTheme = (configOptions?: MutationConfigOptions) => { ...configOptions?.options, onSuccess: ({ data }) => { queryClient.invalidateQueries(QUERY_KEY); - setTimeout(() => { - setSelectedTheme({ ...createTheme, id: data?.data?.id }); - setSelectedThemeId(data?.data?.id); - - router.push(`/admin?themeId=${data?.data?.id}`); - }, 10); setToast({ isOpen: true, title: "테마를 추가했습니다.", diff --git a/app/mutations/putTheme.ts b/app/mutations/putTheme.ts index 41a79996..55399bbf 100644 --- a/app/mutations/putTheme.ts +++ b/app/mutations/putTheme.ts @@ -1,6 +1,5 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AxiosResponse } from "axios"; -import { useRouter } from "next/navigation"; import { useSelectedThemeValue } from "@/components/atoms/selectedTheme.atom"; import { useToastWrite } from "@/components/atoms/toast.atom"; @@ -30,15 +29,12 @@ export const putTheme = async (req: Request) => { export const usePutTheme = (configOptions?: MutationConfigOptions) => { const queryClient = useQueryClient(); const setToast = useToastWrite(); - const selectedTheme = useSelectedThemeValue(); - const router = useRouter(); const info = useMutation({ mutationKey: MUTATION_KEY, mutationFn: (req) => putTheme(req), ...configOptions?.options, onSuccess: () => { queryClient.invalidateQueries(QUERY_KEY); - router.push(`/admin?themeId=${selectedTheme.id}`); setToast({ isOpen: true, diff --git a/app/mutations/useRefresh.ts b/app/mutations/useRefresh.ts deleted file mode 100644 index 2bbc9a9f..00000000 --- a/app/mutations/useRefresh.ts +++ /dev/null @@ -1,104 +0,0 @@ -import axios from "axios"; -import moment from "moment"; -import { useMutation } from "@tanstack/react-query"; - -import { - getLoginInfo, - removeAccessToken, - setLocalStorage, -} from "@/utils/localStorage"; - -interface ReissueRequest { - accessToken: string; - refreshToken: string; -} - -interface ReissueResponse { - accessToken: string; - refreshToken: string; -} - -const reissueToken = async ({ - accessToken, - refreshToken, -}: ReissueRequest): Promise => { - try { - const response = await axios.post( - "/api/v1/auth/reissue", - { - accessToken: accessToken.replace(/^"|"$/g, ""), - refreshToken: refreshToken.replace(/^"|"$/g, ""), - }, - { - headers: { - "Content-Type": "application/json", - }, - } - ); - return response.data; - } catch (error) { - console.error("Token reissue failed:", error); - throw error; - } -}; - -export const useTokenRefresh = () => { - const mutation = useMutation({ - mutationFn: reissueToken, - onSuccess: (data) => { - setLocalStorage("accessToken", data.accessToken); - setLocalStorage( - "accessTokenExpiresIn", - moment().add(3, "minutes").unix() - ); - }, - onError: (error) => { - removeAccessToken(); - }, - }); - - // 토큰 만료 체크 및 갱신 - const checkAndRefreshToken = async () => { - const { accessToken, refreshToken, accessTokenExpiresIn } = getLoginInfo(); - const now = new Date(); - - if (!accessTokenExpiresIn) return null; - - if (Number(accessTokenExpiresIn) - now.getTime() < 0) { - return mutation.mutateAsync({ accessToken, refreshToken }); - } - - return null; - }; - - return { - refreshToken: checkAndRefreshToken, - error: mutation.error, - }; -}; - -export const setupAxiosInterceptors = () => { - axios.interceptors.request.use( - async (config) => { - const { accessToken, refreshToken, accessTokenExpiresIn } = - getLoginInfo(); - const now = new Date(); - - if (Number(accessTokenExpiresIn) - now.getTime() < 0) { - try { - const response = await reissueToken({ accessToken, refreshToken }); - config.headers.Authorization = `Bearer ${accessToken}`; - } catch (error) { - console.error("Token refresh failed in interceptor:", error); - } - } else if (accessToken) { - config.headers.Authorization = `Bearer ${accessToken}`; - } - - return config; - }, - (error) => { - return Promise.reject(error); - } - ); -}; diff --git a/app/mutations/useTimerImageUpload.ts b/app/mutations/useTimerImageUpload.ts new file mode 100644 index 00000000..bee7738e --- /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/getPreSignedUrl.ts b/app/queries/getPreSignedUrl.ts index 4a0997bc..2ff52ca8 100644 --- a/app/queries/getPreSignedUrl.ts +++ b/app/queries/getPreSignedUrl.ts @@ -5,7 +5,7 @@ import { apiClient } from "@/lib/reactQueryProvider"; import { useToastInfo } from "@/components/atoms/toast.atom"; import { QUERY_KEY } from "@/queries/getHintList"; import extractFilename from "@/utils/helper"; -import { getStatus } from "@/utils/localStorage"; +import { getStatus } from "@/utils/storageUtil"; interface PreSignedUrlRequest { themeId: number; diff --git a/app/queries/getSubscriptionPlan.ts b/app/queries/getSubscriptionPlan.ts index 7aec09fc..25f565b8 100644 --- a/app/queries/getSubscriptionPlan.ts +++ b/app/queries/getSubscriptionPlan.ts @@ -5,7 +5,7 @@ import { useToastWrite } from "@/components/atoms/toast.atom"; import { apiClient } from "@/lib/reactQueryProvider"; import { ApiResponse, QueryConfigOptions } from "@/types"; import { useIsLoggedInValue } from "@/components/atoms/account.atom"; -import { setStatus } from "@/utils/localStorage"; +import { setStatus } from "@/utils/storageUtil"; // 요청 타입 type Request = void; diff --git a/app/queries/getThemeList.ts b/app/queries/getThemeList.ts index 6e62c226..78236a7b 100644 --- a/app/queries/getThemeList.ts +++ b/app/queries/getThemeList.ts @@ -5,7 +5,7 @@ import { useSnackBarWrite } from "@/components/atoms/snackBar.atom"; import { apiClient } from "@/lib/reactQueryProvider"; import { ApiResponse, QueryConfigOptions } from "@/types"; import { useIsLoggedInValue } from "@/components/atoms/account.atom"; -import { getSelectedThemeId, setSelectedThemeId } from "@/utils/localStorage"; +import { getSelectedThemeId, setSelectedThemeId } from "@/utils/storageUtil"; import { useSelectedThemeWrite } from "@/components/atoms/selectedTheme.atom"; type Request = void; @@ -21,8 +21,7 @@ export type Themes = Theme[]; type Response = ApiResponse; const URL_PATH = `/v1/theme`; -export const QUERY_KEY = [URL_PATH]; -// TODO - 유저 id를 키에 추가해야 함 +export const QUERY_KEY = [URL_PATH]; // TODO - 유저 id를 키에 추가해야 함 export const getThemeList = async (config?: AxiosRequestConfig) => { const res = await apiClient.get>(URL_PATH, { @@ -59,6 +58,7 @@ export const useGetThemeList = (configOptions?: QueryConfigOptions) => { } } else setSelectedThemeId(0); }, + onError: (error: AxiosError) => { setSnackBar({ isOpen: true, @@ -67,5 +67,10 @@ export const useGetThemeList = (configOptions?: QueryConfigOptions) => { }, }); - return info; + return { + ...info, + isInitialLoading: info.isLoading, + isRefetching: info.isFetching && !info.isLoading, + isLoading: info.isLoading, + }; }; diff --git a/app/signup/CodeInput.tsx b/app/signup/CodeInput.tsx index 3c6866af..0b99c537 100644 --- a/app/signup/CodeInput.tsx +++ b/app/signup/CodeInput.tsx @@ -13,29 +13,29 @@ interface Props { export default function CodeInput(props: Props) { const { disabled, numbers, setNumbers } = props; - const inputRefs = useRef(Array(6).fill(null)); // 6개의 ref를 저장할 배열 + const inputRefs = useRef<(HTMLInputElement | null)[]>( + Array.from({ length: 6 }, () => null) + ); const { mutateAsync: postVerification, isError = false } = usePostVerification(); const signUpState = useSignUpValue(); const handleInputChange = (index: number, value: string) => { - // 입력값이 숫자가 아니거나 길이가 1을 넘어가면 입력을 막음 if (!/^\d$/.test(value)) return; - const newNumbers = [...numbers]; // 기존 숫자 배열 복사 - newNumbers[index] = value; // 해당 인덱스의 값을 업데이트 - setNumbers(newNumbers); // 업데이트된 배열을 상태로 설정 + const newNumbers = [...numbers]; + newNumbers[index] = value; + setNumbers(newNumbers); - // 다음 인풋 필드로 포커스 이동 if (value !== "" && index < 5) { - inputRefs.current[index + 1].focus(); + inputRefs.current[index + 1]?.focus(); } }; useEffect(() => { setTimeout(() => { - inputRefs.current[0].focus(); + inputRefs.current[0]?.focus(); }, 1000); }, []); @@ -47,13 +47,12 @@ export default function CodeInput(props: Props) { if (isError) { setTimeout(() => { setNumbers(Array(6).fill("")); - inputRefs.current[0].focus(); + inputRefs.current[0]?.focus(); }, 1000); } } }, [numbers, isError]); - // 입력값을 삭제하는 함수 const handleInputDelete = ( index: number, e: React.KeyboardEvent @@ -63,9 +62,8 @@ export default function CodeInput(props: Props) { newNumbers[index] = ""; setNumbers(newNumbers); - // 이전 인풋 필드로 포커스 이동 if (index > 0) { - inputRefs.current[index - 1].focus(); + inputRefs.current[index - 1]?.focus(); } } }; @@ -79,9 +77,11 @@ export default function CodeInput(props: Props) { value={number} error={isError && numbers.join("").length === 0} onChange={(e) => handleInputChange(index, e.target.value)} - onKeyDown={(e) => handleInputDelete(index, e)} // 삭제 이벤트 처리 - maxLength={1} // 한 글자만 입력할 수 있도록 설정 - ref={(input) => (inputRefs.current[index] = input)} // ref를 배열에 저장 + onKeyDown={(e) => handleInputDelete(index, e)} + maxLength={1} + ref={(input) => { + inputRefs.current[index] = input; + }} disabled={disabled} inputMode="numeric" /> diff --git a/app/signup/SignUpSuccess.tsx b/app/signup/SignUpSuccess.tsx index 3565c548..8c2c4970 100644 --- a/app/signup/SignUpSuccess.tsx +++ b/app/signup/SignUpSuccess.tsx @@ -17,7 +17,15 @@ import * as S from "./SignUpSuccess.styled"; import "@/apis/firebase"; function SignUpSuccess() { - const isWebView = /APP_NEXTROOM_ANDROID/.test(navigator.userAgent); // 웹뷰에서 실행 중인지 여부 확인 + const [isWebView, setIsWebView] = useState(false); + + useEffect(() => { + if (typeof window !== "undefined") { + const { userAgent } = window.navigator; + const mwebviewRegex = /APP_NEXTROOM_ANDROID/i; + setIsWebView(mwebviewRegex.test(userAgent)); + } + }, []); const [isFinished, setIsFinished] = useState(false); const [snackInfo, setSnackBarInfo] = useSnackBarInfo(); const router = useRouter(); diff --git a/app/signup/StoreInfo.tsx b/app/signup/StoreInfo.tsx index beb87523..29c0de8a 100644 --- a/app/signup/StoreInfo.tsx +++ b/app/signup/StoreInfo.tsx @@ -22,14 +22,27 @@ interface FormValues { function StoreInfo() { const isLoggedIn = useIsLoggedInValue(); const [signUpState, setSignUpState] = useSignUpState(); - const isWebView = /APP_NEXTROOM_ANDROID/.test(navigator.userAgent); // 웹뷰에서 실행 중인지 여부 확인 - const isMobile = - /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test( - navigator.userAgent - ); + const [isWebView, setIsWebView] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const [isChecked, setIsChecked] = useState(false); + const { logEvent } = useAnalytics(); + useEffect(() => { + if (typeof window !== "undefined") { + const { userAgent } = window.navigator; + + const mwebviewRegex = /APP_NEXTROOM_ANDROID/i; + setIsWebView(mwebviewRegex.test(userAgent)); + + const mobileRegex = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i; + setIsMobile(mobileRegex.test(userAgent)); + } + }, []); + const type = isWebView ? 3 : isMobile ? 2 : 1; + useEffect(() => { logEvent("screen_view", { firebase_screen: "sign_up_store_info", @@ -43,7 +56,7 @@ function StoreInfo() { isError = false, error, } = usePostSignUp(); - const [isChecked, setIsChecked] = useState(false); + const { register, handleSubmit, @@ -57,13 +70,14 @@ function StoreInfo() { reason: "", }, }); + const formValue = watch(); useEffect(() => { setTimeout(() => { setFocus("name"); }, 1000); - }, []); + }, [setFocus]); useEffect(() => { if (isChecked) { @@ -74,7 +88,7 @@ function StoreInfo() { setTimeout(() => { setFocus("reason"); }, 10); - }, [isChecked]); + }, [isChecked, reset, setFocus]); const browserPreventEvent = () => { history.pushState(null, "", location.href); @@ -82,16 +96,16 @@ function StoreInfo() { }; useEffect(() => { - history.pushState(null, "", location.href); - window.addEventListener("popstate", () => { - browserPreventEvent(); - }); + if (typeof window !== "undefined") { + history.pushState(null, "", location.href); + window.addEventListener("popstate", browserPreventEvent); + } return () => { - window.removeEventListener("popstate", () => { - browserPreventEvent(); - }); + if (typeof window !== "undefined") { + window.removeEventListener("popstate", browserPreventEvent); + } }; - }, []); + }, [browserPreventEvent]); const onSubmit: SubmitHandler = (data) => { postSignUp({ @@ -106,6 +120,7 @@ function StoreInfo() { btn_position: "top", }); }; + const formProps = { component: "form", noValidate: true, @@ -118,7 +133,6 @@ function StoreInfo() { id: "filled-adminCode", type: "text", helperText: errors?.name && errors?.name.message, - error: Boolean(errors?.name) || isError, variant: "filled", label: "매장명", @@ -140,12 +154,15 @@ function StoreInfo() { style: { marginTop: "26px" }, value: formValue.reason, }; + const checkBoxProps = { label: "매장명이 없습니다.", checked: isChecked, onChange: () => { setIsChecked(!isChecked); - window.scrollTo(0, document.body.scrollHeight); + if (typeof window !== "undefined" && typeof document !== "undefined") { + window.scrollTo(0, document.body.scrollHeight); + } }, onClick: () => { logEvent("btn_click", { diff --git a/app/signup/layout.tsx b/app/signup/layout.tsx index 733629b0..c3f4583f 100644 --- a/app/signup/layout.tsx +++ b/app/signup/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; @@ -14,7 +14,15 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { - const isWebView = /APP_NEXTROOM_ANDROID/.test(navigator.userAgent); // 웹뷰에서 실행 중인지 여부 확인 + const [isWebView, setIsWebView] = useState(false); + + useEffect(() => { + if (typeof window !== "undefined") { + const { userAgent } = window.navigator; + const mwebviewRegex = /APP_NEXTROOM_ANDROID/i; + setIsWebView(mwebviewRegex.test(userAgent)); + } + }, []); const router = useRouter(); const pathName = getCookie(); diff --git a/app/style/_button.sass b/app/style/_button.sass index daa9064d..a605f5be 100644 --- a/app/style/_button.sass +++ b/app/style/_button.sass @@ -36,6 +36,17 @@ color: $color-white20 background-color: $color-white20 cursor: not-allowed + +.icon_secondary_button40 + height: 40px + padding: 12px + border-radius: 8px + background-color: $color-white5 + &:hover + background-color: $color-white8 + &:active + background-color: $color-white8 + .secondary_button40 height: 40px padding: 8px 16px @@ -51,6 +62,8 @@ color: $color-white20 background-color: $color-white5 cursor: not-allowed + + .outlined_button40 height: 40px padding: 8px 16px diff --git a/app/theme/page.tsx b/app/theme/page.tsx deleted file mode 100644 index c759546a..00000000 --- a/app/theme/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import MakeThemePage from "@/components/MakeThemePage/MakeThemePage"; - -export default function ThemePage() { - return ; -} diff --git a/app/trial/Trial.tsx b/app/trial/Trial.tsx deleted file mode 100644 index 7a8db7e9..00000000 --- a/app/trial/Trial.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; - -import { EMAIL } from "@/consts/components/trial"; -import "@/apis/firebase"; -import { usePostInfo } from "@/mutations/postInfo"; -import useAnalytics from "@/hooks/useAnalytics"; - -import TrialView from "./TrialView"; - -interface FormValues { - info: string; -} -function Trial() { - const { mutate: postMutate } = usePostInfo(); - const { logEvent } = useAnalytics(); - - useEffect(() => { - logEvent("screen_view", { - firebase_screen: "homepage_input_contact", - firebase_screen_class: "homepage_input_contact", - }); - }, [logEvent]); - const { - register, - handleSubmit, - formState: { errors }, - } = useForm(); - const [isComplete, setIsComplete] = useState(false); - - const onSubmit: SubmitHandler = (info) => { - setIsComplete(true); - postMutate(info); - }; - // const onSubmit: SubmitHandler = (data) => {}; - const formProps = { - component: "form", - noValidate: true, - autoComplete: "off", - onSubmit: handleSubmit(onSubmit), - flexDirection: "column", - }; - - const emailProps = { - id: "filled-email", - label: EMAIL, - type: "email", - variant: "filled", - placeholder: "", - ...register("info", { required: "이메일이나 연락처를 입력해 주세요." }), - helperText: errors?.info && errors.info.message, - // error: Boolean(errors?.email) || isError, - sx: { backgroundColor: "#ffffff10" }, - }; - - // const errorMessage = isError && error?.response?.data?.message; - - const buttonProps = { - type: "submit", - variant: "contained", - }; - - const TrialViewProps = { - formProps, - buttonProps, - emailProps, - // errorMessage, - // isLoading, - isComplete, - }; - - return ; -} - -export default Trial; diff --git a/app/trial/TrialView.styled.ts b/app/trial/TrialView.styled.ts deleted file mode 100644 index 9d4b3947..00000000 --- a/app/trial/TrialView.styled.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { styled } from "styled-components"; -import { Button } from "@mui/material"; - -// export const Wrapper = styled.div` -// margin: 18px 20px; -// display: flex; -// flex-direction: column; -// `; - -export const Wrapper = styled.form` - /* background: var(--nrdark-01, #151516); */ - height: 100vh; -`; -export const Cont = styled.div` - display: flex; - flex-direction: column; - max-width: 364px; - margin: 0 auto; -`; - -export const MobileWrapper = styled.div` - /* padding: 0 26px; */ -`; - -export const BackWrapper = styled.div` - height: 64px; - padding: 18px 20px; - margin-bottom: 26px; -`; - -export const Title = styled.h1` - font-size: 32px; - font-family: Pretendard; - font-weight: 700; - line-height: 140%; - margin: 0; -`; - -export const Contect = styled.p` - color: var(--nr-gray-01, #9898a0); - /* Pretendard/R/16 */ - font-family: Pretendard; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: 140%; - margin-top: 12px; -`; - -export const Btn = styled(Button)` - font-size: 14px !important; - font-weight: 600 !important; - line-height: 20px !important; - height: 60px !important; - margin-top: 44px !important; -`; - -export const TextCont = styled.div` - display: flex; - flex-direction: column; - margin-top: 21px; -`; diff --git a/app/trial/TrialView.tsx b/app/trial/TrialView.tsx deleted file mode 100644 index 121fca5d..00000000 --- a/app/trial/TrialView.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import "@/style/reset.css"; -import React, { useEffect } from "react"; -import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import { IconButton, TextField } from "@mui/material"; - -import { - TRIAL_TITLE, - CONTECT, - EMAIL, - SUBSCRIPTION, - COMPLETE, - CONTACT_CONFIRMATION, - RETURN_HOME, -} from "@/consts/components/trial"; -import "@/apis/firebase"; // Firebase 초기화 파일 임포트 -import useAnalytics from "@/hooks/useAnalytics"; - -import * as S from "./TrialView.styled"; - -type Props = Record; -function TrialView(props: Props) { - const { formProps, emailProps, buttonProps, isComplete } = props; - const { logEvent } = useAnalytics(); - useEffect(() => { - logEvent("btn_click", { - btn_name: "homepage_apply_free_trial", - }); - }, [logEvent]); - - const navigateToLanding = () => { - if (typeof window !== "undefined") { - window.close(); - // 여기에 클라이언트-사이드 로직 추가 - } - }; - - const titleText = isComplete ? COMPLETE : TRIAL_TITLE; - const contactText = isComplete ? CONTACT_CONFIRMATION : CONTECT; - const mainActionButton = isComplete ? ( - - {RETURN_HOME} - - ) : ( - - - {SUBSCRIPTION} - - ); - - return ( - - - - - - - -
-          {titleText}
-        
- {contactText} - {mainActionButton} -
-
- ); -} - -export default TrialView; diff --git a/app/trial/page.tsx b/app/trial/page.tsx deleted file mode 100644 index 8dff8157..00000000 --- a/app/trial/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from "react"; - -import Trial from "./Trial"; - -function TrialPage() { - return ; -} - -export default TrialPage; diff --git a/app/utils/cookie.ts b/app/utils/cookie.ts index 78279c4b..c768a50f 100644 --- a/app/utils/cookie.ts +++ b/app/utils/cookie.ts @@ -1,7 +1,11 @@ export const setCookie = (value: string, days?: number): void => { + if (typeof document === "undefined") { + console.warn("Cookies are not supported in the current environment."); + return; + } + const exdate = new Date(); - exdate.setDate(exdate.getDate() + (days || 0)); - // 설정 일수만큼 현재시간에 만료값으로 지정 + exdate.setDate(exdate.getDate() + (days || 0)); // 설정 일수만큼 현재 시간에 만료값 지정 const cookieValue = encodeURIComponent(value) + @@ -10,6 +14,11 @@ export const setCookie = (value: string, days?: number): void => { }; export const getCookie = (): string => { + if (typeof document === "undefined") { + console.warn("Cookies are not supported in the current environment."); + return ""; + } + const nameOfCookie = `pathName=`; let x = 0; diff --git a/app/utils/localStorage.ts b/app/utils/storageUtil.ts similarity index 85% rename from app/utils/localStorage.ts rename to app/utils/storageUtil.ts index 84607e9a..0da17739 100644 --- a/app/utils/localStorage.ts +++ b/app/utils/storageUtil.ts @@ -1,3 +1,5 @@ +import Cookies from "js-cookie"; + const ACCESS_TOKEN = "accessToken"; const REFRESH_TOKEN = "refreshToken"; const SHOP_NAME = "shopName"; @@ -66,9 +68,13 @@ export const setLoginInfo = (loginInfo: LoginInfo) => { } = loginInfo; setLocalStorage(ACCESS_TOKEN, accessToken); - setLocalStorage(REFRESH_TOKEN, refreshToken); - setLocalStorage(SHOP_NAME, shopName); - setLocalStorage(ADMIN_CODE, adminCode); + Cookies.set(REFRESH_TOKEN, refreshToken, { + secure: true, + sameSite: "Strict", + expires: 7, + }); + setLocalStorage(SHOP_NAME, shopName?.replaceAll(`"`, "")); + setLocalStorage(ADMIN_CODE, adminCode?.replaceAll(`"`, "")); setLocalStorage(ACCESS_TOKEN_EXPIRES_IN, accessTokenExpiresIn); }; @@ -80,11 +86,10 @@ export const setSelectedThemeId = (themeId: number) => { setLocalStorage(THEME_ID, themeId); }; -// 필요하다면 한번에 가져오는 함수도 만들 수 있습니다 export const getLoginInfo = (): LoginInfo => { return { accessToken: getLocalStorage(ACCESS_TOKEN) || "", - refreshToken: getLocalStorage(REFRESH_TOKEN) || "", + refreshToken: Cookies.get(REFRESH_TOKEN) || "", shopName: getLocalStorage(SHOP_NAME) || "", adminCode: getLocalStorage(ADMIN_CODE) || "", accessTokenExpiresIn: Number(getLocalStorage(ACCESS_TOKEN_EXPIRES_IN)) || 0, @@ -102,5 +107,8 @@ export const removeThemeId = () => { }; export const removeLocalStorageAll = () => { - localStorage.clear(); + if (typeof window !== "undefined") { + window.localStorage.clear(); + Cookies.remove(REFRESH_TOKEN); + } }; diff --git a/package-lock.json b/package-lock.json index 0b5e31d2..54aabb7e 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/package.json b/package.json index 96cdcb44..5875f8d6 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,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", @@ -60,6 +61,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" } } diff --git a/public/images/png/noti_image.png b/public/images/png/noti_image.png new file mode 100644 index 00000000..ae97e9d6 Binary files /dev/null and b/public/images/png/noti_image.png differ diff --git a/public/images/png/tooltip.png b/public/images/png/tooltip.png new file mode 100644 index 00000000..720fd9ea Binary files /dev/null and b/public/images/png/tooltip.png differ diff --git a/public/images/svg/arrow.svg b/public/images/svg/arrow.svg new file mode 100644 index 00000000..9e323e72 --- /dev/null +++ b/public/images/svg/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/svg/icon_preview.svg b/public/images/svg/icon_preview.svg new file mode 100644 index 00000000..9f0b3992 --- /dev/null +++ b/public/images/svg/icon_preview.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/svg/icon_question.svg b/public/images/svg/icon_question.svg new file mode 100644 index 00000000..df920a4f --- /dev/null +++ b/public/images/svg/icon_question.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/svg/icon_setting.svg b/public/images/svg/icon_setting.svg new file mode 100644 index 00000000..7ca64f8b --- /dev/null +++ b/public/images/svg/icon_setting.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/svg/image.png b/public/images/svg/image.png new file mode 100644 index 00000000..dbe6595b Binary files /dev/null and b/public/images/svg/image.png differ diff --git a/public/images/svg/preview.svg b/public/images/svg/preview.svg new file mode 100644 index 00000000..0d992bb9 --- /dev/null +++ b/public/images/svg/preview.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/svg/status_bar.svg b/public/images/svg/status_bar.svg new file mode 100644 index 00000000..cbac7fa4 --- /dev/null +++ b/public/images/svg/status_bar.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/svg/timer_preview.svg b/public/images/svg/timer_preview.svg new file mode 100644 index 00000000..7662d9a1 --- /dev/null +++ b/public/images/svg/timer_preview.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/svg/timer_preview_entire.svg b/public/images/svg/timer_preview_entire.svg new file mode 100644 index 00000000..b53b9f84 --- /dev/null +++ b/public/images/svg/timer_preview_entire.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +