diff --git a/src/frontend/.eslintrc.cjs b/src/frontend/.eslintrc.cjs index 21667bd9..f175836b 100644 --- a/src/frontend/.eslintrc.cjs +++ b/src/frontend/.eslintrc.cjs @@ -22,6 +22,7 @@ module.exports = { parser: '@typescript-eslint/parser', plugins: ['react-refresh'], rules: { + '@typescript-eslint/no-unused-vars': 'warn', 'react/react-in-jsx-scope': 'off', 'react/no-unknown-property': ['error', { ignore: ['css'] }], 'import/order': [ diff --git a/src/frontend/index.html b/src/frontend/index.html index e864d124..4f89e6de 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -8,6 +8,7 @@
+
diff --git a/src/frontend/package.json b/src/frontend/package.json index b0424a02..6d949356 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -17,8 +17,10 @@ "@tanstack/react-query": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.0", "motion": "^12.4.2", + "immer": "^10.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-icons": "^5.4.0", "react-router-dom": "^7.1.5", "styled-components": "^6.1.14", "zustand": "^5.0.3" diff --git a/src/frontend/src/components/common/Modal/index.tsx b/src/frontend/src/components/common/Modal/index.tsx new file mode 100644 index 00000000..f62bd498 --- /dev/null +++ b/src/frontend/src/components/common/Modal/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import useModalStore from '../../../stores/modalStore'; +import { ModalType } from '../../../types'; + +import * as S from './styles'; + +interface ModalProps { + children: React.ReactNode; + name: ModalType; +} + +const Modal = ({ children, name }: ModalProps) => { + const { closeModal, closeAllModal } = useModalStore(); + + return ( + <> + closeModal(name, 'close-modal')} /> + + + closeAllModal()} /> + + {children} + + + ); +}; + +const Header = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; + +const Content = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; + +const Footer = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; + +Modal.Header = Header; +Modal.Content = Content; +Modal.Footer = Footer; + +export default Modal; diff --git a/src/frontend/src/components/common/Modal/styles.ts b/src/frontend/src/components/common/Modal/styles.ts new file mode 100644 index 00000000..6ff9056b --- /dev/null +++ b/src/frontend/src/components/common/Modal/styles.ts @@ -0,0 +1,66 @@ +import { TbX } from 'react-icons/tb'; +import styled from 'styled-components'; + +export const Overlay = styled.div` + position: fixed; + z-index: 1001; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + background-color: rgb(0 0 0 / 50%); +`; + +export const ModalContainer = styled.div` + position: fixed; + z-index: 1002; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + display: flex; + flex-direction: column; + + min-width: 49rem; + min-height: 36rem; + border-radius: 0.4rem; + + color: ${({ theme }) => theme.colors.white}; + + background-color: ${({ theme }) => theme.colors.dark[500]}; + + svg { + color: ${({ theme }) => theme.colors.white}; + } +`; + +export const HeaderWrapper = styled.div` + padding: 0 2.4rem; + text-align: center; +`; + +export const ContentWrapper = styled.div` + margin-top: 0.8rem; + padding: 0 2.4rem; +`; + +export const FooterWrapper = styled.div` + display: flex; + gap: 1.2rem; + align-items: center; + justify-content: center; + + margin-top: 1.6rem; +`; + +export const CloseButton = styled.div` + display: flex; + justify-content: end; + padding: 2.4rem 2.4rem 0; +`; + +export const CloseIcon = styled(TbX)` + cursor: pointer; +`; diff --git a/src/frontend/src/components/common/ModalRender/index.tsx b/src/frontend/src/components/common/ModalRender/index.tsx new file mode 100644 index 00000000..44a1cf1a --- /dev/null +++ b/src/frontend/src/components/common/ModalRender/index.tsx @@ -0,0 +1,28 @@ +import { Fragment, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useLocation } from 'react-router-dom'; + +import { useModalStore } from '../../../stores/modalStore'; + +const ModalRenderer = () => { + const location = useLocation(); + const { modal, closeAllModal } = useModalStore(); + + useEffect(() => { + closeAllModal(); + }, [location.pathname]); + + const portalRoot = document.getElementById('portal-root'); + if (!portalRoot) return null; + + const renderModals = () => { + return Object.entries(modal).flatMap(([type, typeModals]) => { + if (!typeModals) return null; + return {typeModals.content}; + }); + }; + + return createPortal(<>{renderModals()}, portalRoot); +}; + +export default ModalRenderer; diff --git a/src/frontend/src/components/guild/CreateGuildModalContent/index.tsx b/src/frontend/src/components/guild/CreateGuildModalContent/index.tsx new file mode 100644 index 00000000..1e0338ca --- /dev/null +++ b/src/frontend/src/components/guild/CreateGuildModalContent/index.tsx @@ -0,0 +1,27 @@ +import useFunnel from '@/hooks/useFunnel'; +import CreateGuildModal from '@/pages/FriendsPage/components/CreateGuildModal'; +import CustomizeGuildModal from '@/pages/FriendsPage/components/CustomizeGuildModal'; + +type CreateGuildSteps = '서버공개여부' | '서버커스텀'; +const STEP_SEQUENCE: CreateGuildSteps[] = ['서버공개여부', '서버커스텀']; + +const CreateGuildModalContent = () => { + const { Funnel, moveToNextStep, moveToPrevStep, currentStep } = useFunnel({ + defaultStep: '서버공개여부', + stepList: STEP_SEQUENCE, + }); + + return ( + + + + + + + + + + ); +}; + +export default CreateGuildModalContent; diff --git a/src/frontend/src/components/guild/GuildList/index.tsx b/src/frontend/src/components/guild/GuildList/index.tsx new file mode 100644 index 00000000..dadd590e --- /dev/null +++ b/src/frontend/src/components/guild/GuildList/index.tsx @@ -0,0 +1,30 @@ +import useModalStore from '@/stores/modalStore'; + +import CreateGuildModalContent from '../CreateGuildModalContent'; + +import * as S from './styles'; + +const GuildList = () => { + const { openModal } = useModalStore(); + + const handleChangeModal = () => { + openModal('basic', ); + }; + + return ( + + + + + {/* 서버 리스트 추가 예정 */} + + + + + + + + ); +}; + +export default GuildList; diff --git a/src/frontend/src/components/guild/GuildList/styles.ts b/src/frontend/src/components/guild/GuildList/styles.ts new file mode 100644 index 00000000..fc5bf75f --- /dev/null +++ b/src/frontend/src/components/guild/GuildList/styles.ts @@ -0,0 +1,59 @@ +import { BiCompass } from 'react-icons/bi'; +import { BsDiscord } from 'react-icons/bs'; +import { TbPlus } from 'react-icons/tb'; +import styled from 'styled-components'; + +export const GuildList = styled.nav` + display: flex; + flex-direction: column; + gap: 2rem; + + width: 8.4rem; + height: 100%; + padding: 2.2rem 1rem; + + background-color: ${({ theme }) => theme.colors.dark[800]}; +`; + +export const DMButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + + width: 6.2rem; + height: 6.2rem; + border-radius: 1.4rem; + + background-color: ${({ theme }) => theme.colors.blue}; +`; + +export const DiscordIcon = styled(BsDiscord)` + color: ${({ theme }) => theme.colors.white}; +`; + +export const CircleButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + + width: 6.25rem; + height: 6.2rem; + border-radius: 100%; + + background-color: ${({ theme }) => theme.colors.dark[600]}; +`; + +export const AddGuildButton = styled(CircleButton)` + /* 버튼별 역할 구별을 위해 분리 */ +`; +export const SearchCommunityButton = styled(CircleButton)` + /* 버튼별 역할 구별을 위해 분리 */ +`; + +export const PlusIcon = styled(TbPlus)` + color: ${({ theme }) => theme.colors.dark[400]}; +`; + +export const CompassIcon = styled(BiCompass)` + color: ${({ theme }) => theme.colors.dark[400]}; +`; diff --git a/src/frontend/src/components/guild/types/index.ts b/src/frontend/src/components/guild/types/index.ts new file mode 100644 index 00000000..1c10e7cc --- /dev/null +++ b/src/frontend/src/components/guild/types/index.ts @@ -0,0 +1 @@ +export type CreateGuildStep = 'initial' | 'customize'; diff --git a/src/frontend/src/components/layout/AuthFullLayout/index.tsx b/src/frontend/src/components/layout/AuthFullLayout/index.tsx index 07c0096b..7abc11b0 100644 --- a/src/frontend/src/components/layout/AuthFullLayout/index.tsx +++ b/src/frontend/src/components/layout/AuthFullLayout/index.tsx @@ -1,9 +1,11 @@ +import { Outlet } from 'react-router-dom'; + import * as S from './styles'; const AuthFullLayout = () => { return ( - + ); }; diff --git a/src/frontend/src/components/layout/AuthLayout.tsx b/src/frontend/src/components/layout/AuthLayout.tsx new file mode 100644 index 00000000..b5dd531c --- /dev/null +++ b/src/frontend/src/components/layout/AuthLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom'; + +const AuthLayout = () => { + return ( +
+ +
+ ); +}; + +export default AuthLayout; diff --git a/src/frontend/src/hooks/useFunnel.tsx b/src/frontend/src/hooks/useFunnel.tsx new file mode 100644 index 00000000..d1475e8e --- /dev/null +++ b/src/frontend/src/hooks/useFunnel.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; + +type UseFunnelProps = { + defaultStep: T; + stepList: T[]; +}; + +type StepProps = { + children: React.ReactNode; + name: T; +}; + +type FunnelProps = { + currentStep: T; + children: React.ReactElement>[]; +}; + +const Step = (stepProps: StepProps) => { + return <>{stepProps.children}; +}; + +const Funnel = ({ children, currentStep }: FunnelProps) => { + const targetStep = children.find((curStep) => curStep.props.name === currentStep); + if (!targetStep) { + throw new Error(`${currentStep} 단계에 해당하는 컴포넌트가 존재하지 않습니다.`); + } + return <>{targetStep}; +}; + +Funnel.Step = Step; + +const useFunnel = ({ defaultStep, stepList }: UseFunnelProps) => { + const [currentStep, setCurrentStep] = useState(defaultStep); + const currentIndex = stepList.indexOf(currentStep); + + if (!stepList.includes(defaultStep)) { + throw new Error('defaultStep은 반드시 stepList에 포함되어 있어야 합니다.'); + } + + const moveToNextStep = () => { + const hasNext = currentIndex < stepList.length - 1; + if (!hasNext) return; + setCurrentStep(stepList[currentIndex + 1]); + }; + + const moveToPrevStep = () => { + const hasPrev = currentIndex > 0; + if (!hasPrev) return; + setCurrentStep(stepList[currentIndex - 1]); + }; + + return { + Funnel, + Step, + currentStep, + moveToNextStep, + moveToPrevStep, + }; +}; + +export default useFunnel; diff --git a/src/frontend/src/pages/FriendsPage/components/CreateGuildModal/index.tsx b/src/frontend/src/pages/FriendsPage/components/CreateGuildModal/index.tsx new file mode 100644 index 00000000..69f54ebd --- /dev/null +++ b/src/frontend/src/pages/FriendsPage/components/CreateGuildModal/index.tsx @@ -0,0 +1,40 @@ +import { TbChevronRight } from 'react-icons/tb'; + +import Modal from '../../../../components/common/Modal'; +import { CaptionText, SmallButtonText } from '../../../../styles/Typography'; + +import * as S from './styles'; + +interface CreateGuildModalProps { + onNext: () => void; +} + +const CreateGuildModal = ({ onNext }: CreateGuildModalProps) => { + return ( + + + + 이 서버에 대해 더 자세히 말해주세요 + + + + 설정을 돕고자 질문을 드려요. 혹시 서버가 친구 몇 명만을 위한 서버인가요, 아니면 더 큰 커뮤니티를 위한 + 서버인가요? + + + + + 나와 친구들을 위한 서버 + + + + 커뮤니티용 서버 + + + + + + ); +}; + +export default CreateGuildModal; diff --git a/src/frontend/src/pages/FriendsPage/components/CreateGuildModal/styles.ts b/src/frontend/src/pages/FriendsPage/components/CreateGuildModal/styles.ts new file mode 100644 index 00000000..5e481167 --- /dev/null +++ b/src/frontend/src/pages/FriendsPage/components/CreateGuildModal/styles.ts @@ -0,0 +1,38 @@ +import styled from 'styled-components'; + +import { BodyMediumText } from '../../../../styles/Typography'; + +export const CreateGuildModal = styled.div` + display: flex; +`; + +export const CreateButtons = styled.div` + display: flex; + flex-direction: column; + gap: 1.6rem; + padding: 3.6rem 4.6rem 0; +`; + +const BaseGuildButton = styled.button` + display: flex; + align-items: center; + justify-content: space-between; + + padding: 0.9rem 2rem; + border: 1px solid ${({ theme }) => theme.colors.dark[400]}; + border-radius: 0.8rem; + + color: ${({ theme }) => theme.colors.white}; +`; + +export const CreatePrivateGuild = styled(BaseGuildButton)` + /* 버튼별 역할 구별을 위해 분리 */ +`; + +export const CreatePublicGuild = styled(BaseGuildButton)` + /* 버튼별 역할 구별을 위해 분리 */ +`; + +export const HeaderText = styled(BodyMediumText)` + font-size: 1.8rem; +`; diff --git a/src/frontend/src/pages/FriendsPage/components/CustomizeGuildModal/index.tsx b/src/frontend/src/pages/FriendsPage/components/CustomizeGuildModal/index.tsx new file mode 100644 index 00000000..b833cd2c --- /dev/null +++ b/src/frontend/src/pages/FriendsPage/components/CustomizeGuildModal/index.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { LuCamera } from 'react-icons/lu'; +import { TbPlus } from 'react-icons/tb'; + +import Modal from '../../../../components/common/Modal'; +import { BodyMediumText, CaptionText, ChipText, SmallText } from '../../../../styles/Typography'; + +import * as S from './styles'; + +interface CustomizeGuildModalProps { + onPrev: () => void; +} +const CustomizeGuildModal = ({ onPrev }: CustomizeGuildModalProps) => { + const [inputValue, setInputValue] = useState(''); + + const handleInputChange = (value: string) => { + setInputValue(value); + }; + + return ( + + + + 서버 커스터마이즈하기 + + + + + 새로운 서버에 이름과 아이콘을 부여해 개성을 드러내 보세요 나중에 언제든 바꿀 수 있어요 + + + + + UPLOAD + + + + + + + 서버 이름 + ) => handleInputChange(e.target.value)} + value={inputValue} + placeholder="서버이름을 입력해주세요" + /> + 서버를 만들면 Discord의 커뮤니티 지침에 동의하게 됩니다. + + + + + 뒤로가기 + 만들기 + + + + ); +}; + +export default CustomizeGuildModal; diff --git a/src/frontend/src/pages/FriendsPage/components/CustomizeGuildModal/styles.ts b/src/frontend/src/pages/FriendsPage/components/CustomizeGuildModal/styles.ts new file mode 100644 index 00000000..917ec8ed --- /dev/null +++ b/src/frontend/src/pages/FriendsPage/components/CustomizeGuildModal/styles.ts @@ -0,0 +1,106 @@ +import styled from 'styled-components'; + +import { CaptionText } from '../../../../styles/Typography'; + +export const CustomizeGuildModal = styled.div` + display: flex; +`; + +export const ContentContainer = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; +`; + +export const ImageUpLoad = styled.div` + position: relative; + + display: flex; + align-items: center; + justify-content: center; + + width: 8rem; + height: 8rem; + border: 1px dashed ${({ theme }) => theme.colors.dark[400]}; + border-radius: 100%; +`; + +export const UpLoadIcon = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; + +export const PlusIcon = styled.div` + position: absolute; + top: -0.2rem; + right: -0.2rem; + + display: flex; + align-items: center; + justify-content: center; + + width: 2.4rem; + height: 2.4rem; + border-radius: 100%; + + background-color: ${({ theme }) => theme.colors.blue}; +`; + +export const GuildNameWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 0.2rem; + align-items: start; + + width: 100%; +`; + +export const GuildNameInput = styled.input` + display: flex; + align-items: center; + + width: 100%; + height: 3rem; + padding: 0.4rem 0 0.4rem 0.8rem; + border: none; + border-radius: 0.4rem; + + color: ${({ theme }) => theme.colors.white}; + + background-color: ${({ theme }) => theme.colors.dark[700]}; + outline: none; +`; + +export const Caption = styled(CaptionText)` + color: ${({ theme }) => theme.colors.dark[400]}; +`; + +export const FooterContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + height: 5.4rem; + margin-top: auto; + padding: 0 2rem; + + background-color: ${({ theme }) => theme.colors.dark[600]}; +`; + +export const BackButton = styled.button` + color: ${({ theme }) => theme.colors.white}; + background-color: transparent; +`; + +export const CreateButton = styled.button` + width: 11rem; + height: 4rem; + border-radius: 0.8rem; + + color: ${({ theme }) => theme.colors.white}; + + background-color: ${({ theme }) => theme.colors.blue}; +`; diff --git a/src/frontend/src/pages/FriendsPage/index.tsx b/src/frontend/src/pages/FriendsPage/index.tsx new file mode 100644 index 00000000..dff8a5ec --- /dev/null +++ b/src/frontend/src/pages/FriendsPage/index.tsx @@ -0,0 +1,13 @@ +import GuildList from '../../components/guild/GuildList'; + +import * as S from './styles'; + +const FriendsPage = () => { + return ( + + + + ); +}; + +export default FriendsPage; diff --git a/src/frontend/src/pages/FriendsPage/styles.ts b/src/frontend/src/pages/FriendsPage/styles.ts new file mode 100644 index 00000000..288e7e7a --- /dev/null +++ b/src/frontend/src/pages/FriendsPage/styles.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components'; + +export const FriendsPage = styled.div` + width: 100%; + height: 100vh; + background-color: ${({ theme }) => theme.colors.dark[500]}; +`; diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index 8217a1dd..f2ab3a58 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -1,39 +1,56 @@ -import { createBrowserRouter } from 'react-router-dom'; +import { createBrowserRouter, Outlet } from 'react-router-dom'; +import ModalRenderer from './components/common/ModalRender'; import AuthFullLayout from './components/layout/AuthFullLayout'; import FullLayout from './components/layout/FullLayout'; import PublicOnlyLayout from './components/layout/PublicOnlyLayout'; +import FriendsPage from './pages/FriendsPage'; import LandingPage from './pages/LandingPage'; import LoginPage from './pages/LoginPage'; import RegisterPage from './pages/RegisterPage'; const router = createBrowserRouter([ { - element: , + element: ( + <> + + + + ), children: [ { - path: '/', - element: , + element: , + children: [ + { + path: '/', + element: , + }, + ], }, - ], - }, - { - element: , - children: [ { - path: '/login', - element: , + element: , + children: [ + { + path: '/login', + element: , + }, + { + path: '/register', + element: , + }, + ], }, { - path: '/register', - element: , + element: , + children: [ + { + path: '/friends', + element: , + }, + ], }, ], }, - { - element: , - children: [], - }, ]); export default router; diff --git a/src/frontend/src/stores/modalStore.ts b/src/frontend/src/stores/modalStore.ts new file mode 100644 index 00000000..33498e1b --- /dev/null +++ b/src/frontend/src/stores/modalStore.ts @@ -0,0 +1,40 @@ +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; + +import { BasePopUpData, BottomSheetType, ModalType } from '../types'; + +type ModalState = { + modal: { [K in ModalType]?: BasePopUpData }; + bottomSheet?: { [K in BottomSheetType]?: BasePopUpData }; +}; + +type ModalActions = { + openModal: (type: T, content: React.ReactNode) => void; + closeModal: (type: ModalType, key: string) => void; + closeAllModal: () => void; +}; + +export const useModalStore = create()( + immer((set) => ({ + modal: {}, + openModal: (type, content) => { + document.body.style.overflow = 'hidden'; + set((state) => { + state.modal[type] = { + content, + }; + }); + }, + closeModal: (type) => { + document.body.style.overflow = 'unset'; + set((state) => { + if (state.modal[type]) { + delete state.modal[type]; + } + }); + }, + closeAllModal: () => set({ modal: {} }), + })), +); + +export default useModalStore; diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 25e716a0..1c8cda23 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -3,3 +3,23 @@ export interface YearMonthDay { month: string; day: string; } + +export const ModalTypes = { + modal: { + basic: 'basic', + withFooter: 'withFooter', + }, + bottomSheet: { + basic: 'basic', + }, +} as const; + +type ValueOf = T[keyof T]; + +export type ModalType = ValueOf; +export type BottomSheetType = ValueOf; +export type PopupType = ModalType | BottomSheetType; + +export interface BasePopUpData { + content: React.ReactNode; +} diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index d53200c6..04682dc5 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -3583,6 +3583,11 @@ ignore@^7.0.3: resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.3.tgz#397ef9315dfe0595671eefe8b633fec6943ab733" integrity sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA== +immer@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" @@ -5008,6 +5013,11 @@ react-dom@^18.3.1: loose-envify "^1.1.0" scheduler "^0.23.2" +react-icons@^5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.4.0.tgz#443000f6e5123ee1b21ea8c0a716f6e7797f7416" + integrity sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ== + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"