[Project] Stormit

George·2022년 7월 1일
0

project

목록 보기
5/6
post-thumbnail

부트캠프 진행 중 게시판 기능을 가진 커뮤니티 어플리케이션을 구현 해보았습니다.

1. 기획


프로젝트 주제

  • 커뮤니티 어플리케이션

프로젝트 기간

  • 2022년 6월 20일(월) ~ 7월 01일(금) 12일 간 진행

기술 스택

  • Program Languege

    • JS / TS
  • Front-End

    • Core - React.js
    • State Management - React-Query
    • Styling - styled-component, Emotion
  • Back-End

    • nest JS

Package

  • react
  • @reduxjs/toolkit
  • axios
  • react-router-dom
  • styled-components
  • ckeditor
  • jsonwebtoken
  • nestjs
  • mysql
  • typeorm
  • passport
  • nodemailer

Mockup

ERD


2. Code review


redux toolkit관련

state에 보관하여 활용할 데이터들은 store 폴더를 따로 만들어 slice형태로 만들어 보관하는 것이 편리하다.

src/store/index.ts

import { configureStore } from "@reduxjs/toolkit";
import {
  TypedUseSelectorHook,
  useDispatch as useTypedDispatch,
  useSelector as useTypedSelector,
} from "react-redux";
import communitySlice from "./communitySlice";
import modalSlice from "./modalSlice";
import postSlice from "./postSlice";
import snackbarSlice from "./snackbarSlice";
import themeSlice from "./themeSlice";
import userSlice from "./userSlice";

export const store = configureStore({
  reducer: {
    user: userSlice.reducer,
    modal: modalSlice.reducer,
    theme: themeSlice.reducer,
    post: postSlice.reducer,
    snackbar: snackbarSlice.reducer,
    community: communitySlice.reducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export const useDispatch: () => AppDispatch = useTypedDispatch;
export const useSelector: TypedUseSelectorHook<RootState> = useTypedSelector;

유저에 대한 정보를 담는 슬라이스
src/store/userSlice.ts

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

interface userState {
  isLoggedIn: true | false | "init";
  userId: number;
  nickname: string;
  email: string;
}

const initialState: userState = {
  isLoggedIn: "init",
  userId: 0,
  nickname: "",
  email: "",
};

export const userSlice = createSlice({
  name: "user",
  initialState,
  reducers: {
    // 닉네임 변경 시 사용
    setNickname(state, action: PayloadAction<string>) {
      state.nickname = action.payload;
    },
    setLoggedIn(state) {
      state.isLoggedIn = true;
    },
    setLoggedOut(state) {
      state.isLoggedIn = false;
      state.nickname = "";
      state.email = "";
      state.userId = 0;
    },
    // 로그인 시 같이 호출해야함
    setUserInfo(
      state,
      action: PayloadAction<{
        email: string;
        nickname: string;
        userId: number;
      }>
    ) {
      state.userId = action.payload.userId;
      state.email = action.payload.email;
      state.nickname = action.payload.nickname;
    },
  },
});

export const userActions = { ...userSlice.actions };

export default userSlice;

component

자주쓰는 컴포넌트들은 컴포넌트 폴더 내 common 하위 폴더를 만들어 보관하는 것이 깔끔하고 편리하다.

다음 코드는 들어오는 인자 값에 따라 style를 변경해주는 버튼 컴포넌트다.
src/component/common/Button.tsx

import { css } from "styled-components";
import styled from "styled-components";
import React from "react";
import theme from "../../styles/theme";
import palette from "../../styles/palette";

const getButtonVariant = (variant?: "text" | "contained" | "outlined") => {
  switch (variant) {
    case "text":
      return css`
        color: ${theme.primary};

        &:hover {
          background-color: ${palette.blue[50]};
        }
      `;
    case "contained":
      return css`
        background-color: ${theme.primary};
        color: white;

        &:hover {
          background-color: ${palette.blue[600]};
        }
      `;
    case "outlined":
      return css`
        background-color: white;
        border: 1px solid ${theme.primary};
        color: ${theme.primary};

        &:hover {
          background-color: ${palette.blue[50]};
        }
      `;
  }
};

const getButtonDisabled = (disabled?: boolean) => {
  switch (disabled) {
    case true:
      return css`
        background-color: ${palette.gray[300]};
        color: ${palette.gray[400]};
        cursor: default;

        &:hover {
          background-color: ${palette.gray[300]};
        }
      `;
  }
};

const getButtonSize = (size?: "small" | "medium" | "large") => {
  switch (size) {
    case "small":
      return css`
        height: 2rem; // 36px
        padding: 0.5rem 1.2rem; // 8px 19.2px
        font-size: 0.75rem; // 12px
      `;
    case "medium":
      return css`
        height: 2.5rem; // 40px
        padding: 0.625rem 1.5rem; // 10px 24px
        font-size: 0.875rem; // 14px
      `;
    case "large":
      return css`
        height: 3rem; // 48px
        padding: 0.75rem 1.8rem; // 12px 28.8px
        font-size: 1rem; // 16px
      `;
  }
};

interface BaseProps {
  variant?: "text" | "contained" | "outlined";
  size?: "small" | "medium" | "large";
  disabled?: boolean;
  startIcon?: React.ReactNode;
  endIcon?: React.ReactNode;
}

const Base = styled.button<BaseProps>`
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 0.5rem; // 8px

  color: ${theme.primary};
  height: 2.5rem; // 40px
  padding: 0.625rem 1.5rem; // 10px 24px
  font-size: 0.875rem; // 14px
  font-weight: 500;
  border-radius: 10px;
  background: none;
  border: none;
  cursor: pointer;

  &:hover {
    background-color: ${palette.blue[100]};
  }

  // 아이콘이 있는 경우 버튼 사이즈에 따라 패딩이 달라져야함;

  ${({ startIcon }) =>
    startIcon &&
    css`
      padding-left: 1rem; // 16px
    `};

  ${({ endIcon }) =>
    endIcon &&
    css`
      padding-right: 1rem; // 16px
    `};

  ${({ variant }) => getButtonVariant(variant)};

  ${({ disabled }) => getButtonDisabled(disabled)};

  ${({ size }) => getButtonSize(size)};
`;

interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "text" | "contained" | "outlined";
  size?: "small" | "medium" | "large";
  disabled?: boolean;
  children: React.ReactNode;
  startIcon?: React.ReactNode;
  endIcon?: React.ReactNode;
}

const Button: React.FC<Props> = ({
  children,
  variant,
  disabled,
  size,
  startIcon,
  endIcon,
  ...props
}) => {
  return (
    <Base
      variant={variant}
      disabled={disabled}
      size={size}
      startIcon={startIcon}
      endIcon={endIcon}
      {...props}
    >
      {startIcon && startIcon}
      {children}
      {endIcon && endIcon}
    </Base>
  );
};

export default Button;

style

자주 쓰이는 컬러들은 팔레트 파일을 만들어 담아 두는 것이 활용하기 좋다.

src/styles/palette.ts

const palette = {
  gray: {
    50: "#FAFAFA",
    100: "#F5F5F5",
    200: "#E5E5E5",
    300: "#D4D4D4",
    400: "#A3A3A3",
    500: "#737373",
    600: "#525252",
    700: "#404040",
    800: "#262626",
    900: "#171717",
  },
  blue: {
    50: "#EFF6FF",
    100: "#DBEAFE",
    200: "#BFDBFE",
    300: "#93C5FD",
    400: "#60A5FA",
    500: "#3B82F6",
    600: "#2563EB",
    700: "#1D4ED8",
    800: "#1E40AF",
    900: "#1E3A8A",
  },
  green: {
    50: "#F0FDF4",
    100: "#DCFCE7",
    200: "#D9F99D",
    300: "#86EFAC",
    400: "#4ADE80",
    500: "#22C55E",
    600: "#16A34A",
    700: "#15803D",
    800: "#166534",
    900: "#14532D",
  },
  red: {
    50: "#FEF2F2",
    100: "#FEE2E2",
    200: "#FECACA",
    300: "#FCA5A5",
    400: "#F87171",
    500: "#EF4444",
    600: "#DC2626",
    700: "#B91C1C",
    800: "#991B1B",
    900: "#7F1D1D",
  },
  black: "#1A2027",
};

export default palette;

react 관련

특정 조건에 맞는 정규표현식
src/pages/SignUp.tsx

  // 닉네임 정규표현식
  const validateNickname = (nickname: string) => {
    const special = /[`~!@#$%^&*|\\\'\";:\/?]/gi;
    const regExp = /^(?=.*[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣]).{2,8}$/;
    if (special.test(nickname)) {
      return false;
    }
    return regExp.test(nickname);
  };

  // 이메일 정규표현식
  const validateEmail = (email: string) => {
    const regExp =
      /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
    return regExp.test(email);
  };

  // 패스워드 정규표현식 (특수문자 포함)
  const validatePassword = (password: string) => {
    const regExp = /^(?=.*[0-9a-zA-Z][$@!%*#?&]).{8,20}$/;
    return regExp.test(password);
  };

  // input value값에 따른 결과를 반영하는 함수
  const handleInputValue =
    (key: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
      const value = e.target.value;
      if (key === "email") {
        if (value === "") {
          setValidate({ ...validate, [key]: "none" });
        } else if (validateEmail(value)) {
          setValidate({ ...validate, [key]: "pass" });
        } else {
          setValidate({ ...validate, [key]: "fail" });
        }
      } else if (key === "nickname") {
        if (value === "") {
          setValidate({ ...validate, [key]: "none" });
        } else if (validateNickname(value)) {
          setValidate({ ...validate, [key]: "pass" });
        } else {
          setValidate({ ...validate, [key]: "fail" });
        }
      } else if (key === "password") {
        if (value === "") {
          setValidate({ ...validate, [key]: "none" });
          // input값이 없을 때 password 기본 타입으로 변경
          setPasswordType({ type: "password", visible: false });
        } else if (validatePassword(value)) {
          setValidate({ ...validate, [key]: "pass" });
        } else {
          setValidate({ ...validate, [key]: "fail" });
        }
      }
      setUserinfo({ ...userinfo, [key]: value });
    };

react-router-dom의 navigate를 활용하면 뒤로가기 기능, 홈으로 이동을 간단하게 구축할 수 있다.

<Button variant="outlined" onClick={() => navigate(-2)}>
   돌아가기
</Button>

아래의 코드는 로그인 상태면 홈으로 리다이렉트하는 코드이다.

  useEffect(() => {
    if (isLoggedIn) {
      navigate("/", { replace: true });
    }
  }, [isLoggedIn, navigate]);

3. 구현 화면


API list


4. 후기


이번 프로젝트에서 프론트엔드를 담당하게 되었는데, 같은 조원 분이 타입스크립트를 활용한 리액트에 관한 지식이 뛰어나서 많이 배울 수 있었고, 이전에 배웠던 next.js와 활용해서도 무언가를 만들어 보고 싶다는 생각이 들었습니다.

또한 api를 활용해서 server와 client간의 연결에서 어려움을 느껴 추후 express를 이용해서 유저인증 등을 연습 해야겠습니다.

폰트어썸으로 어렵게 아이콘을 불러왔는데 Material Design을 통해서 아이콘을 쉽게 가져올 수 있었고, 반응형 웹을 만드는데 필요한 정보들을 알게 되었습니다.


5. 참고자료



0개의 댓글