ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 2022.08.12(금)-2022.08.22(월)_Slack 클론 코딩 강의를 들으며(2)
    SPA/Slack 클론 코딩 2022. 8. 22. 17:50

    + SWR

    SWR은 데이터를 저장해주는 장점이 있지만 너무 자주 데이터를 요청하지 않도록 주의해야 한다고 한다.

    데이터를 너무 자주 요청하면 백엔드에게 안 좋으며 프론트 해커와 같은 행동을 하는 것과 다름없다고 한다.

     

    이는 revalidate를 mutate로 변환하면서 한 설명인데 우리는 이미 mutate를 사용하고 있으니 괜찮다.

     

    revalidate는 서버에 데이터를 요청해서 다시 가져오는 것이고

    mutate는 서버에 요청을 안 보내고 데이터를 수정하는 것이라고 한다.

     

    텍스트로만 봐도 mutate가 더 이득인 걸 알 수 있다.

    // pages/LogIn/index.tsx
    
     .then((response) => {
       mutate(response.data, false);
     })

    그래서 위와 같이 작성하면 수정된 데이터로 data를 변경해주는 것이 된다.

     

    false 부분은 데이터가 변경되었을 때 이를 확인해달라고 서버에 다시 요청을 보낼지 여부에 대한 boolean 타입인데

    우리는 이를 방지하려고 mutate를 사용한 것이므로 false로 처리한다.

     

    똑같은 원리로 layouts>Workspace.tsx 파일의 부분도 아래와 같이 변경해준다.

    // layouts/Workspace.tsx
      
    .then(() => {
      mutate(false);
    })

    로그아웃을 하기 위해서는 데이터가 false 처리가 되어야 하기 때문이다.

     

    여기서 추가적으로 설명해준 부분이 있는데

    optimistic ui는 요청한 사항이 성공할 거라고 가정하고 우선 그에 따른 반응을 한 뒤, 추후에 서버에 점검을 요청하는 것이다.

     

    이 예로 인스타가 있다.

    게시물 좋아요를 눌렀을 때 바로 색은 칠해지지만 만약 서버에서 제대로 요청이 안 되었을 경우, 다시 보면 해당 게시물 좋아요가 사라져있다.

     

    이 반대가 passimistic ui이다.

    무조건 서버에 점검을 받고 그에 따른 반응을 하는 것.

     

    SWR은 비동기 요청뿐만 아니라 데이터를 로컬 스토리지에 저장할 수도 있다.

     


     

    1. Workspace

    (1) Style

    이제 workspace를 본격적으로 만들어볼텐데 이에 필요한 css 코드는 laytouts>Workspace>style.tsx 파일을 생성하여 아래와 같이 작성한다.

    // laytouts/Workspace/style.tsx
    
    import styled from '@emotion/styled';
    
    export const RightMenu = styled.div`
      float: right;
    `;
    
    export const Header = styled.header`
      height: 38px;
      background: #350d36;
      color: #ffffff;
      box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.1);
      padding: 5px;
      text-align: center;
    `;
    
    export const ProfileImg = styled.img`
      width: 28px;
      height: 28px;
      position: absolute;
      top: 5px;
      right: 16px;
    `;
    
    export const ProfileModal = styled.div`
      display: flex;
      padding: 20px;
    
      & img {
        display: flex;
      }
    
      & > div {
        display: flex;
        flex-direction: column;
        margin-left: 10px;
      }
    
      & #profile-name {
        font-weight: bold;
        display: inline-flex;
      }
    
      & #profile-active {
        font-size: 13px;
        display: inline-flex;
      }
    `;
    
    export const LogOutButton = styled.button`
      border: none;
      width: 100%;
      border-top: 1px solid rgb(29, 28, 29);
      background: transparent;
      display: block;
      height: 33px;
      padding: 5px 20px 5px;
      outline: none;
      cursor: pointer;
    `;
    
    export const WorkspaceWrapper = styled.div`
      display: flex;
      flex: 1;
    `;
    
    export const Workspaces = styled.div`
      width: 65px;
      display: inline-flex;
      flex-direction: column;
      align-items: center;
      background: #3f0e40;
      border-top: 1px solid rgb(82, 38, 83);
      border-right: 1px solid rgb(82, 38, 83);
      vertical-align: top;
      text-align: center;
      padding: 15px 0 0;
    `;
    
    export const Channels = styled.nav`
      width: 260px;
      display: inline-flex;
      flex-direction: column;
      background: #3f0e40;
      color: rgb(188, 171, 188);
      vertical-align: top;
    
      & a {
        padding-left: 36px;
        color: inherit;
        text-decoration: none;
        height: 28px;
        line-height: 28px;
        display: flex;
        align-items: center;
    
        &.selected {
          color: white;
        }
      }
    
      & .bold {
        color: white;
        font-weight: bold;
      }
    
      & .count {
        margin-left: auto;
        background: #cd2553;
        border-radius: 16px;
        display: inline-block;
        font-size: 12px;
        font-weight: 700;
        height: 18px;
        line-height: 18px;
        padding: 0 9px;
        color: white;
        margin-right: 16px;
      }
    
      & h2 {
        height: 36px;
        line-height: 36px;
        margin: 0;
        text-overflow: ellipsis;
        overflow: hidden;
        white-space: nowrap;
        font-size: 15px;
      }
    `;
    
    export const WorkspaceName = styled.button`
      height: 64px;
      line-height: 64px;
      border: none;
      width: 100%;
      text-align: left;
      border-top: 1px solid rgb(82, 38, 83);
      border-bottom: 1px solid rgb(82, 38, 83);
      font-weight: 900;
      font-size: 24px;
      background: transparent;
      text-overflow: ellipsis;
      overflow: hidden;
      white-space: nowrap;
      padding: 0;
      padding-left: 16px;
      margin: 0;
      color: white;
      cursor: pointer;
    `;
    
    export const MenuScroll = styled.div`
      height: calc(100vh - 102px);
      overflow-y: auto;
    `;
    
    export const WorkspaceModal = styled.div`
      padding: 10px 0 0;
    
      & h2 {
        padding-left: 20px;
      }
    
      & > button {
        width: 100%;
        height: 28px;
        padding: 4px;
        border: none;
        background: transparent;
        border-top: 1px solid rgb(28, 29, 28);
        cursor: pointer;
    
        &:last-of-type {
          border-bottom: 1px solid rgb(28, 29, 28);
        }
      }
    `;
    
    export const Chats = styled.div`
      flex: 1;
    `;
    
    export const AddButton = styled.button`
      color: white;
      font-size: 24px;
      display: inline-block;
      width: 40px;
      height: 40px;
      background: transparent;
      border: none;
      cursor: pointer;
    `;
    
    export const WorkspaceButton = styled.button`
      display: inline-block;
      width: 40px;
      height: 40px;
      border-radius: 10px;
      background: white;
      border: 3px solid #3f0e40;
      margin-bottom: 15px;
      font-size: 18px;
      font-weight: 700;
      color: black;
      cursor: pointer;
    `;

    여기서 폴더 구조를 한번 바꿔주는데 App.tsx와 Workspace.tsx도 각 폴더를 생성하고 하위 파일의 index.tsx로 변경한다.

     

    (2) Gravatar

    보통 서비스를 이용할 때 프로필 이미지를 설정하지 않은 사람에게는 랜덤한 이미지가 부여된다.

    이를 적용되게 해주는 곳이 있는데 바로 Gravatar이다.

     

    Gravatar - 전세계적으로 인정된 아바타

    이미지에 국한되지 않습니다. 자신이 누구이며 사람들이 어디에서 자신을 찾을 수 있는지 즉시 웹에 알리세요. 그라바타에서는 링크, 사진, 연락처, 지갑 주소와 상세 정보를 표시할 수 있습니

    ko.gravatar.com

     

    사용하기 위해 터미널에서 설치해보자.

    npm i gravatar
    npm i @types/gravatar

     

    그리고 상단에서 gravatar를 import하고 아래와 같이 구조를 잡아주면

    // layouts/Workspace/index.tsx
     
      return (
        <div>
          <Header>
            <RightMenu>
              <span>
                <ProfileImg src={gravatar.url(data.email, {s: '28px', d: 'retro'})} alt={data.nickname}/>
              </span>
            </RightMenu>
          </Header>
          <button onClick={onLogout}>로그아웃</button>
          {children}
        </div>
      )

     

    아래와 같이 프로필 이미지가 잘 설정되었다.

    프로필 이미지의 스타일은 여러가지인데 공식 문서에서 확인하면 된다.

     

    이어서 간단하게 Workspace 화면 중 바뀌지 않는 부분(헤더, 왼쪽 탭메뉴)을 구성해보자.

    // layouts/Workspace/index.tsx
     
    import React, { FC, useCallback } from "react";
    import useSWR from "swr";
    import fetcher from "@utils/fetcher";
    import axios from "axios";
    import { Redirect } from "react-router";
    import { Header, ProfileImg, RightMenu, WorkspaceWrapper, Workspaces, Channels, Chats, WorkspaceName, MenuScroll } from "./style";
    import gravatar from 'gravatar'
    
    const Workspace :FC = ({children}) => {
      const {data, error, mutate} = useSWR('http://localhost:3095/api/users', fetcher);
    
      const onLogout = useCallback(() => {
        axios.post('http://localhost:3095/api/users/logout', null, {
          withCredentials: true
        })
        .then(() => {
          mutate(false);
        })
      }, [])
    
      if(!data) {
        return <Redirect to='/login'/>
      }
    
      return (
        <div>
          <Header>
            <RightMenu>
              <span>
                <ProfileImg src={gravatar.url(data.email, {s: '28px', d: 'retro'})} alt={data.nickname}/>
              </span>
            </RightMenu>
          </Header>
          <button onClick={onLogout}>로그아웃</button>
          <WorkspaceWrapper>
            <Workspaces>test</Workspaces>
            <Channels>
              <WorkspaceName>Sleact</WorkspaceName>
              <MenuScroll>menu scroll</MenuScroll>
            </Channels>
            <Chats>Chat</Chats>
          </WorkspaceWrapper>
          {children}
        </div>
      )
    }
    
    export default Workspace

     


     

    2. Channel & DirectMessage

    Workspace는 component처럼 재사용되는 부분이기에 layouts 폴더 내에 위치했고

    이제는 Channel과 DirectMessage 부분을 pages에서 작업해볼 것이다.

     

    Channel은 이미 생성되어있고 DirectMessage 폴더를 생성하여 하위에 index.tsx 파일을 생성하여 아래와 같이 입력한다.

    // pages/DirectMessage/index.tsx
    
    import Workspace from "@layouts/Workspace";
    import React from "react";
    
    const DirectMessage = () => {
      return (
        <Workspace>
          <div>로그인하신 것을 축하드려요!</div>
        </Workspace>
      )
    }
    
    export default DirectMessage;

     

    지금까지는 Channel과 DirectMessage가 Workspace에 감싸져 있어서 공동 레이아웃이 보여지고 있다면

    이 부분을 지우고

    // layouts/App/index.tsx
    
    import React from "react"
    import loadable from '@loadable/component';
    import { Switch, Route, Redirect } from "react-router"
    
    const LogIn = loadable(() => import('@pages/LogIn'));
    const SignUp = loadable(() => import('@pages/SignUp'));
    const Workspace = loadable(() => import('@layouts/Workspace'));
    
    const App = () => {
      return (
        <Switch>
          <Redirect exact path='/' to='/login'/>
          <Route path='/login' component={LogIn}/>
          <Route path='/signup' component={SignUp}/>
          <Route path='/workspace' component={Workspace}/>
        </Switch>
      )
    }
    
    export default App

    반대로 Workspace에서 판단하도록 변경해보자.

    // layouts/Workspace/index.tsx
    
    import React, { FC, useCallback } from "react";
    import useSWR from "swr";
    import fetcher from "@utils/fetcher";
    import axios from "axios";
    import { Redirect, Switch, Route } from "react-router";
    import { Header, ProfileImg, RightMenu, WorkspaceWrapper, Workspaces, Channels, Chats, WorkspaceName, MenuScroll } from "./style";
    import gravatar from 'gravatar'
    import loadable from "@loadable/component";
    
    const Channel = loadable(() => import('@pages/Channel'));
    const DirectMessage = loadable(() => import('@pages/DirectMessage'));
    
    const Workspace :FC = ({children}) => {
      const {data, error, mutate} = useSWR('http://localhost:3095/api/users', fetcher);
    
      const onLogout = useCallback(() => {
        axios.post('http://localhost:3095/api/users/logout', null, {
          withCredentials: true
        })
        .then(() => {
          mutate(false);
        })
      }, [])
    
      if(!data) {
        return <Redirect to='/login'/>
      }
    
      return (
        <div>
          <Header>
            <RightMenu>
              <span>
                <ProfileImg src={gravatar.url(data.email, {s: '28px', d: 'retro'})} alt={data.nickname}/>
              </span>
            </RightMenu>
          </Header>
          <button onClick={onLogout}>로그아웃</button>
          <WorkspaceWrapper>
            <Workspaces>test</Workspaces>
            <Channels>
              <WorkspaceName>Sleact</WorkspaceName>
              <MenuScroll>menu scroll</MenuScroll>
            </Channels>
            <Chats>
              <Switch>
              <Route path='/workspace/channel' component={Channel}/>
              <Route path='/workspace/dm' component={DirectMessage}/>
              </Switch>
            </Chats>
          </WorkspaceWrapper>
        </div>
      )
    }
    
    export default Workspace

     

    이어서 Channel을 본격적으로 만들기 위해 Channel 폴더 하위에 style.tsx 파일을 생성하고 아래와 같이 작성해주자.

    // pages/Channel/style.tsx
    
    import styled from '@emotion/styled';
    
    export const Container = styled.div`
      display: flex;
      flex-wrap: wrap;
      height: calc(100vh - 38px);
      flex-flow: column;
      position: relative;
    `;
    
    export const Header = styled.header`
      height: 64px;
      display: flex;
      width: 100%;
      --saf-0: rgba(var(--sk_foreground_low, 29, 28, 29), 0.13);
      box-shadow: 0 1px 0 var(--saf-0);
      padding: 20px 16px 20px 20px;
      font-weight: bold;
      align-items: center;
    `;
    
    export const DragOver = styled.div`
      position: absolute;
      top: 64px;
      left: 0;
      width: 100%;
      height: calc(100% - 64px);
      background: white;
      opacity: 0.7;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 40px;
    `;

    그리고 pages>Channel>index.tsx에서 import해서 적용시키면 된다.

     // pages/Channel/index.tsx
     
    import React from "react";
    import { Container, Header } from "./style";
    
    const Channel = () => {
      return (
          <Container>
            <Header>채널!</Header>
          </Container>
      )
    }
    
    export default Channel;

     


     

    3. Menu

    (1) index.tsx & style.tsx

    이번엔 여기저기서 사용되는 메뉴를 만들기 위해 components 폴더에 Menu 폴더를 생성하고 하위에 index.tsx와 style.tsx 파일을 생성해보자.

    // components/Menu/style.tsx
    
    import styled from '@emotion/styled';
    
    export const CreateMenu = styled.div`
      position: fixed;
      top: 0;
      right: 0;
      left: 0;
      bottom: 0;
      z-index: 1000;
    
      & > div {
        position: absolute;
        display: inline-block;
        --saf-0: rgba(var(--sk_foreground_low, 29, 28, 29), 0.13);
        box-shadow: 0 0 0 1px var(--saf-0), 0 4px 12px 0 rgba(0, 0, 0, 0.12);
        background-color: rgba(var(--sk_foreground_min_solid, 248, 248, 248), 1);
        border-radius: 6px;
        user-select: none;
        min-width: 360px;
        z-index: 512;
        max-height: calc(100vh - 20px);
        color: rgb(29, 28, 29);
      }
    `;
    
    export const CloseModalButton = styled.button`
      position: absolute;
      right: 10px;
      top: 6px;
      background: transparent;
      border: none;
      font-size: 30px;
      cursor: pointer;
    `;
    // components/Menu/index.tsx
    
    import { CSSProperties } from "@emotion/serialize";
    import React, { FC, useCallback } from "react";
    import { CloseModalButton, CreateMenu } from "./style";
    
    interface Props {
      style: CSSProperties;
      show: boolean;
      onCloseModal: () => void;
      closeButton?: boolean
    }
    
    const Menu: FC<Props> = ({children, style, show, onCloseModal, closeButton}) => {
      const stopPropagation = useCallback((e) => {
        e.stopPropagation()
      }, [])
    
      return (
        <CreateMenu onClick={onCloseModal}>
          <div style={{right: 0, top: 38}} onClick={stopPropagation}>
            {closeButton && <CloseModalButton onClick={onCloseModal}>&times;</CloseModalButton>}
            {children}
          </div>
        </CreateMenu>
      )
    }
    
    Menu.defaultProps = {
      closeButton: true
    }
    
    export default Menu

    메뉴는 다양한 곳에서 사용되기에 고정값이 아닌, 사용되는 하위 Component에서 props로 각각의 값을 받아와 적용되도록 한다.

     

    이어서 Workspace에서 Menu를 사용해보자.

    // layouts/Workspace/index.tsx
    
    import React, { FC, useCallback, useState } from "react";
    import useSWR from "swr";
    import fetcher from "@utils/fetcher";
    import axios from "axios";
    import { Redirect, Switch, Route } from "react-router";
    import { Header, ProfileImg, RightMenu, WorkspaceWrapper, Workspaces, Channels, Chats, WorkspaceName, MenuScroll, ProfileModal, LogOutButton } from "./style";
    import gravatar from 'gravatar'
    import loadable from "@loadable/component";
    import Menu from "@components/Menu";
    
    const Channel = loadable(() => import('@pages/Channel'));
    const DirectMessage = loadable(() => import('@pages/DirectMessage'));
    
    const Workspace :FC = ({children}) => {
      const [showUserMenu, setShowUserMenu] = useState(false)
      const {data, error, mutate} = useSWR('http://localhost:3095/api/users', fetcher);
    
      const onLogout = useCallback(() => {
        axios.post('http://localhost:3095/api/users/logout', null, {
          withCredentials: true
        })
        .then(() => {
          mutate(false);
        })
      }, [])
    
      const onClickUserProfile = useCallback(() => {
        setShowUserMenu((prev) => !prev)
      }, [])
    
      if(!data) {
        return <Redirect to='/login'/>
      }
    
      return (
        <div>
          <Header>
            <RightMenu>
              <span onClick={onClickUserProfile}>
                <ProfileImg src={gravatar.url(data.email, {s: '28px', d: 'retro'})} alt={data.nickname}/>
                {showUserMenu && (
                <Menu style={{right: 0, top: 38}} show={showUserMenu} onCloseModal={onClickUserProfile}>
                  <ProfileModal>
                    <img src={gravatar.url(data.email, {s: '30px', d: 'retro'})} alt={data.nickname}/>
                    <div>
                      <span id="profile-name">{data.nickname}</span>
                      <span id="profile-active">Active</span>
                    </div>
                  </ProfileModal>
                  <LogOutButton onClick={onLogout}>로그아웃</LogOutButton>
                </Menu>
                )}
              </span>
            </RightMenu>
          </Header>
          <WorkspaceWrapper>
            <Workspaces>test</Workspaces>
            <Channels>
              <WorkspaceName>Sleact</WorkspaceName>
              <MenuScroll>menu scroll</MenuScroll>
            </Channels>
            <Chats>
              <Switch>
                <Route path='/workspace/channel' component={Channel}/>
                <Route path='/workspace/dm' component={DirectMessage}/>
              </Switch>
            </Chats>
          </WorkspaceWrapper>
        </div>
      )
    }
    
    export default Workspace

     

    그럼 아래와 같이 프로필 이미지를 클릭했을 때 메뉴 Component가 잘 나타나는 것을 확인할 수 있다.

     

    (2) Workspace 메뉴

    이번엔 좌측에 있는 Workspace메뉴를 만들어볼 것이다.

    // layouts/Workspace/index.tsx
    
    import React, { FC, useCallback, useState } from "react";
    import useSWR from "swr";
    import fetcher from "@utils/fetcher";
    import axios from "axios";
    import { Redirect, Switch, Route } from "react-router";
    import { Link } from "react-router-dom";
    import { Header, ProfileImg, RightMenu, WorkspaceWrapper, Workspaces, Channels, Chats, WorkspaceName, MenuScroll, ProfileModal, LogOutButton, WorkspaceButton, AddButton } from "./style";
    import gravatar from 'gravatar'
    import loadable from "@loadable/component";
    import Menu from "@components/Menu";
    
    const Channel = loadable(() => import('@pages/Channel'));
    const DirectMessage = loadable(() => import('@pages/DirectMessage'));
    
    const Workspace :FC = ({children}) => {
      const [showUserMenu, setShowUserMenu] = useState(false)
      const {data:userData, error, mutate} = useSWR('http://localhost:3095/api/users', fetcher);
    
      const onLogout = useCallback(() => {
        axios.post('http://localhost:3095/api/users/logout', null, {
          withCredentials: true
        })
        .then(() => {
          mutate(false);
        })
      }, [])
    
      const onClickUserProfile = useCallback(() => {
        setShowUserMenu((prev) => !prev)
      }, [])
    
      const onClickCreateWorkspace = useCallback(() => {
    
      }, [])
    
      if(!userData) {
        return <Redirect to='/login'/>
      }
    
      return (
        <div>
          <Header>
            <RightMenu>
              <span onClick={onClickUserProfile}>
                <ProfileImg src={gravatar.url(userData.email, {s: '28px', d: 'retro'})} alt={userData.nickname}/>
                {showUserMenu && (
                <Menu style={{right: 0, top: 38}} show={showUserMenu} onCloseModal={onClickUserProfile}>
                  <ProfileModal>
                    <img src={gravatar.url(userData.email, {s: '30px', d: 'retro'})} alt={userData.nickname}/>
                    <div>
                      <span id="profile-name">{userData.nickname}</span>
                      <span id="profile-active">Active</span>
                    </div>
                  </ProfileModal>
                  <LogOutButton onClick={onLogout}>로그아웃</LogOutButton>
                </Menu>
                )}
              </span>
            </RightMenu>
          </Header>
          <WorkspaceWrapper>
            <Workspaces>
              {userData?.Workspaces.map((ws) => {
                return (
                  <Link key={ws.id} to={`/workspace/${123}/channel/일반`}>
                    <WorkspaceButton>{ws.name.slice(0, 1).toUpperCase()}</WorkspaceButton>
                  </Link>
                )
              })}
              <AddButton onClick={onClickCreateWorkspace}>+</AddButton>
            </Workspaces>
            <Channels>
              <WorkspaceName>Sleact</WorkspaceName>
              <MenuScroll>menu scroll</MenuScroll>
            </Channels>
            <Chats>
              <Switch>
                <Route path='/workspace/channel' component={Channel}/>
                <Route path='/workspace/dm' component={DirectMessage}/>
              </Switch>
            </Chats>
          </WorkspaceWrapper>
        </div>
      )
    }
    
    export default Workspace

    아래 코드 부분은 원래 slack에서 workspace마다 썸네일을 따로 설정하지 않았을 경우, 해당 workspace명의 앞글자를 썸네일로 사용하기에 이를 설정해준 것이다.

    <WorkspaceButton>{ws.name.slice(0, 1).toUpperCase()}</WorkspaceButton>

     

    이 작업 중 data를 userData라는 이름으로 변경하여 사용하였다.

    이렇듯 정해진 변수의 이름을 다른 이름으로 변경하여 사용하는 것도 가능하다.

     

    (3) Data type 설정

    그러고 화면을 보면 에러가 발생할텐데 이는 우리가 가져온 데이터의 type이 무엇인지 따로 정의해주지 않았기에 발생한다.

     

    typings 폴더 내에 db.ts 파일을 생성하고 아래와 같이 작성하자.

    // typings/db.ts
    
    export interface IUser {
      id: number;
      nickname: string;
      email: string;
      Workspaces: IWorkspace[];
    }
    
    export interface IUserWithOnline extends IUser {
      online: boolean;
    }
    
    export interface IChannel {
      id: number;
      name: string;
      private: boolean; // 비공개 채널 여부, 강좌에서는 모두 false(공개)
      WorkspaceId: number;
    }
    
    export interface IChat {
      // 채널의 채팅
      id: number;
      UserId: number;
      User: IUser; // 보낸 사람
      content: string;
      createdAt: Date;
      ChannelId: number;
      Channel: IChannel;
    }
    
    export interface IDM {
      // DM 채팅
      id: number;
      SenderId: number; // 보낸 사람 아이디
      Sender: IUser;
      ReceiverId: number; // 받는 사람 아이디
      Receiver: IUser;
      content: string;
      createdAt: Date;
    }
    
    export interface IWorkspace {
      id: number;
      name: string;
      url: string; // 주소 창에 보이는 주소
      OwnerId: number; // 워크스페이스 만든 사람 아이디
    }

     

    그리고 Workspace index.tsx 파일에서 useSWR을 사용하는 부분에 저렇게 type을 추가해주면 된다.

    // layouts/Workspace/index.tsx
    
    const {data:userData, error, mutate} = useSWR<IUser | false>('http://localhost:3095/api/users', fetcher);

    그럼 이렇게 slack처럼 좌측에 workspace 메뉴가 생성된다.

     

    (4) 새 Workspace 만들기

    이번엔 새 Workspace를 생성하려 할 때 노출되는 모달창을 만들어보자.

    // layouts/Workspace/index.tsx
    
    import React, { FC, useCallback, useState } from "react";
    import useSWR from "swr";
    import fetcher from "@utils/fetcher";
    import axios from "axios";
    import { Redirect, Switch, Route } from "react-router";
    import { Link } from "react-router-dom";
    import { Header, ProfileImg, RightMenu, WorkspaceWrapper, Workspaces, Channels, Chats, WorkspaceName, MenuScroll, ProfileModal, LogOutButton, WorkspaceButton, AddButton } from "./style";
    import gravatar from 'gravatar'
    import loadable from "@loadable/component";
    import Menu from "@components/Menu";
    import { IUser } from "@typings/db";
    import Modal from "@components/Modal";
    import { Input, Label, Button } from "@pages/SignUp/style";
    import useInput from "@hooks/useInput";
    
    const Channel = loadable(() => import('@pages/Channel'));
    const DirectMessage = loadable(() => import('@pages/DirectMessage'));
    
    const Workspace :FC = ({children}) => {
      const [showUserMenu, setShowUserMenu] = useState(false)
      const [showCreateWorkspaceModal, setShowCreateWorkspaceModal] = useState(false)
      const [newWorkspace, onChangeNewWorkspace, setNewWorkspace]= useInput('')
      const [newUrl, onChangeNewUrl, setNewUrl]= useInput('')
      const {data:userData, error, mutate} = useSWR<IUser | false>('http://localhost:3095/api/users', fetcher);
    
      const onLogout = useCallback(() => {
        axios.post('http://localhost:3095/api/users/logout', null, {
          withCredentials: true
        })
        .then(() => {
          mutate(false);
        })
      }, [])
    
      const onClickUserProfile = useCallback(() => {
        setShowUserMenu((prev) => !prev)
      }, [])
    
      const onClickCreateWorkspace = useCallback(() => {
        setShowCreateWorkspaceModal(true)
      }, [])
    
      const onCreateWorkspace = useCallback(() => {
    
      }, [])
    
      const onCloseModal = useCallback(() => {
        setShowCreateWorkspaceModal(false)
      }, [])
    
      if(!userData) {
        return <Redirect to='/login'/>
      }
    
      return (
        <div>
          <Header>
            <RightMenu>
              <span onClick={onClickUserProfile}>
                <ProfileImg src={gravatar.url(userData.email, {s: '28px', d: 'retro'})} alt={userData.nickname}/>
                {showUserMenu && (
                <Menu style={{right: 0, top: 38}} show={showUserMenu} onCloseModal={onClickUserProfile}>
                  <ProfileModal>
                    <img src={gravatar.url(userData.email, {s: '30px', d: 'retro'})} alt={userData.nickname}/>
                    <div>
                      <span id="profile-name">{userData.nickname}</span>
                      <span id="profile-active">Active</span>
                    </div>
                  </ProfileModal>
                  <LogOutButton onClick={onLogout}>로그아웃</LogOutButton>
                </Menu>
                )}
              </span>
            </RightMenu>
          </Header>
          <WorkspaceWrapper>
            <Workspaces>
              {userData?.Workspaces.map((ws) => {
                return (
                  <Link key={ws.id} to={`/workspace/${123}/channel/일반`}>
                    <WorkspaceButton>{ws.name.slice(0, 1).toUpperCase()}</WorkspaceButton>
                  </Link>
                )
              })}
              <AddButton onClick={onClickCreateWorkspace}>+</AddButton>
            </Workspaces>
            <Channels>
              <WorkspaceName>Sleact</WorkspaceName>
              <MenuScroll>menu scroll</MenuScroll>
            </Channels>
            <Chats>
              <Switch>
                <Route path='/workspace/channel' component={Channel}/>
                <Route path='/workspace/dm' component={DirectMessage}/>
              </Switch>
            </Chats>
          </WorkspaceWrapper>
          <Modal show={showCreateWorkspaceModal} onCloseModal={onCloseModal}>
            <form onSubmit={onCreateWorkspace}>
              <Label id="workspace-label">
                <span>워크스페이스 이름</span>
                <Input id="workspace" value={newWorkspace} onChange={onChangeNewWorkspace}/>
              </Label>
              <Label id="workspace-url-label">
                <span>워크스페이스 url</span>
                <Input id="workspace" value={newUrl} onChange={onChangeNewUrl}/>
              </Label>
              <Button type="submit">생성하기</Button>
            </form>
          </Modal>
        </div>
      )
    }
    
    export default Workspace

    새 Workspace를 생성할 때 필요한 정보는 워크스페이스 이름과 url이므로 이를 입력받는 form을 삽입한다.

     

    그리고 Modal 폴더에 style.tsx 파일을 생성하여 아래와 같이 작성하자.

    // component/Modal/style.tsx
    
    import styled from '@emotion/styled';
    
    export const CreateModal = styled.div`
      position: fixed;
      text-align: center;
      left: 0;
      bottom: 0;
      top: 0;
      right: 0;
      z-index: 1022;
    
      & > div {
        margin-top: 200px;
        display: inline-block;
        width: 440px;
        background: white;
        --saf-0: rgba(var(--sk_foreground_low, 29, 28, 29), 0.13);
        box-shadow: 0 0 0 1px var(--saf-0), 0 4px 12px 0 rgba(0, 0, 0, 0.12);
        background-color: rgba(var(--sk_foreground_min_solid, 248, 248, 248), 1);
        border-radius: 6px;
        user-select: none;
        max-width: 440px;
        padding: 30px 40px 0;
        z-index: 1012;
        position: relative;
      }
    `;
    
    export const CloseModalButton = styled.button`
      position: absolute;
      right: 10px;
      top: 6px;
      background: transparent;
      border: none;
      font-size: 30px;
      cursor: pointer;
    `;

    그리고 이를 import해서 style component를 적용시켜주자.

    // component/Modal/index.tsx
    
    import React, { useCallback, FC } from "react";
    import { CloseModalButton, CreateModal } from "./style";
    
    interface Props {
      show: boolean;
      onCloseModal: () => void
    }
    const Modal: FC<Props> = ({show, children, onCloseModal}) => {
      const stopPropagation = useCallback((e) => {
        e.stopPropagation();
      }, [])
    
      if(!show) {
        return null;
      }
    
      return(
        <CreateModal onClick={onCloseModal}>
          <div onClick={stopPropagation}>
            <CloseModalButton onClick={onCloseModal}>&times;</CloseModalButton>
            {children}
          </div>
        </CreateModal>
      )
    }
    
    export default Modal

     

    그럼 좌측 + 버튼을 눌렀을 때 아래와 같이 workspace를 생성하는 모달창이 잘 뜨고 X버튼이나 바깥쪽을 눌렀을 때에는 사라지는 것을 확인할 수 있다.

    근데 이걸 확인하던 중 프로필 메뉴는 바깥쪽을 눌러도 안 사라지는 걸 확인했다.

    알고보니 이벤트 버블링으로 인해 두번 실행되어 boolean값이 원 상태로 돌아왔던 것이다. (true => true)

     

    그러니 버블링을 막도록 stopPropagation을 넣어주면

    // layouts/Workspace/index.tsx
    
      const onClickUserProfile = useCallback((e) => {
        e.stopPropagation()
        setShowUserMenu((prev) => !prev)
      }, [])

    아래와 같은 추가적인 에러가 발생하는데 Typesciprt 에러이다.

    어떻게 해결하면 될지 다 알려준다. (타입스크립트 짱)

    그대로 해준다.

    // Components/Menu/index.tsx
    
    interface Props {
      style: CSSProperties;
      show: boolean;
      onCloseModal: (e:any) => void;
      closeButton?: boolean
    }

    그럼 이제 메뉴 바깥쪽을 눌러도 사라진다.

     

    이제 workspace를 생성하는 기능을 만들어보자.

    // layouts/Workspace/index.tsx
    
      const onCreateWorkspace = useCallback((e) => {
        e.preventDefault();
        if(!newWorkspace || !newWorkspace.trim()) {
          return;
        }
        if(!newUrl || !newUrl.trim()) {
          return;
        }
        axios
        .post('/api/workspaces', {
          workspace: newWorkspace,
          url: newUrl
        })
        .then(() => {
          mutate()
          setShowCreateWorkspaceModal(false)
          setNewWorkspace('')
          setNewUrl('')
        })
        .catch((error) => {
          console.dir(error);
        })
      }, [newWorkspace, newUrl])

    input창을 비워놓고 버튼을 눌렀을 때를 대비하여 조건문으로 막아주고

    입력된 값들을 api에 전달하는 게 성공한 다음에는 모달창도 닫아주고 입력되어 변경된 값들도 초기화시켜준다.

     

    그리고 만약 에러가 났을 경우에 사용자가 알 수 있도록 보여지게 할텐데 이를 위한 패키지를 따로 설치하자.

    npm i react-toastify

    말 그대로 토스트 기계에서 토스트가 밑에서부터 튀어나오듯이 얘도 에러메시지를 밑에서부터 튀어나오게 하는 친구라고 한다.

     

    상단에서 toast를 import하고 아래와 같이 catch 부분을 수정해준다.

    // layouts/Workspace/index.tsx
    
        .catch((error) => {
          console.dir(error);
          toast.error(error.response?.data, {position: 'bottom-center'})
        })

     

    그러고 생성하기 버튼을 눌러보니 Network에서 에러가 발생했다.

    post하는 경로의 문제이므로 아래와 같이 수정하고 withCredentials도 추가하자.

    // layouts/Workspace/index.tsx
    
        .post('http://localhost:3095/api/workspaces', {
          workspace: newWorkspace,
          url: newUrl
        }, {
          withCredentials: true
        })
        .then(() => {

     

    그럼 아래와 같이 잘 작동하며 우리가 생성한 '테스트' 워크스페이스의 앞글자 '테'가 썸네일로 보이는 걸 확인할 수 있다.

     

    (5) Channel 생성

    보통 input이 있으면 값을 입력받고 변경될 때마다 실행되기에(리렌더링) 효율성을 위해 따로 component로 빼두는 게 낫다.

    그래서 우선 Channel 생성 모달을 따로 component로 생성하는 작업을 진행할까 한다.

     

    어디까지 수정되었는지 기억이 안 나므로 layouts>Workspace>index.tsx 전체 파일 내용을 올린다.

    // layouts/Workspace/index.tsx
    
    import React, { VFC, useCallback, useState } from "react";
    import useSWR from "swr";
    import fetcher from "@utils/fetcher";
    import axios from "axios";
    import { Redirect, Switch, Route } from "react-router";
    import { Link } from "react-router-dom";
    import { Header, ProfileImg, RightMenu, WorkspaceWrapper, Workspaces, Channels, Chats, WorkspaceName, MenuScroll, ProfileModal, LogOutButton, WorkspaceButton, AddButton, WorkspaceModal } from "./style";
    import gravatar from 'gravatar'
    import loadable from "@loadable/component";
    import Menu from "@components/Menu";
    import { IUser } from "@typings/db";
    import Modal from "@components/Modal";
    import { Input, Label, Button } from "@pages/SignUp/style";
    import useInput from "@hooks/useInput";
    import { toast } from "react-toastify";
    import CreateChannelModal from '@components/CreateChannelModal'
    
    const Channel = loadable(() => import('@pages/Channel'));
    const DirectMessage = loadable(() => import('@pages/DirectMessage'));
    
    const Workspace :VFC = () => {
      const [showUserMenu, setShowUserMenu] = useState(false)
      const [showCreateWorkspaceModal, setShowCreateWorkspaceModal] = useState(false)
      const [showWorkspace, setShowWorkspace] = useState(false)
      const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
      const [newWorkspace, onChangeNewWorkspace, setNewWorkspace]= useInput('')
      const [newUrl, onChangeNewUrl, setNewUrl]= useInput('')
      const {data:userData, error, mutate} = useSWR<IUser | false>('http://localhost:3095/api/users', fetcher);
    
      const onLogout = useCallback(() => {
        axios.post('http://localhost:3095/api/users/logout', null, {
          withCredentials: true
        })
        .then(() => {
          mutate(false);
        })
      }, [])
    
      const onClickUserProfile = useCallback((e) => {
        e.stopPropagation()
        setShowUserMenu((prev) => !prev)
      }, [])
    
      const onClickCreateWorkspace = useCallback(() => {
        setShowCreateWorkspaceModal(true)
      }, [])
    
      const onCreateWorkspace = useCallback((e) => {
        e.preventDefault();
        if(!newWorkspace || !newWorkspace.trim()) {
          return;
        }
        if(!newUrl || !newUrl.trim()) {
          return;
        }
        axios
        .post('http://localhost:3095/api/workspaces', {
          workspace: newWorkspace,
          url: newUrl
        }, {
          withCredentials: true
        })
        .then(() => {
          console.log('되나')
          mutate()
          setShowCreateWorkspaceModal(false)
          setNewWorkspace('')
          setNewUrl('')
        })
        .catch((error) => {
          console.dir(error);
          toast.error(error.response?.data, {position: 'bottom-center'})
        })
      }, [newWorkspace, newUrl])
    
      const onCloseModal = useCallback(() => {
        setShowCreateWorkspaceModal(false)
        setShowCreateChannelModal(false)
      }, [])
    
      const toggleWorkspaceModal = useCallback(() => {
        setShowWorkspace((prev) => !prev)
      }, [])
    
      const onClickAddChannel = useCallback(() => {
        setShowCreateChannelModal(true)
      }, [])
    
      if(!userData) {
        return <Redirect to='/login'/>
      }
    
      return (
        <div>
          <Header>
            <RightMenu>
              <span onClick={onClickUserProfile}>
                <ProfileImg src={gravatar.url(userData.email, {s: '28px', d: 'retro'})} alt={userData.nickname}/>
                {showUserMenu && (
                <Menu style={{right: 0, top: 38}} show={showUserMenu} onCloseModal={onClickUserProfile}>
                  <ProfileModal>
                    <img src={gravatar.url(userData.email, {s: '30px', d: 'retro'})} alt={userData.nickname}/>
                    <div>
                      <span id="profile-name">{userData.nickname}</span>
                      <span id="profile-active">Active</span>
                    </div>
                  </ProfileModal>
                  <LogOutButton onClick={onLogout}>로그아웃</LogOutButton>
                </Menu>
                )}
              </span>
            </RightMenu>
          </Header>
          <WorkspaceWrapper>
            <Workspaces>
              {userData?.Workspaces.map((ws) => {
                return (
                  <Link key={ws.id} to={`/workspace/${123}/channel/일반`}>
                    <WorkspaceButton>{ws.name.slice(0, 1).toUpperCase()}</WorkspaceButton>
                  </Link>
                )
              })}
              <AddButton onClick={onClickCreateWorkspace}>+</AddButton>
            </Workspaces>
            <Channels>
              <WorkspaceName onClick={toggleWorkspaceModal}>Sleact</WorkspaceName>
              <MenuScroll>
                <Menu show={showWorkspace} onCloseModal={toggleWorkspaceModal} style={{top: 95, left: 80}}>
                  <WorkspaceModal>
                    <h2>Sleact</h2>
                    <button onClick={onClickAddChannel}>채널 만들기</button>
                    <button onClick={onLogout}>로그아웃</button>
                  </WorkspaceModal>
                </Menu>
              </MenuScroll>
            </Channels>
            <Chats>
              <Switch>
                <Route path='/workspace/channel' component={Channel}/>
                <Route path='/workspace/dm' component={DirectMessage}/>
              </Switch>
            </Chats>
          </WorkspaceWrapper>
          <Modal show={showCreateWorkspaceModal} onCloseModal={onCloseModal}>
            <form onSubmit={onCreateWorkspace}>
              <Label id="workspace-label">
                <span>워크스페이스 이름</span>
                <Input id="workspace" value={newWorkspace} onChange={onChangeNewWorkspace}/>
              </Label>
              <Label id="workspace-url-label">
                <span>워크스페이스 url</span>
                <Input id="workspace" value={newUrl} onChange={onChangeNewUrl}/>
              </Label>
              <Button type="submit">생성하기</Button>
            </form>
          </Modal>
          <CreateChannelModal show={showCreateChannelModal} onCloseModal={onCloseModal}/>
        </div>
      )
    }
    
    export default Workspace

    여기서 해준 작업은 따로 생성할 CreateChannelModal component를 import하고 삽입해주는 것이다.

     

    그럼 이번엔 components 폴더에 CreateChannelModal 폴더를 생성하고 하위로 index.tsx 파일을 생성하여 아래와 같이 작성해준다.

    // components/CreateChannelModal/index.tsx
    
    import React, {useCallback, VFC} from 'react';
    import Modal from '@components/Modal';
    import { Label, Input, Button } from '@pages/SignUp/style';
    import useInput from '@hooks/useInput';
    
    interface Props {
      show: boolean;
      onCloseModal: () => void
    }
    
    const CreateChannelModal: VFC<Props> = ({show, onCloseModal}) => {
      const [newChannel, onChangeNewChannel] = useInput('')
      const onCreateChannel = useCallback(() => {
    
      }, [])
    
      return (
        <Modal show={show} onCloseModal={onCloseModal}>
          <form onSubmit={onCreateChannel}>
            <Label id="channel-label">
              <span>채널</span>
              <Input id="channel" value={newChannel} onChange={onChangeNewChannel} />
            </Label>
            <Button type="submit">생성하기</Button>
          </form>
        </Modal>
      );
    };
    
    export default CreateChannelModal;

    아직 기능은 없고 모달창의 모양과 내용만 만들어줬다.

     

    show가 false가 되었을 때 안 보여지게 하기 위해 Menu>index.tsx 파일에 아래 부분을 추가한다.

    // components/Menu/index.tsx
    
      }, [])
    
      if(!show) {
        return null
      }
    
      return (

     

    그럼 아래와 같이 Sleact를 눌렀을 때에는 채널 만들기 모달이 나타나고 다른 곳이나 X버튼을 눌렀을 때에는 사라지는 것을 확인할 수 있다.

     


     

    4. 새 Channel 생성

    (1) Router 설정

    Router는 위에서부터 아래로 실행되는데 만약 주소를 아래와 같이 파라미터로 설정한 것과 명확한 값으로 이루어진 경로가 있을 경우,

    파라미터 경로가 앞에 위치해있으므로 아래 경로는 절대 실행되지 않는다.

    <Route path='/workspace/:workspace' component={Workspace}/>
    <Route path='/workspace/sleact' component={Workspace}/>

     

    참고로 설명한거고 layouts>App>index.tsx 파일을 아래와 같이 수정하자.

    // layouts/App/index.tsx
    
    import React from "react"
    import loadable from '@loadable/component';
    import { Switch, Route, Redirect } from "react-router"
    
    const LogIn = loadable(() => import('@pages/LogIn'));
    const SignUp = loadable(() => import('@pages/SignUp'));
    const Workspace = loadable(() => import('@layouts/Workspace'));
    
    const App = () => {
      return (
        <Switch>
          <Redirect exact path='/' to='/login'/>
          <Route path='/login' component={LogIn}/>
          <Route path='/signup' component={SignUp}/>
          <Route path='/workspace/:workspace' component={Workspace}/>
        </Switch>
      )
    }
    
    export default App

     

    Workspace의 Router들도 파라미터로 설정해준다.

    파라미터로 설정하는 이유는 각 채널명, 사용자마다의 경로가 다를 것이기 때문이다.

    // layouts/Workspace/index.tsx
    
            <Chats>
              <Switch>
                <Route path='/workspace/:workspace/channel/:channel' component={Channel}/>
                <Route path='/workspace/:workspace/dm/:id' component={DirectMessage}/>
              </Switch>
            </Chats>

     

    이에 맞춰 기존에 설정했던 경로들도 수정해준다.

      // pages/LogIn/index.tsx
      
      if (data) {
        return <Redirect to="/workspace/sleact/channel/일반" />;
      }
    
      return (
      // pages/SignUp/index.tsx
      
      if (data) {
        return <Redirect to="/workspace/sleact/channel/일반" />;
      }
    
      return (

     

    (2) Channel 생성 기능

    이제 채널을 추가하고 이 채널리스트가 화면에 보여지도록 해볼 것이다.

    근데 뭔가 잘 작동이 안돼서 따로 수정한 부분도 있어 첨부한다.

    // layouts/Workspace/index.tsx
    
    import React, { VFC, useCallback, useState } from "react";
    import useSWR from "swr";
    import fetcher from "@utils/fetcher";
    import axios from "axios";
    import { Redirect, Switch, Route, useParams } from "react-router";
    import { Link } from "react-router-dom";
    import { Header, ProfileImg, RightMenu, WorkspaceWrapper, Workspaces, Channels, Chats, WorkspaceName, MenuScroll, ProfileModal, LogOutButton, WorkspaceButton, AddButton, WorkspaceModal } from "./style";
    import gravatar from 'gravatar'
    import loadable from "@loadable/component";
    import Menu from "@components/Menu";
    import { IChannel, IUser } from "@typings/db";
    import Modal from "@components/Modal";
    import { Input, Label, Button } from "@pages/SignUp/style";
    import useInput from "@hooks/useInput";
    import { toast } from "react-toastify";
    import CreateChannelModal from '@components/CreateChannelModal'
    
    const Channel = loadable(() => import('@pages/Channel'));
    const DirectMessage = loadable(() => import('@pages/DirectMessage'));
    
    const Workspace :VFC = () => {
      const [showUserMenu, setShowUserMenu] = useState(false)
      const [showCreateWorkspaceModal, setShowCreateWorkspaceModal] = useState(false)
      const [showWorkspace, setShowWorkspace] = useState(false)
      const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
      const [newWorkspace, onChangeNewWorkspace, setNewWorkspace]= useInput('')
      const [newUrl, onChangeNewUrl, setNewUrl]= useInput('')
    
      const {workspace} = useParams<{workspace:string}>();
      const {data:userData, error, mutate} = useSWR<IUser | false>('http://localhost:3095/api/users', fetcher);
      const { data: channelData } = useSWR<IChannel[]>(userData ? `http://localhost:3095/api/workspaces/${workspace}/channels` : null, fetcher);
    
      const onLogout = useCallback(() => {
        axios.post('http://localhost:3095/api/users/logout', null, {
          withCredentials: true
        })
        .then(() => {
          mutate(false);
        })
      }, [])
    
      const onClickUserProfile = useCallback((e) => {
        e.stopPropagation()
        setShowUserMenu((prev) => !prev)
      }, [])
    
      const onClickCreateWorkspace = useCallback(() => {
        setShowCreateWorkspaceModal(true)
      }, [])
    
      const onCreateWorkspace = useCallback((e) => {
        e.preventDefault();
        if(!newWorkspace || !newWorkspace.trim()) {
          return;
        }
        if(!newUrl || !newUrl.trim()) {
          return;
        }
        axios
        .post('http://localhost:3095/api/workspaces', {
          workspace: newWorkspace,
          url: newUrl
        }, {
          withCredentials: true
        })
        .then(() => {
          mutate()
          setShowCreateWorkspaceModal(false)
          setNewWorkspace('')
          setNewUrl('')
        })
        .catch((error) => {
          console.dir(error);
          toast.error(error.response?.data, {position: 'bottom-center'})
        })
      }, [newWorkspace, newUrl])
    
      const onCloseModal = useCallback(() => {
        setShowCreateWorkspaceModal(false)
        setShowCreateChannelModal(false)
      }, [])
    
      const toggleWorkspaceModal = useCallback(() => {
        setShowWorkspace((prev) => !prev)
      }, [])
    
      const onClickAddChannel = useCallback(() => {
        setShowCreateChannelModal(true)
      }, [])
    
      if(!userData) {
        return <Redirect to='/login'/>
      }
    
      return (
        <div>
          <Header>
            <RightMenu>
              <span onClick={onClickUserProfile}>
                <ProfileImg src={gravatar.url(userData.email, {s: '28px', d: 'retro'})} alt={userData.nickname}/>
                {showUserMenu && (
                <Menu style={{right: 0, top: 38}} show={showUserMenu} onCloseModal={onClickUserProfile}>
                  <ProfileModal>
                    <img src={gravatar.url(userData.email, {s: '30px', d: 'retro'})} alt={userData.nickname}/>
                    <div>
                      <span id="profile-name">{userData.nickname}</span>
                      <span id="profile-active">Active</span>
                    </div>
                  </ProfileModal>
                  <LogOutButton onClick={onLogout}>로그아웃</LogOutButton>
                </Menu>
                )}
              </span>
            </RightMenu>
          </Header>
          <WorkspaceWrapper>
            <Workspaces>
              {userData?.Workspaces.map((ws) => {
                return (
                  <Link key={ws.id} to={`/workspace/${123}/channel/일반`}>
                    <WorkspaceButton>{ws.name.slice(0, 1).toUpperCase()}</WorkspaceButton>
                  </Link>
                )
              })}
              <AddButton onClick={onClickCreateWorkspace}>+</AddButton>
            </Workspaces>
            <Channels>
              <WorkspaceName onClick={toggleWorkspaceModal}>Sleact</WorkspaceName>
              <MenuScroll>
                <Menu show={showWorkspace} onCloseModal={toggleWorkspaceModal} style={{top: 95, left: 80}}>
                  <WorkspaceModal>
                    <h2>Sleact</h2>
                    <button onClick={onClickAddChannel}>채널 만들기</button>
                    <button onClick={onLogout}>로그아웃</button>
                  </WorkspaceModal>
                </Menu>
                {channelData?.map((v) => (
                  <div>{v.name}</div>
                ) )}
              </MenuScroll>
            </Channels>
            <Chats>
              <Switch>
                <Route path='/workspace/:workspace/channel/:channel' component={Channel}/>
                <Route path='/workspace/:workspace/dm/:id' component={DirectMessage}/>
              </Switch>
            </Chats>
          </WorkspaceWrapper>
          <Modal show={showCreateWorkspaceModal} onCloseModal={onCloseModal}>
            <form onSubmit={onCreateWorkspace}>
              <Label id="workspace-label">
                <span>워크스페이스 이름</span>
                <Input id="workspace" value={newWorkspace} onChange={onChangeNewWorkspace}/>
              </Label>
              <Label id="workspace-url-label">
                <span>워크스페이스 url</span>
                <Input id="workspace" value={newUrl} onChange={onChangeNewUrl}/>
              </Label>
              <Button type="submit">생성하기</Button>
            </form>
          </Modal>
          <CreateChannelModal show={showCreateChannelModal} onCloseModal={onCloseModal} setShowCreateChannelModal={setShowCreateChannelModal}/>
        </div>
      )
    }
    
    export default Workspace

    우선 핵심적인 역할은 channelData를 요청해서 현재 등록된 채널의 정보를 알아온 뒤 이 정보들이 인식된다면 map을 이용해서 보여주는 것이다.

     

    // components/CreateChannelModal/index.tsx
    
    import React, {useCallback, VFC} from 'react';
    import Modal from '@components/Modal';
    import { Label, Input, Button } from '@pages/SignUp/style';
    import useInput from '@hooks/useInput';
    import axios from 'axios';
    import { useParams } from 'react-router';
    import { toast } from 'react-toastify';
    import useSWR from 'swr'
    import { IChannel, IUser } from "@typings/db";
    import fetcher from "@utils/fetcher";
    
    interface Props {
      show: boolean;
      onCloseModal: () => void;
      setShowCreateChannelModal: (flag: boolean) => void
    }
    
    const CreateChannelModal: VFC<Props> = ({show, onCloseModal, setShowCreateChannelModal}) => {
      const [newChannel, onChangeNewChannel, setNewChannel] = useInput('')
      const {workspace} = useParams<{workspace: string, channel: string}>()
      const {data:userData, error, mutate} = useSWR<IUser | false>('http://localhost:3095/api/users', fetcher, {
        dedupingInterval: 2000
      });
      const {data: channelData, mutate: mutateChannel} = useSWR<IChannel[]>(userData ? `http://localhost:3095/api/workspaces/${workspace}/channels` : null, fetcher);
    
      const onCreateChannel = useCallback((e) => {
        e.preventDefault()
        axios.post(`http://localhost:3095/api/workspaces/${workspace}/channels`, {
          name: newChannel
        }, {
          withCredentials: true
        })
        .then(() => {
          setShowCreateChannelModal(false)
          mutateChannel()
          setNewChannel('')
        })
        .catch((error) => {
          console.dir(error)
          toast.error(error.response?.data, {position: 'bottom-center'})
        })
      }, [workspace, newChannel, setNewChannel, setShowCreateChannelModal])
    
      return (
        <Modal show={show} onCloseModal={onCloseModal}>
          <form onSubmit={onCreateChannel}>
            <Label id="channel-label">
              <span>채널</span>
              <Input id="channel" value={newChannel} onChange={onChangeNewChannel} />
            </Label>
            <Button type="submit">생성하기</Button>
          </form>
        </Modal>
      );
    };
    
    export default CreateChannelModal;

     

    채널을 생성하고 보면 좌측에 채널리스트가 뜨는 걸 확인할 수 있다.

     


     

    5. Workspace에 초대하기

    이번엔 사용자를 Workspace에 초대하는 모달을 만들어보자.

    먼저 하단에서 생성될 각 모달 component를 삽입해둔다.

    // layouts/Workspace/index.tsx
    
    import React, { VFC, useCallback, useState } from "react";
    import useSWR from "swr";
    import fetcher from "@utils/fetcher";
    import axios from "axios";
    import { Redirect, Switch, Route, useParams } from "react-router";
    import { Link } from "react-router-dom";
    import { Header, ProfileImg, RightMenu, WorkspaceWrapper, Workspaces, Channels, Chats, WorkspaceName, MenuScroll, ProfileModal, LogOutButton, WorkspaceButton, AddButton, WorkspaceModal } from "./style";
    import gravatar from 'gravatar'
    import loadable from "@loadable/component";
    import Menu from "@components/Menu";
    import { IChannel, IUser } from "@typings/db";
    import Modal from "@components/Modal";
    import { Input, Label, Button } from "@pages/SignUp/style";
    import useInput from "@hooks/useInput";
    import { toast } from "react-toastify";
    import CreateChannelModal from '@components/CreateChannelModal'
    import InviteWorkspaceModal from "@components/InviteWorkspaceModal";
    import InviteChannelModal from "@components/InviteChannelModal";
    
    const Channel = loadable(() => import('@pages/Channel'));
    const DirectMessage = loadable(() => import('@pages/DirectMessage'));
    
    const Workspace :VFC = () => {
      const [showUserMenu, setShowUserMenu] = useState(false)
      const [showCreateWorkspaceModal, setShowCreateWorkspaceModal] = useState(false)
      const [showWorkspace, setShowWorkspace] = useState(false)
      const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
      const [showInviteWorkspaceModal, setShowInviteWorkspaceModal] = useState(false)
      const [showInviteChannelModal, setShowInviteChannelModal] = useState(false)
      const [newWorkspace, onChangeNewWorkspace, setNewWorkspace]= useInput('')
      const [newUrl, onChangeNewUrl, setNewUrl]= useInput('')
    
      const {workspace} = useParams<{workspace:string}>();
      const {data:userData, error, mutate} = useSWR<IUser | false>('http://localhost:3095/api/users', fetcher);
      const {data: channelData} = useSWR<IChannel[]>(userData ? `http://localhost:3095/api/workspaces/${workspace}/channels` : null, fetcher);
    
      const onLogout = useCallback(() => {
        axios.post('http://localhost:3095/api/users/logout', null, {
          withCredentials: true
        })
        .then(() => {
          mutate(false);
        })
      }, [])
    
      const onClickUserProfile = useCallback((e) => {
        e.stopPropagation()
        setShowUserMenu((prev) => !prev)
      }, [])
    
      const onClickCreateWorkspace = useCallback(() => {
        setShowCreateWorkspaceModal(true)
      }, [])
    
      const onCreateWorkspace = useCallback((e) => {
        e.preventDefault();
        if(!newWorkspace || !newWorkspace.trim()) {
          return;
        }
        if(!newUrl || !newUrl.trim()) {
          return;
        }
        axios
        .post('http://localhost:3095/api/workspaces', {
          workspace: newWorkspace,
          url: newUrl
        }, {
          withCredentials: true
        })
        .then(() => {
          mutate()
          setShowCreateWorkspaceModal(false)
          setNewWorkspace('')
          setNewUrl('')
        })
        .catch((error) => {
          console.dir(error);
          toast.error(error.response?.data, {position: 'bottom-center'})
        })
      }, [newWorkspace, newUrl])
    
      const onCloseModal = useCallback(() => {
        setShowCreateWorkspaceModal(false)
        setShowCreateChannelModal(false)
        setShowCreateWorkspaceModal(false)
        setShowInviteWorkspaceModal(false)
        setShowInviteChannelModal(false)
      }, [])
    
      const toggleWorkspaceModal = useCallback(() => {
        setShowWorkspace((prev) => !prev)
      }, [])
    
      const onClickAddChannel = useCallback(() => {
        setShowCreateChannelModal(true)
      }, [])
    
      const onClickInviteWorkspace = useCallback(() => {
        setShowInviteWorkspaceModal(true)
      }, [])
    
      if(!userData) {
        return <Redirect to='/login'/>
      }
    
      return (
        <div>
          <Header>
            <RightMenu>
              <span onClick={onClickUserProfile}>
                <ProfileImg src={gravatar.url(userData.email, {s: '28px', d: 'retro'})} alt={userData.nickname}/>
                {showUserMenu && (
                <Menu style={{right: 0, top: 38}} show={showUserMenu} onCloseModal={onClickUserProfile}>
                  <ProfileModal>
                    <img src={gravatar.url(userData.email, {s: '30px', d: 'retro'})} alt={userData.nickname}/>
                    <div>
                      <span id="profile-name">{userData.nickname}</span>
                      <span id="profile-active">Active</span>
                    </div>
                  </ProfileModal>
                  <LogOutButton onClick={onLogout}>로그아웃</LogOutButton>
                </Menu>
                )}
              </span>
            </RightMenu>
          </Header>
          <WorkspaceWrapper>
            <Workspaces>
              {userData?.Workspaces.map((ws) => {
                return (
                  <Link key={ws.id} to={`/workspace/${123}/channel/일반`}>
                    <WorkspaceButton>{ws.name.slice(0, 1).toUpperCase()}</WorkspaceButton>
                  </Link>
                )
              })}
              <AddButton onClick={onClickCreateWorkspace}>+</AddButton>
            </Workspaces>
            <Channels>
              <WorkspaceName onClick={toggleWorkspaceModal}>Sleact</WorkspaceName>
              <MenuScroll>
                <Menu show={showWorkspace} onCloseModal={toggleWorkspaceModal} style={{top: 95, left: 80}}>
                  <WorkspaceModal>
                    <h2>Sleact</h2>
                    <button onClick={onClickInviteWorkspace}>워크스페이스에 사용자 초대</button>
                    <button onClick={onClickAddChannel}>채널 만들기</button>
                    <button onClick={onLogout}>로그아웃</button>
                  </WorkspaceModal>
                </Menu>
                {channelData?.map((v) => (
                  <div>{v.name}</div>
                ) )}
              </MenuScroll>
            </Channels>
            <Chats>
              <Switch>
                <Route path='/workspace/:workspace/channel/:channel' component={Channel}/>
                <Route path='/workspace/:workspace/dm/:id' component={DirectMessage}/>
              </Switch>
            </Chats>
          </WorkspaceWrapper>
          <Modal show={showCreateWorkspaceModal} onCloseModal={onCloseModal}>
            <form onSubmit={onCreateWorkspace}>
              <Label id="workspace-label">
                <span>워크스페이스 이름</span>
                <Input id="workspace" value={newWorkspace} onChange={onChangeNewWorkspace}/>
              </Label>
              <Label id="workspace-url-label">
                <span>워크스페이스 url</span>
                <Input id="workspace" value={newUrl} onChange={onChangeNewUrl}/>
              </Label>
              <Button type="submit">생성하기</Button>
            </form>
          </Modal>
          <CreateChannelModal show={showCreateChannelModal} onCloseModal={onCloseModal} setShowCreateChannelModal={setShowCreateChannelModal}/>
          <InviteWorkspaceModal show={showInviteWorkspaceModal} onCloseModal={onCloseModal} setShowInviteWorkspaceModal={setShowInviteWorkspaceModal}/>
          <InviteChannelModal show={showInviteChannelModal} onCloseModal={onCloseModal} setShowInviteChannelModal={setShowInviteChannelModal}/>
        </div>
      )
    }
    
    export default Workspace

     

    이번엔 components 디렉토리 하위에 InviteChannelModal 폴더를 생성하고 하위로 index.tsx 파일을 생성하여 아래와 같이 입력해준다.

    // components/InviteChannelModal/index.tsx
    
    import Modal from '@components/Modal';
    import useInput from '@hooks/useInput';
    import { Button, Input, Label } from '@pages/SignUp/style';
    import { IUser } from '@typings/db';
    import fetcher from '@utils/fetcher';
    import axios from 'axios';
    import React, { FC, useCallback } from 'react';
    import { useParams } from 'react-router';
    import { toast } from 'react-toastify';
    import useSWR from 'swr';
    
    interface Props {
      show: boolean;
      onCloseModal: () => void;
      setShowInviteChannelModal: (flag: boolean) => void;
    }
    const InviteChannelModal: FC<Props> = ({ show, onCloseModal, setShowInviteChannelModal }) => {
      const { workspace, channel } = useParams<{ workspace: string; channel: string }>();
      const [newMember, onChangeNewMember, setNewMember] = useInput('');
      const { data: userData } = useSWR<IUser>('http://localhost:3095/api/users', fetcher);
      const { mutate: mutateMembers } = useSWR<IUser[]>(
        userData ? `http://localhost:3095/api/workspaces/${workspace}/channels/${channel}/members` : null,
        fetcher,
      );
    
      const onInviteMember = useCallback(
        (e) => {
          e.preventDefault();
          if (!newMember || !newMember.trim()) {
            return;
          }
          axios
            .post(`http://localhost:3095/api/workspaces/${workspace}/channels/${channel}/members`, {
              email: newMember,
            })
            .then(() => {
              mutateMembers();
              setShowInviteChannelModal(false);
              setNewMember('');
            })
            .catch((error) => {
              console.dir(error);
              toast.error(error.response?.data, { position: 'bottom-center' });
            });
        },
        [channel, newMember, mutateMembers, setNewMember, setShowInviteChannelModal, workspace],
      );
    
      return (
        <Modal show={show} onCloseModal={onCloseModal}>
          <form onSubmit={onInviteMember}>
            <Label id="member-label">
              <span>채널 멤버 초대</span>
              <Input id="member" value={newMember} onChange={onChangeNewMember} />
            </Label>
            <Button type="submit">초대하기</Button>
          </form>
        </Modal>
      );
    };
    
    export default InviteChannelModal;

     

    그럼 워크스페이스에 사용자 초대를 눌렀을 때 아래와 같이 초대 모달이 뜨는 걸 확인할 수 있다.

     


     

    6. DM 목록 만들기

    유저들의 데이터를 받아오기 위해 아래와 같이 memberData를 받아오는 useSWR을 설정한다.

    // layouts/Workspace/index.tsx
    
      const {data: channelData} = useSWR<IChannel[]>(userData ? `/api/workspaces/${workspace}/channels` : null, fetcher);
      const {data: memberData} = useSWR<IChannel[]>(userData ? `/api/workspaces/${workspace}/members` : null, fetcher);
    
      const onLogout = useCallback(() => {

     

    (1) proxy

    그리고 proxy 설정을 다시 하기 위해 api를 불러오는 모든 파일에서 http://localhost:3095를 삭제하고

    webpack.config.ts 파일에서 proxy 설정 부분을 다시 살린다.

    // webpack.config.ts
    
      devServer: {
        historyApiFallback: true,
        port: 3090,
        devMiddleware: { publicPath: '/dist/' },
        static: { directory: path.resolve(__dirname) },
        proxy: {
          '/api/': {
            target: 'http://localhost:3095',
            changeOrigin: true,
            ws: true,
          },
        },
      },

     

    지웠다가 다시 설정하는 이유는 모든 작업이 완료된 후 파일을 취합하고 ts 파일을 js 파일로 변환하여 서버 개발자한테 보내면 서버 개발자가 서버에 올려주는데 그 때 우리는 localhost의 경로를 사용하지 않기에 에러가 나타날 것이라고 한다.

    (그럼 왜 지웠던거야)

     

    (2) DMList

    이제 본격적으로 DM 목록을 만들어보자.

    components 디렉토리에 DMList 폴더를 생성하고 하위에 index.tsx 파일을 생성한 뒤 아래와 같이 입력하자.

    // components/DMList/index.tsx
    
    // import EachDM from '@components/EachDM';
    // import useSocket from '@hooks/useSocket';
    import { IDM, IUser, IUserWithOnline } from '@typings/db';
    import { CollapseButton } from '@components/DMList/style';
    import fetcher from '@utils/fetcher';
    import React, { FC, useCallback, useEffect, useState } from 'react';
    import { useParams } from 'react-router';
    import { NavLink } from 'react-router-dom';
    import useSWR from 'swr';
    
    interface Props {
      userData?: IUser
    }
    
    const DMList :FC<Props> = ({userData}) => {
      const { workspace } = useParams<{ workspace?: string }>();
      const { data: memberData } = useSWR<IUserWithOnline[]>(
        userData ? `/api/workspaces/${workspace}/members` : null,
        fetcher,
      );
      // const [socket] = useSocket(workspace);
      const [channelCollapse, setChannelCollapse] = useState(false);
      const [countList, setCountList] = useState<{[key:string]: number}>({});
      const [onlineList, setOnlineList] = useState<number[]>([]);
    
      const toggleChannelCollapse = useCallback(() => {
        setChannelCollapse((prev) => !prev);
      }, []);
    
      const resetCount = useCallback(
        (id) => () => {
          setCountList((list) => {
            return {
              ...list,
              [id]: 0
            }
          })
        }, [])
    
        const onMessage = (data: IDM) => {
          console.log('dm왔다', data)
          setCountList((list) => {
            return {
              ...list,
              [data.SenderId]: list[data.SenderId] ? list[data.SenderId] + 1 : 1
            }
          })
        }
    
      useEffect(() => {
        console.log('DMList: workspace 바꼈다', workspace);
        setOnlineList([]);
        setCountList({})
      }, [workspace]);
    
      // useEffect(() => {
      //   socket?.on('onlineList', (data: number[]) => {
      //     setOnlineList(data);
      //   });
      //   console.log('socket on dm', socket?.hasListeners('dm'), socket);
      //   return () => {
      //     console.log('socket off dm', socket?.hasListeners('dm'));
      //     socket?.off('onlineList');
      //   };
      // }, [socket]);
    
      return (
        <>
          <h2>
            <CollapseButton collapse={channelCollapse} onClick={toggleChannelCollapse}>
              <i
                className="c-icon p-channel_sidebar__section_heading_expand p-channel_sidebar__section_heading_expand--show_more_feature c-icon--caret-right c-icon--inherit c-icon--inline"
                data-qa="channel-section-collapse"
                aria-hidden="true"
              />
            </CollapseButton>
            <span>Direct Messages</span>
          </h2>
          <div>
            {!channelCollapse &&
              memberData?.map((member) => {
                const isOnline = onlineList.includes(member.id);
                const count = countList[member.id] || 0;
                return (
                  <NavLink key={member.id} activeClassName="selected" to={`/workspace/${workspace}/dm/${member.id}`}>
                    <i
                      className={`c-icon p-channel_sidebar__presence_icon p-channel_sidebar__presence_icon--dim_enabled c-presence ${
                        isOnline ? 'c-presence--active c-icon--presence-online' : 'c-icon--presence-offline'
                      }`}
                      aria-hidden="true"
                      data-qa="presence_indicator"
                      data-qa-presence-self="false"
                      data-qa-presence-active="false"
                      data-qa-presence-dnd="false"
                    />
                  <span className={count > 0 ? 'bold' : undefined}>{member.nickname}</span>
                  {member.id === userData?.id && <span> (나) </span>}
                  {count > 0 && <span className='count'>{count}</span>}
                  </NavLink>
                )
              })}
          </div>
        </>
      );
    };
    
    export default DMList;

    주석 처리한 부분은 나중에 실시간 DM과 상태를 확인할 수 있도록 socket을 사용할텐데 아직 설치가 안됐기에 미리 작성하고 주석 처리해둔 것이다.

     

    style 또한 tsx 파일로 생성하여 아래와 같이 정의해주자.

    // components/DMList/style.tsx
    
    import styled from '@emotion/styled';
    
    export const CollapseButton = styled.button<{ collapse: boolean }>`
      background: transparent;
      border: none;
      width: 26px;
      height: 26px;
      display: inline-flex;
      justify-content: center;
      align-items: center;
      color: white;
      margin-left: 10px;
      cursor: pointer;
      ${({ collapse }) =>
        collapse &&
        `
        & i {
          transform: none;
        }
      `};
    `;

    그리고 Workspace에서 DMList를 상단에서 import하고 아래와 같이 적용시켰는데 나와야 하는 아이콘이 안나와서

    // layouts/Workspace/index.tsx
    
                <DMList userData={userData}/>
                {channelData?.map((v) => (

    찾아보니 외부에서 import해오는 style의 link가 변경되어 적용이 안 됐던 것이다.

     

    index.html의 link를 import하는 부분을 아래와 같이 수정하자.

    // index.html
    
    <html>
      <head>
        <meta charset="UTF-8">
        <meta name="viewport"
              content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>슬리액</title>
        <style>
          html, body {
              margin: 0;
              padding: 0;
              overflow: initial !important;
          }
          body {
              font-size: 15px;
              line-height: 1.46668;
              font-weight: 400;
              font-variant-ligatures: common-ligatures;
              -moz-osx-font-smoothing: grayscale;
              -webkit-font-smoothing: antialiased;
          }
          * {
              box-sizing: border-box;
          }
        </style>
         <link rel="stylesheet" href="https://a.slack-edge.com/bv1-9/client-boot-styles.dc0a11f.css?cacheKey=gantry-1613184053" crossorigin="anonymous" />
         <link rel="shortcut icon" href="https://a.slack-edge.com/cebaa/img/ico/favicon.ico" />
         <link href="https://a.slack-edge.com/bv1-9/slack-icons-v2-16ca3a7.woff2" rel="preload" as="font" crossorigin="anonymous" />
      </head>
      <body>
        <div id="app"></div>
        <script src="/dist/app.js"></script>
      </body>
    </html>

     

    그럼 아래와 같이 가입된 유저들의 닉네임이 나온다.

    근데 내가 가입할 때마다 닉네임을 챙으로 해서 다 같은 사람으로 보인다.

    로그아웃했다가 다른 이름으로도 생성해야겠다.

     

    잉어참치로 새로 가입했더니 구분이 간다.

    현재 작동되는 부분만 하나하나 원리를 뜯어보면

     

    Workspace에서 userData를 받아와서 userData가 존재할 때 api를 요청하여 memberData라는 이름으로 사용자들의 정보를 받아온다.

    const DMList :FC<Props> = ({userData}) => {
      const { workspace } = useParams<{ workspace?: string }>();
      const { data: memberData } = useSWR<IUserWithOnline[]>(
        userData ? `/api/workspaces/${workspace}/members` : null,
        fetcher,
      );

     

    그리고 DM리스트를 접었다 필 때의 현재 상태를 구분해줄 channelCollapse 상태를 정의하고 이를 버튼을 클릭할 때마다 toggle로 변경되게 한다.

     

    countList와 onlineList는 각각 온 DM의 수와 현재 온라인 상태인 사용자의 수를 의미한다.

      const [channelCollapse, setChannelCollapse] = useState(false);
      const [countList, setCountList] = useState<{[key:string]: number}>({});
      const [onlineList, setOnlineList] = useState<number[]>([]);
    
      const toggleChannelCollapse = useCallback(() => {
        setChannelCollapse((prev) => !prev);
      }, []);

     

    DM 리스트가 펴져 있는 게 기본값(false)이므로 이를 조건으로 DM 리스트가 보여지게 설정한다.

    사용자 데이터가 존재할 경우 map 함수를 이용해 리스트를 렌더하도록 하는데

    그 중 온라인 상태에 포함되어있는 멤버의 아이디를 isOnline 변수에 대입해 추후에 온라인 상태를 구분할 때에도 사용할 수 있도록 한다.

     

    count는 사용자의 수로 나중에 총 몇명의 사용자가 있는지 보여줄 때 이를 이용할 것이다.

          <div>
            {!channelCollapse &&
              memberData?.map((member) => {
                const isOnline = onlineList.includes(member.id);
                const count = countList[member.id] || 0;
                return (
                  <NavLink key={member.id} activeClassName="selected" to={`/workspace/${workspace}/dm/${member.id}`}>
                    <i
                      className={`c-icon p-channel_sidebar__presence_icon p-channel_sidebar__presence_icon--dim_enabled c-presence ${
                        isOnline ? 'c-presence--active c-icon--presence-online' : 'c-icon--presence-offline'
                      }`}
                      aria-hidden="true"
                      data-qa="presence_indicator"
                      data-qa-presence-self="false"
                      data-qa-presence-active="false"
                      data-qa-presence-dnd="false"
                    />
                  <span className={count > 0 ? 'bold' : undefined}>{member.nickname}</span>
                  {member.id === userData?.id && <span> (나) </span>}
                  {count > 0 && <span className='count'>{count}</span>}
                  </NavLink>
                )
              })}
          </div>

     

    각 사용자와 DM이 가능하도록 하려면 각 사용자별로 경로가 달라야 하기에 NavLink로 감싸서 진행하는데

    NavLink만의 기능인 activeClassName을 사용해 현재 선택된 사용자의 이름에 쉽게 추가적인 class를 부여할 수 있도록 한다.

     

    사용자 중에는 나도 포함되므로 구분시켜주기 위해 member.id와 userData.id가 동일한 경우 (나) 라는 문구를 추가로 삽입해준다.

Designed by Tistory.