Skip to content

Commit 881b365

Browse files
authoredMar 21, 2025··
[25.02.19 / TASK-98] Refactor - 페이지 로딩 효과 추가 (#25)
* modify: 페이지 이동 시 로딩 바 표시 기능 추가 * modify: 호버 관련 오류 수정 * modify: 오탈자 수정 * refactor: 코드 정리 * refactor: useCallback 적용 * fix: 패키지 정리 * feature: husky 추가 * modify: 코드래빗 의견 반영 * modify: 코드래빗 의견 반영 * modify: ReadME 최신화
1 parent 5f3f1aa commit 881b365

16 files changed

+527
-87
lines changed
 

‎.eslintignore

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.next
2+
next-env.d.ts
3+
pnpm-lock.yaml
4+
public
5+
next.config.js
6+
readme.md
7+
Dockerfile
8+
.vscode
9+
.swc
10+
.github

‎.husky/pre-commit

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pnpm lint-staged

‎.prettierrc

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"trailingComma": "all",
55
"useTabs": false,
66
"tabWidth": 2,
7-
"printWidth": 80,
7+
"printWidth": 100,
88
"arrowParens": "always",
99
"bracketSpacing": true,
1010
"proseWrap": "preserve"

‎eslint.config.mjs

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import globals from 'globals';
21
import pluginJs from '@eslint/js';
32
import { configs as tseslint } from 'typescript-eslint';
43
import pluginReact from 'eslint-plugin-react';
@@ -34,10 +33,8 @@ export default [
3433
pluginImport.flatConfigs.recommended,
3534
{
3635
rules: {
37-
'import/order': [
38-
'error',
39-
{ groups: ['builtin', 'external', 'internal'] },
40-
],
36+
'prettier/prettier': ['error', { printWidth: 100 }],
37+
'import/order': ['error', { groups: ['builtin', 'external', 'internal'] }],
4138
'testing-library/no-container': 'warn',
4239
'testing-library/no-node-access': 'warn',
4340
'react/react-in-jsx-scope': 'off',

‎package.json

+14-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
"name": "velog-dashboard-v2-fe",
33
"version": "0.1.0",
44
"private": true,
5+
"lint-staged": {
6+
"*.{ts,tsx}": [
7+
"next lint",
8+
"prettier --check"
9+
]
10+
},
511
"scripts": {
612
"dev": "next dev --port 3000",
713
"build": "next build",
@@ -10,20 +16,19 @@
1016
"lint": "next lint",
1117
"lintTest": "eslint ./src/__test__",
1218
"format": "prettier --check --ignore-path .gitignore --ignore-path pnpm-lock.yaml .",
13-
"test": "jest"
19+
"test": "jest",
20+
"prepare": "husky"
1421
},
1522
"dependencies": {
1623
"@channel.io/channel-web-sdk-loader": "^2.0.0",
1724
"@next/third-parties": "^15.1.7",
1825
"@sentry/core": "^8.47.0",
1926
"@sentry/nextjs": "^8.47.0",
2027
"@tailwindcss/typography": "^0.5.16",
21-
"@tanstack/react-query": "^5.61.3",
22-
"@tanstack/react-query-devtools": "^5.62.11",
28+
"@tanstack/react-query": "^5.69.0",
29+
"@tanstack/react-query-devtools": "^5.69.0",
2330
"chart.js": "^4.4.7",
24-
"jest-fixed-jsdom": "^0.0.9",
2531
"js-cookie": "^3.0.5",
26-
"msw": "^2.7.0",
2732
"next": "14.2.18",
2833
"react": "^18",
2934
"react-chartjs-2": "^5.2.0",
@@ -58,9 +63,13 @@
5863
"eslint-plugin-react": "^7.37.2",
5964
"eslint-plugin-testing-library": "^6.5.0",
6065
"globals": "^15.12.0",
66+
"husky": "^9.1.7",
6167
"jest": "^29.7.0",
6268
"jest-environment-jsdom": "^29.7.0",
6369
"jest-fetch-mock": "^3.0.3",
70+
"jest-fixed-jsdom": "^0.0.9",
71+
"lint-staged": "^15.5.0",
72+
"msw": "^2.7.3",
6473
"postcss": "^8",
6574
"prettier": "^3.3.3",
6675
"tailwindcss": "^3.4.1",

‎pnpm-lock.yaml

+359-58
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎readme.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- `git clone https://github.com/Check-Data-Out/velog-dashboard-v2-fe.git`
88
- `cd velog-dashboard-v2-fe`
99
- `pnpm install`
10-
- `NODE_ENV=development pnpm install`
10+
- `pnpm prepare` (husky 설정)
1111
- `pnpm dev`
1212

1313
## 린팅

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

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import Link from 'next/link';
21
import { usePathname } from 'next/navigation';
32
import { Icon, NameType } from '@/components';
43
import { COLORS } from '@/constants';
4+
import { useCustomNavigation } from '@/hooks';
55

66
export const defaultStyle =
77
'w-[180px] h-[65px] px-9 transition-all duration-300 shrink-0 max-MBI:w-[65px] max-MBI:px-0 ';
@@ -40,13 +40,13 @@ export const Section = <T extends clickType>({
4040
icon,
4141
}: PropType<T>) => {
4242
const currentPath = usePathname();
43+
const { push } = useCustomNavigation();
4344

4445
if (clickType === 'link') {
4546
return (
46-
<Link
47-
href={action}
47+
<div
48+
onClick={() => push(action)}
4849
className={defaultStyle + navigateStyle}
49-
id="navigation"
5050
>
5151
<Icon
5252
size={25}
@@ -64,7 +64,7 @@ export const Section = <T extends clickType>({
6464
>
6565
{children}
6666
</span>
67-
</Link>
67+
</div>
6868
);
6969
}
7070

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

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
'use client';
22

33
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4-
import { usePathname, useRouter } from 'next/navigation';
4+
import { usePathname } from 'next/navigation';
55
import { useEffect, useRef, useState } from 'react';
66
import Image from 'next/image';
77
import { revalidate } from '@/utils/revalidateUtil';
88
import { PATHS, SCREENS } from '@/constants';
99
import { NameType } from '@/components';
10-
import { useResponsive } from '@/hooks';
10+
import { useCustomNavigation, useResponsive } from '@/hooks';
1111
import { logout, me } from '@/apis';
1212
import { useModal } from '@/hooks/useModal';
1313
import { defaultStyle, Section, textStyle } from './Section';
@@ -33,7 +33,7 @@ export const Header = () => {
3333
const { open: ModalOpen } = useModal();
3434
const menu = useRef<HTMLDivElement | null>(null);
3535
const path = usePathname();
36-
const router = useRouter();
36+
const { replace } = useCustomNavigation();
3737
const width = useResponsive();
3838
const barWidth = width < SCREENS.MBI ? 65 : 180;
3939
const client = useQueryClient();
@@ -43,7 +43,7 @@ export const Header = () => {
4343
onSuccess: async () => {
4444
await revalidate();
4545
client.clear();
46-
router.replace('/');
46+
replace('/');
4747
},
4848
});
4949

@@ -71,7 +71,7 @@ export const Header = () => {
7171
<div className="flex w-fit">
7272
<Section
7373
clickType="function"
74-
action={() => router.replace(`/main${PARAMS.MAIN}`)}
74+
action={() => replace(`/main${PARAMS.MAIN}`)}
7575
>
7676
<Image
7777
width={35}
@@ -121,7 +121,7 @@ export const Header = () => {
121121
<div className="w-0 h-0 border-[15px] ml-3 mr-3 border-TRANSPARENT border-b-BG-SUB" />
122122
<div className="cursor-pointer h-fit flex-col rounded-[4px] bg-BG-SUB shadow-BORDER-MAIN shadow-md">
123123
<button
124-
className="text-DESTRUCTIVE-SUB text-I3 p-5 max-MBI:p-4 flex whitespace-nowrap w-auto"
124+
className="text-DESTRUCTIVE-SUB text-I3 p-5 max-MBI:p-4 flex whitespace-nowrap w-auto hover:bg-BG-ALT"
125125
onClick={() => out()}
126126
>
127127
로그아웃

‎src/app/(auth-required)/main/Content.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
44
import { useEffect } from 'react';
55
import { useInView } from 'react-intersection-observer';
6+
import { useSearchParam } from '@/hooks/useSearchParam';
67
import { Button, Dropdown, Check } from '@/components';
7-
import { Section, Summary } from './components';
88
import { postList, postSummary } from '@/apis';
99
import { PATHS, SORT_TYPE } from '@/constants';
10-
import { useSearchParam } from '@/hooks/useSearchParam';
1110
import { SortKey, SortValue } from '@/types';
11+
import { Section, Summary } from './components';
1212

1313
const sorts: Array<[SortKey, SortValue]> = Object.entries(SORT_TYPE) as Array<
1414
[SortKey, SortValue]

‎src/app/(login)/Content.tsx

+12-4
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
'use client';
22

33
import { useMutation } from '@tanstack/react-query';
4-
import { useRouter } from 'next/navigation';
54
import { useForm } from 'react-hook-form';
65
import Image from 'next/image';
76
import { Input, Button } from '@/components';
87
import { login, sampleLogin } from '@/apis';
98
import { LoginVo } from '@/types';
9+
import { useCustomNavigation } from '@/hooks';
1010

1111
const responsiveStyle =
1212
"flex items-center gap-5 max-MBI:before:inline-block max-MBI:before:bg-[url('/favicon.png')] max-MBI:before:[background-size:_100%_100%] max-MBI:before:w-16 max-MBI:before:h-16";
1313

1414
export const Content = () => {
15-
const { replace } = useRouter();
15+
const { replace, start, complete } = useCustomNavigation();
1616

1717
const {
1818
register,
@@ -27,17 +27,22 @@ export const Content = () => {
2727
const { mutate } = useMutation({
2828
mutationFn: login,
2929
onSuccess,
30+
onError: complete,
3031
});
3132

3233
const { mutate: sampleMutate } = useMutation({
3334
mutationFn: sampleLogin,
3435
onSuccess,
36+
onError: complete,
3537
});
3638

3739
return (
3840
<main className="w-full h-full flex justify-center MBI:items-center max-MBI:p-[30px_25px]">
3941
<form
40-
onSubmit={handleSubmit((data: LoginVo) => mutate(data))}
42+
onSubmit={handleSubmit((data: LoginVo) => {
43+
start();
44+
mutate(data);
45+
})}
4146
className="h-[480px] flex bg-BG-SUB rounded-[4px] max-MBI:bg-BG-MAIN max-MBI:h-fit max-MBI:w-full"
4247
>
4348
<div className="w-[220px] h-full bg-BG-ALT flex items-center justify-center max-MBI:hidden ">
@@ -77,7 +82,10 @@ export const Content = () => {
7782
</Button>
7883
<span
7984
className="text-TEXT-ALT text-I2 max-MBI:text-ST5 after:cursor-pointer after:hover:underline after:ml-2 after:content-['체험_계정으로_로그인'] after:text-PRIMARY-MAIN after:inline-block"
80-
onClick={() => sampleMutate()}
85+
onClick={() => {
86+
start();
87+
sampleMutate();
88+
}}
8189
>
8290
서비스를 체험해보고 싶다면?
8391
</span>

‎src/app/layout.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ModalProvider,
1313
} from '@/components';
1414
import { env } from '@/constants';
15+
import { TopBarProvider } from '@/components/Providers/TopBarProvider';
1516

1617
export const BASE = 'https://velog-dashboard.kro.kr/';
1718

@@ -44,6 +45,7 @@ export default function RootLayout({
4445
<ChannelTalkProvider>
4546
<ToastContainer autoClose={2000} />
4647
<ModalProvider />
48+
<TopBarProvider />
4749
<Suspense>{children}</Suspense>
4850
</ChannelTalkProvider>
4951
</QueryProvider>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client';
2+
import { useCallback, useEffect, useRef, useState } from 'react';
3+
import { useCustomNavigation } from '@/hooks';
4+
import { COLORS } from '@/constants';
5+
6+
const START_TIME_MS = 150;
7+
const REMOVE_BAR_TIME_MS = 400;
8+
const INCREASE_INTERVAL_MS = 300;
9+
const INCREASE_LEVEL = [0.6, 0.3, 0.1];
10+
11+
export const TopBarProvider = () => {
12+
const { isNavigating } = useCustomNavigation();
13+
14+
const [width, setWidth] = useState(0);
15+
const initialLoadDone = useRef(false);
16+
const intervalRef = useRef<null | NodeJS.Timeout>(null);
17+
18+
const startLoading = useCallback(() => {
19+
setWidth(20);
20+
initialLoadDone.current = true;
21+
22+
intervalRef.current = setInterval(() => {
23+
setWidth((prev) => prev + Math.random() * INCREASE_LEVEL[Math.floor(prev / 30)]);
24+
}, INCREASE_INTERVAL_MS);
25+
}, []);
26+
27+
useEffect(() => {
28+
if (isNavigating) {
29+
initialLoadDone.current = false;
30+
setWidth(0);
31+
32+
const initialTimer = setTimeout(startLoading, START_TIME_MS);
33+
34+
return () => {
35+
clearTimeout(initialTimer);
36+
if (intervalRef.current) clearInterval(intervalRef.current);
37+
};
38+
} else if (initialLoadDone.current) {
39+
if (intervalRef.current) {
40+
clearInterval(intervalRef.current);
41+
intervalRef.current = null;
42+
}
43+
44+
setWidth(100);
45+
const hideTimer = setTimeout(() => setWidth(0), REMOVE_BAR_TIME_MS);
46+
47+
return () => clearTimeout(hideTimer);
48+
}
49+
}, [isNavigating]);
50+
51+
return (
52+
<div className="fixed top-0 left-0 z-50 w-full h-1">
53+
<div
54+
className={`h-full transition-all bg-PRIMARY-MAIN shadow-[0_0_3px_${COLORS.PRIMARY.SUB}] duration-300 ease-out`}
55+
style={{ width: `${width}%` }}
56+
/>
57+
</div>
58+
);
59+
};

‎src/components/Providers/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './ChannelTalkProvider';
2+
export * from './TopBarProvider';
23
export * from './QueryProvider';
34
export * from './ModalProvider';

‎src/hooks/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1+
export * from './useCustomNavigation';
12
export * from './useSearchParam';
23
export * from './useResponsive';
4+
export * from './useModal';

‎src/hooks/useCustomNavigation.tsx

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { create } from 'zustand';
2+
import { usePathname, useRouter } from 'next/navigation';
3+
import { useCallback, useEffect, useRef } from 'react';
4+
5+
type NavStatusStore = {
6+
isNavigating: boolean;
7+
flip: () => void;
8+
start: () => void;
9+
complete: () => void;
10+
};
11+
12+
const useNavStatus = create<NavStatusStore>((set) => ({
13+
isNavigating: false,
14+
flip: () => set(({ isNavigating }) => ({ isNavigating: !isNavigating })),
15+
start: () => set({ isNavigating: true }),
16+
complete: () => set({ isNavigating: false }),
17+
}));
18+
19+
export const useCustomNavigation = () => {
20+
const router = useRouter();
21+
const pathname = usePathname();
22+
const prevPathRef = useRef(pathname);
23+
const { isNavigating, start, complete } = useNavStatus();
24+
25+
const replace = useCallback(
26+
(target: string) => {
27+
router.replace(target);
28+
start();
29+
},
30+
[router],
31+
);
32+
33+
const push = useCallback(
34+
(target: string) => {
35+
router.push(target);
36+
start();
37+
},
38+
[router],
39+
);
40+
41+
useEffect(() => {
42+
// 페이지 이동 완료 감지
43+
if (prevPathRef.current !== pathname) {
44+
prevPathRef.current = pathname;
45+
complete();
46+
}
47+
}, [pathname]);
48+
49+
return { isNavigating, start, push, complete, replace };
50+
};

0 commit comments

Comments
 (0)
Please sign in to comment.