Skip to content

Commit 038347f

Browse files
authored
[25.03.10 / TASK-121] Feature - 공지사항 기능 구현 (#23)
* feat: 공지사항 제작 * refactor: 공지사항 반응형 지원 * refactor: 클라이언트 전용 처리 * refactor: 안정성 강화 * refactor: esc close 처리 * refactor: prose 적용 * refactor: 놓친 부분 반영 * refactor: 코드 리팩 * refactor: 주석 제거
1 parent e4eb523 commit 038347f

File tree

18 files changed

+272
-13
lines changed

18 files changed

+272
-13
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@next/third-parties": "^15.1.7",
1818
"@sentry/core": "^8.47.0",
1919
"@sentry/nextjs": "^8.47.0",
20+
"@tailwindcss/typography": "^0.5.16",
2021
"@tanstack/react-query": "^5.61.3",
2122
"@tanstack/react-query-devtools": "^5.62.11",
2223
"chart.js": "^4.4.7",
@@ -31,7 +32,8 @@
3132
"react-intersection-observer": "^9.14.0",
3233
"react-toastify": "^10.0.6",
3334
"return-fetch": "^0.4.6",
34-
"sharp": "^0.33.5"
35+
"sharp": "^0.33.5",
36+
"zustand": "^5.0.3"
3537
},
3638
"devDependencies": {
3739
"@eslint/js": "^9.15.0",

pnpm-lock.yaml

Lines changed: 56 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/apis/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './dashboard.request';
22
export * from './instance.request';
3+
export * from './notice.request';
34
export * from './user.request';

src/apis/notice.request.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { PATHS } from '@/constants';
2+
import { NotiListDto } from '@/types';
3+
import { instance } from './instance.request';
4+
5+
export const notiList = async () =>
6+
await instance<null, NotiListDto>(PATHS.NOTIS);

src/app/(auth-required)/components/header/index.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { PATHS, SCREENS } from '@/constants';
99
import { NameType } from '@/components';
1010
import { useResponsive } from '@/hooks';
1111
import { logout, me } from '@/apis';
12+
import { useModal } from '@/hooks/useModal';
1213
import { defaultStyle, Section, textStyle } from './Section';
14+
import { Modal } from '../notice/Modal';
1315

1416
const PARAMS = {
1517
MAIN: '?asc=false&sort=',
@@ -28,6 +30,7 @@ const layouts: Array<{ icon: NameType; title: string; path: string }> = [
2830

2931
export const Header = () => {
3032
const [open, setOpen] = useState(false);
33+
const { open: ModalOpen } = useModal();
3134
const menu = useRef<HTMLDivElement | null>(null);
3235
const path = usePathname();
3336
const router = useRouter();
@@ -116,13 +119,19 @@ export const Header = () => {
116119
{open && (
117120
<div className="flex flex-col items-center max-MBI:items-end absolute self-center top-[50px] max-MBI:right-[6px]">
118121
<div className="w-0 h-0 border-[15px] ml-3 mr-3 border-TRANSPARENT border-b-BG-SUB" />
119-
<div className="cursor-pointer h-fit flex-col rounded-[4px] bg-BG-SUB hover:bg-BG-ALT shadow-BORDER-MAIN shadow-md">
122+
<div className="cursor-pointer h-fit flex-col rounded-[4px] bg-BG-SUB shadow-BORDER-MAIN shadow-md">
120123
<button
121124
className="text-DESTRUCTIVE-SUB text-I3 p-5 max-MBI:p-4 flex whitespace-nowrap w-auto"
122125
onClick={() => out()}
123126
>
124127
로그아웃
125128
</button>
129+
<button
130+
className="text-TEXT-MAIN text-I3 p-5 max-MBI:p-4 flex whitespace-nowrap w-auto hover:bg-BG-ALT"
131+
onClick={() => ModalOpen(<Modal />)}
132+
>
133+
공지사항
134+
</button>
126135
</div>
127136
</div>
128137
)}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
import { useQuery } from '@tanstack/react-query';
5+
import { notiList } from '@/apis';
6+
import { PATHS } from '@/constants';
7+
import { useModal } from '@/hooks/useModal';
8+
import { Icon } from '@/components';
9+
10+
export const Modal = () => {
11+
const { close } = useModal();
12+
const { data } = useQuery({ queryKey: [PATHS.NOTIS], queryFn: notiList });
13+
14+
useEffect(() => {
15+
const handleClose = (e: KeyboardEvent) => e.key === 'Escape' && close();
16+
17+
window.addEventListener('keydown', handleClose);
18+
return () => window.removeEventListener('keydown', handleClose);
19+
}, [close]);
20+
21+
return (
22+
<div className="w-[800px] h-[500px] max-MBI:w-[450px] max-MBI:h-[200px] overflow-auto flex flex-col gap-3 p-10 max-MBI:p-7 rounded-md bg-BG-SUB">
23+
<div className="flex items-center justify-between">
24+
<h2 className="text-TEXT-MAIN items-cenetr gap-3 text-T3 max-MBI:text-T4">
25+
공지사항
26+
</h2>
27+
<Icon name="Close" onClick={close} className="cursor-pointer" />
28+
</div>
29+
30+
{data?.posts?.map(({ content, created_at, id, title }) => (
31+
<div key={id} className="flex flex-col gap-2">
32+
<div className="flex items-center gap-3">
33+
<h3 className="text-TEXT-MAIN text-T4 max-MBI:text-T5">{title}</h3>
34+
<h4 className="text-TEXT-ALT text-T5 max-MBI:text-ST5">
35+
{created_at.split('T')[0]}
36+
</h4>
37+
</div>
38+
<div
39+
className="text-TEXT-MAIN text-I4 prose"
40+
dangerouslySetInnerHTML={{ __html: content }}
41+
/>
42+
</div>
43+
))}
44+
</div>
45+
);
46+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { useQuery } from '@tanstack/react-query';
5+
import { notiList } from '@/apis';
6+
import { PATHS } from '@/constants';
7+
import { useModal } from '@/hooks/useModal';
8+
import { Modal } from './Modal';
9+
10+
const DAY_IN_MS = 1000 * 60 * 60 * 24;
11+
const TTL = DAY_IN_MS * 2;
12+
const RECENT_POST_THRESHOLD_DAYS = 4;
13+
const NOTIFICATION_STORAGE_KEY = 'noti_expiry';
14+
15+
export const Notice = () => {
16+
const { data } = useQuery({ queryKey: [PATHS.NOTIS], queryFn: notiList });
17+
const [show, setShow] = useState(false);
18+
const { open } = useModal();
19+
20+
useEffect(() => {
21+
try {
22+
const lastUpdated = new Date(
23+
data?.posts[0].created_at?.split('T')[0] as string,
24+
).getTime();
25+
26+
const daysSinceUpdate = Math.ceil(
27+
(new Date().getTime() - lastUpdated) / DAY_IN_MS,
28+
);
29+
30+
if (daysSinceUpdate <= RECENT_POST_THRESHOLD_DAYS) {
31+
const expiry = localStorage.getItem(NOTIFICATION_STORAGE_KEY);
32+
if (!expiry || parseInt(expiry, 10) < new Date().getTime()) {
33+
setShow(true);
34+
}
35+
}
36+
} catch (error) {
37+
console.error('알림 날짜 처리 중 오류 발생:', error);
38+
}
39+
}, [data]);
40+
41+
return (
42+
<>
43+
<div
44+
className={`transition-all shrink-0 duration-300 flex items-center justify-center gap-2 w-full overflow-hidden bg-BORDER-SUB ${show ? 'h-[50px]' : 'h-[0px]'}`}
45+
>
46+
<h1 className="text-TEXT-MAIN text-ST4 max-MBI:text-ST5">
47+
📣 새로운 업데이트를 확인해보세요!
48+
</h1>
49+
<button
50+
className="text-PRIMARY-MAIN hover:text-PRIMARY-SUB text-ST4 transition-all duration-300 max-MBI:text-ST5"
51+
onClick={() => {
52+
setShow(false);
53+
localStorage.setItem(
54+
'noti_expiry',
55+
JSON.stringify(new Date().getTime() + TTL),
56+
);
57+
58+
open(<Modal />);
59+
}}
60+
>
61+
확인하기
62+
</button>
63+
</div>
64+
</>
65+
);
66+
};

src/app/(auth-required)/layout.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
22
import { ReactElement } from 'react';
33
import { getQueryClient } from '@/utils/queryUtil';
44
import { PATHS } from '@/constants';
5-
import { me } from '@/apis';
5+
import { me, notiList } from '@/apis';
6+
import { Notice } from './components/notice';
67
import { Header } from './components/header';
78

89
interface IProp {
@@ -14,14 +15,20 @@ export default async function Layout({ children }: IProp) {
1415

1516
await client.prefetchQuery({ queryKey: [PATHS.ME], queryFn: me });
1617

18+
await client.prefetchQuery({
19+
queryKey: [PATHS.NOTIS],
20+
queryFn: notiList,
21+
});
22+
1723
return (
18-
<main className="items-center w-full h-full flex flex-col p-[50px_70px_70px_70px] transition-all max-TBL:p-[20px_30px_30px_30px] max-MBI:p-[10px_25px_25px_25px]">
19-
<div className="w-full max-w-[1740px] h-full overflow-hidden flex flex-col gap-[30px] max-TBL:gap-[20px]">
20-
<HydrationBoundary state={dehydrate(client)}>
24+
<HydrationBoundary state={dehydrate(client)}>
25+
<main className="items-center w-full h-full flex flex-col">
26+
<Notice />
27+
<div className="w-full max-w-[1740px] h-full overflow-hidden flex flex-col gap-[30px] p-[50px_70px_70px_70px] transition-all duration-300 max-TBL:gap-[20px] max-TBL:p-[20px_30px_30px_30px] max-MBI:p-[10px_25px_25px_25px]">
2128
<Header />
22-
</HydrationBoundary>
23-
{children}
24-
</div>
25-
</main>
29+
{children}
30+
</div>
31+
</main>
32+
</HydrationBoundary>
2633
);
2734
}

src/app/layout.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import 'react-toastify/dist/ReactToastify.css';
55
import { ErrorBoundary } from '@sentry/nextjs';
66
import { ReactNode, Suspense } from 'react';
77
import type { Metadata } from 'next';
8-
import { ChannelTalkProvider, QueryProvider } from '@/components';
9-
import { env } from '@/constants';
108
import './globals.css';
9+
import {
10+
ChannelTalkProvider,
11+
QueryProvider,
12+
ModalProvider,
13+
} from '@/components';
14+
import { env } from '@/constants';
1115

1216
export const BASE = 'https://velog-dashboard.kro.kr/';
1317

@@ -39,6 +43,7 @@ export default function RootLayout({
3943
<QueryProvider>
4044
<ChannelTalkProvider>
4145
<ToastContainer autoClose={2000} />
46+
<ModalProvider />
4247
<Suspense>{children}</Suspense>
4348
</ChannelTalkProvider>
4449
</QueryProvider>
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)