Skip to content

Commit

Permalink
feat: 커뮤니티 레이아웃 구현 (#1038)
Browse files Browse the repository at this point in the history
* feat: 레이아웃 기본 구성

* feat: 모바일 대응 추가

* feat: 쿼리 파라미터 일반화 추가

* refactor: DOM에 키가 들어가지 않도록 수정

* chore: 주석 추가
  • Loading branch information
Tekiter authored Oct 28, 2023
1 parent c833cdc commit ae7f660
Show file tree
Hide file tree
Showing 13 changed files with 323 additions and 27 deletions.
28 changes: 16 additions & 12 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import React from 'react';
import { themes } from '@storybook/theming';
import { LazyMotion } from 'framer-motion';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import NextAdapterPages from 'next-query-params/pages';
import { RouterContext } from 'next/dist/shared/lib/router-context';
import { QueryParamProvider } from 'use-query-params';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RecoilRoot } from 'recoil';

Expand Down Expand Up @@ -43,18 +45,20 @@ export const parameters = {
export const decorators = [
(Story) => (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<LazyMotion strict features={() => import('framer-motion').then((mod) => mod.domAnimation)}>
<StorybookEventLoggerProvider>
<StorybookToastProvider>
<GlobalStyle />
<ResponsiveProvider>
<Story />
</ResponsiveProvider>
</StorybookToastProvider>
</StorybookEventLoggerProvider>
</LazyMotion>
</RecoilRoot>
<QueryParamProvider adapter={NextAdapterPages}>
<RecoilRoot>
<LazyMotion strict features={() => import('framer-motion').then((mod) => mod.domAnimation)}>
<StorybookEventLoggerProvider>
<StorybookToastProvider>
<GlobalStyle />
<ResponsiveProvider>
<Story />
</ResponsiveProvider>
</StorybookToastProvider>
</StorybookEventLoggerProvider>
</LazyMotion>
</RecoilRoot>
</QueryParamProvider>
</QueryClientProvider>
),
mswDecorator,
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"lodash-es": "^4.17.21",
"nanoid": "^4.0.0",
"next": "^13.1.1",
"next-query-params": "^4.2.3",
"next-seo": "^6.1.0",
"qs": "^6.11.0",
"react": "^18.2.0",
Expand All @@ -62,6 +63,7 @@
"react-remove-scroll": "^2.5.6",
"recoil": "^0.7.2",
"swiper": "^9.4.1",
"use-query-params": "^2.2.1",
"yup": "^1.0.0",
"zod": "^3.20.6"
},
Expand Down
60 changes: 60 additions & 0 deletions src/components/community/layout/DesktopCommunityLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import styled from '@emotion/styled';
import { m } from 'framer-motion';
import { FC, ReactNode } from 'react';

import { layoutCSSVariable } from '@/components/layout/utils';

interface DesktopCommunityLayoutProps {
isDetailOpen: boolean;
listSlot: ReactNode;
detailSlot: ReactNode;
}

const DETAIL_SLOT_WIDTH = 560;

const DesktopCommunityLayout: FC<DesktopCommunityLayoutProps> = ({ isDetailOpen, listSlot, detailSlot }) => {
return (
<Container>
<ListSlot>{listSlot}</ListSlot>
<DetailSlot
initial={{ width: 0 }}
animate={{ width: isDetailOpen ? DETAIL_SLOT_WIDTH : 0 }}
transition={{ bounce: 0 }}
>
<DetailSlotInner>{detailSlot}</DetailSlotInner>
</DetailSlot>
</Container>
);
};

export default DesktopCommunityLayout;

const Container = styled.div`
display: flex;
justify-content: center;
width: 100%;
height: ${layoutCSSVariable.contentAreaHeight};
`;

const ListSlot = styled.div`
flex: 1 1 0;
max-width: 560px;
height: 100%;
`;

const DetailSlot = styled(m.div)`
position: relative;
width: 100%;
max-width: 560px;
height: 100%;
overflow: hidden;
`;

const DetailSlotInner = styled.div`
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 560px;
min-width: ${DETAIL_SLOT_WIDTH}px;
`;
42 changes: 42 additions & 0 deletions src/components/community/layout/MobileCommunityLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import styled from '@emotion/styled';
import { colors } from '@sopt-makers/colors';
import { m } from 'framer-motion';
import { FC, ReactNode } from 'react';

import { layoutCSSVariable } from '@/components/layout/utils';

interface MobileCommunityLayoutProps {
isDetailOpen: boolean;
listSlot: ReactNode;
detailSlot: ReactNode;
}

const MobileCommunityLayout: FC<MobileCommunityLayoutProps> = ({ isDetailOpen, listSlot, detailSlot }) => {
return (
<Container>
<ListSlotBox>{listSlot}</ListSlotBox>
<DetailSlotBox initial={{ x: '100%' }} animate={{ x: isDetailOpen ? '0%' : '100%' }} transition={{ bounce: 0 }}>
{detailSlot}
</DetailSlotBox>
</Container>
);
};

export default MobileCommunityLayout;

const Container = styled.div`
position: relative;
height: ${layoutCSSVariable.contentAreaHeight};
overflow: hidden;
`;

const ListSlotBox = styled.div`
position: absolute;
inset: 0;
`;

const DetailSlotBox = styled(m.div)`
position: absolute;
inset: 0;
background-color: ${colors.background};
`;
43 changes: 43 additions & 0 deletions src/components/community/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { colors } from '@sopt-makers/colors';
import { FC } from 'react';

import Responsive from '@/components/common/Responsive';
import DesktopCommunityLayout from '@/components/community/layout/DesktopCommunityLayout';
import MobileCommunityLayout from '@/components/community/layout/MobileCommunityLayout';
import { FeedDetailLink, TagLink, useFeedDetailParam, useTagParam } from '@/components/community/queryParam';

const CommunityPage: FC = () => {
const [feed] = useFeedDetailParam();
const [tag] = useTagParam();

const feedList = (
<div style={{ backgroundColor: colors.blue600, display: 'flex', flexDirection: 'column' }}>
<TagLink tagId='tag1'>태그1</TagLink>
<TagLink tagId='tag2'>태그2</TagLink>
<TagLink tagId='tag3'>태그3</TagLink>
<div>CurrentTag: {tag}</div>
<FeedDetailLink feedId='ramen'>한강라면</FeedDetailLink>
<FeedDetailLink feedId='chicken'>치킨</FeedDetailLink>
</div>
);

const feedDetail = (
<div style={{ backgroundColor: colors.green600 }}>
<FeedDetailLink feedId={undefined}>닫기</FeedDetailLink>
<div>피드 ID: {feed}</div>
</div>
);

return (
<>
<Responsive only='desktop'>
<DesktopCommunityLayout isDetailOpen={feed !== ''} listSlot={feedList} detailSlot={feedDetail} />
</Responsive>
<Responsive only='mobile'>
<MobileCommunityLayout isDetailOpen={feed !== ''} listSlot={feedList} detailSlot={feedDetail} />
</Responsive>
</>
);
};

export default CommunityPage;
51 changes: 51 additions & 0 deletions src/components/community/queryParam.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import { ComponentPropsWithoutRef, forwardRef } from 'react';
import { StringParam, useQueryParam, withDefault } from 'use-query-params';

/**
* 쿼리 파라미터와 연동된 상태를 쉽게 만들 수 있게 해줍니다. paramKey가 쿼리 파라미터의 키가 됩니다.
*/
function createLinkComponent<T extends string>({ paramKey }: { paramKey: T }) {
type Key = {
[K in `${T}Id`]: string | undefined;
};

const ParamLink = forwardRef<
HTMLAnchorElement,
Omit<ComponentPropsWithoutRef<typeof Link>, 'href' | 'shallow'> & Key
>((props, ref) => {
const { pathname, query } = useRouter();
const value = props[`${paramKey}Id`] as string | undefined;

const newProps = { ...props };
delete newProps[`${paramKey}Id`];

return (
<Link
ref={ref}
{...newProps}
href={{
pathname,
query: {
...query,
[paramKey]: value,
},
}}
shallow
/>
);
});

const useParam = () => {
return useQueryParam(paramKey, withDefault(StringParam, undefined));
};

return [ParamLink, useParam] as const;
}

export const [TagLink, useTagParam] = createLinkComponent({ paramKey: 'tag' });

export const [CategoryLink, useCategoryParam] = createLinkComponent({ paramKey: 'category' });

export const [FeedDetailLink, useFeedDetailParam] = createLinkComponent({ paramKey: 'feed' });
4 changes: 4 additions & 0 deletions src/components/layout/FullScreenLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import styled from '@emotion/styled';
import { FC, ReactNode } from 'react';

import { createLayoutCSSVariable } from '@/components/layout/utils';

interface FullScreenLayoutProps {
children: ReactNode;
}
Expand All @@ -13,4 +15,6 @@ export default FullScreenLayout;

const StyledFullScreenLayout = styled.div`
height: 100vh;
${createLayoutCSSVariable({ headerHeight: 0 })}
`;
5 changes: 5 additions & 0 deletions src/components/layout/HeaderFooterLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { RemoveScroll } from 'react-remove-scroll';
import Footer from '@/components/common/Footer';
import Header from '@/components/common/Header';
import Responsive from '@/components/common/Responsive';
import { createLayoutCSSVariable } from '@/components/layout/utils';
import { MOBILE_MEDIA_QUERY } from '@/styles/mediaQuery';

interface HeaderLayoutProps {
Expand All @@ -31,8 +32,12 @@ export default HeaderFooterLayout;
const StyledContainer = styled.div`
padding-top: 80px;
${createLayoutCSSVariable({ headerHeight: 80 })}
@media ${MOBILE_MEDIA_QUERY} {
padding-top: 56px;
${createLayoutCSSVariable({ headerHeight: 80 })}
}
`;

Expand Down
5 changes: 5 additions & 0 deletions src/components/layout/HeaderLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FC, ReactNode } from 'react';
import { RemoveScroll } from 'react-remove-scroll';

import Header from '@/components/common/Header';
import { createLayoutCSSVariable } from '@/components/layout/utils';
import { MOBILE_MEDIA_QUERY } from '@/styles/mediaQuery';

interface HeaderLayoutProps {
Expand All @@ -25,8 +26,12 @@ export default HeaderLayout;
const StyledContainer = styled.div`
padding-top: 80px;
${createLayoutCSSVariable({ headerHeight: 80 })}
@media ${MOBILE_MEDIA_QUERY} {
padding-top: 56px;
${createLayoutCSSVariable({ headerHeight: 56 })}
}
`;

Expand Down
22 changes: 22 additions & 0 deletions src/components/layout/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
interface CreateLayoutCSSVariableOptions {
headerHeight: number;
footerHeight?: number;
}

const layoutCSSVariableNames = {
globalHeaderHeight: '--global-header-height',
globalFooterHeight: '--global-footer-height',
contentAreaHeight: '--content-area-height',
};

export const layoutCSSVariable = Object.fromEntries(
Object.entries(layoutCSSVariableNames).map(([key, value]) => [key, `var(${value})`]),
) as Record<keyof typeof layoutCSSVariableNames, string>;

export function createLayoutCSSVariable({ headerHeight, footerHeight = 0 }: CreateLayoutCSSVariableOptions) {
return `
${layoutCSSVariableNames.globalHeaderHeight}: ${headerHeight}px;
${layoutCSSVariableNames.globalFooterHeight}: ${footerHeight}px;
${layoutCSSVariableNames.contentAreaHeight}: calc(100vh - var(--global-header-height) - var(--global-footer-height));
`;
}
35 changes: 20 additions & 15 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import type { AppProps } from 'next/app';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import Router, { useRouter } from 'next/router';
import NextAdapterPages from 'next-query-params/pages';
import { NextSeo } from 'next-seo';
import { useEffect } from 'react';
import { RecoilRoot } from 'recoil';
import { QueryParamProvider } from 'use-query-params';

import ResponsiveProvider from '@/components/common/Responsive/ResponsiveProvider';
import ToastProvider from '@/components/common/Toast/providers/ToastProvider';
Expand Down Expand Up @@ -57,21 +59,24 @@ function MyApp({ Component, pageProps }: AppProps) {
<meta name='theme-color' media='(prefers-color-scheme: dark)' content={colors.gray400} />
</Head>
<GoogleTagManagerScript />
<RecoilRoot>
<AmplitudeProvider apiKey={AMPLITUDE_API_KEY}>
<LazyMotion features={() => import('framer-motion').then((mod) => mod.domAnimation)}>
<ToastProvider>
<GlobalStyle />
<ResponsiveProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</ResponsiveProvider>
{DEBUG && <Debugger />}
</ToastProvider>
</LazyMotion>
</AmplitudeProvider>
</RecoilRoot>

<QueryParamProvider adapter={NextAdapterPages}>
<RecoilRoot>
<AmplitudeProvider apiKey={AMPLITUDE_API_KEY}>
<LazyMotion features={() => import('framer-motion').then((mod) => mod.domAnimation)}>
<ToastProvider>
<GlobalStyle />
<ResponsiveProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</ResponsiveProvider>
{DEBUG && <Debugger />}
</ToastProvider>
</LazyMotion>
</AmplitudeProvider>
</RecoilRoot>
</QueryParamProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
Expand Down
Loading

0 comments on commit ae7f660

Please sign in to comment.