├── .dockerignore
├── .eslintrc.cjs
├── .github
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── production.yml
├── .gitignore
├── .gitmessage.txt
├── .husky
├── pre-commit
└── pre-push
├── .prettierrc
├── Dockerfile
├── README.md
├── index.html
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── icon-16x16.ico
├── icon-192x192.png
├── icon-256x256.png
├── icon-384x384.png
├── icon-512x512.png
├── icon_144x144.png
├── icon_48x48.png
├── icon_96x96.png
├── sw.js
├── sw.js.map
├── workbox-4754cb34.js
├── workbox-bd7e3b9b.js
└── workbox-bd7e3b9b.js.map
├── src
├── actions
│ └── index.ts
├── app
│ ├── api
│ │ └── auth
│ │ │ └── kakao
│ │ │ ├── redirect
│ │ │ └── route.ts
│ │ │ └── route.ts
│ ├── apple-icon.png
│ ├── error.tsx
│ ├── fonts
│ │ ├── Gugi-Regular.woff2
│ │ ├── Pretendard-Black.woff2
│ │ ├── Pretendard-Bold.woff2
│ │ ├── Pretendard-ExtraBold.woff2
│ │ ├── Pretendard-ExtraLight.woff2
│ │ ├── Pretendard-Light.woff2
│ │ ├── Pretendard-Medium.woff2
│ │ ├── Pretendard-Regular.woff2
│ │ ├── Pretendard-SemiBold.woff2
│ │ └── Pretendard-Thin.woff2
│ ├── global.css
│ ├── home
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── loading.tsx
│ ├── manifest.ts
│ ├── not-found.tsx
│ ├── opengraph-image.png
│ ├── page.tsx
│ ├── profile
│ │ ├── delete
│ │ │ └── page.tsx
│ │ └── page.tsx
│ ├── quiz
│ │ ├── page.tsx
│ │ └── result
│ │ │ └── [quizResultId]
│ │ │ ├── @modal
│ │ │ ├── (.)share
│ │ │ │ └── page.tsx
│ │ │ ├── default.tsx
│ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx
│ │ │ └── share
│ │ │ └── page.tsx
│ ├── robots.txt
│ ├── sitemap.xml
│ ├── user
│ │ └── wordbook
│ │ │ └── page.tsx
│ ├── word
│ │ └── search
│ │ │ └── page.tsx
│ └── words
│ │ └── [wordName]
│ │ ├── loading.tsx
│ │ └── page.tsx
├── components
│ ├── common
│ │ ├── BackButton.tsx
│ │ ├── CopiedNotice.tsx
│ │ ├── ErrorHandlingMarkup.tsx
│ │ ├── LoginAlertModal.tsx
│ │ ├── Modal.tsx
│ │ ├── Pagination.tsx
│ │ ├── ShareModal.tsx
│ │ ├── Spinner.tsx
│ │ ├── TTSPlayer.tsx
│ │ ├── ToolTip.tsx
│ │ └── WordItem.tsx
│ ├── layout
│ │ ├── Header.tsx
│ │ ├── HeightWrapper.tsx
│ │ └── SearchBar.tsx
│ ├── pages
│ │ ├── detail
│ │ │ ├── DetailHeader.tsx
│ │ │ ├── DetailLoading.tsx
│ │ │ ├── DetailTTSButton.tsx
│ │ │ ├── LikeButton.tsx
│ │ │ ├── PronunciationDetail.tsx
│ │ │ ├── ReportButton.tsx
│ │ │ └── URLShareButton.tsx
│ │ ├── error
│ │ │ └── index.tsx
│ │ ├── home
│ │ │ ├── HomeSkeleton.tsx
│ │ │ ├── HomeToggleZone.tsx
│ │ │ ├── QuizButton.tsx
│ │ │ ├── all-posts
│ │ │ │ └── index.tsx
│ │ │ ├── index.tsx
│ │ │ └── trending-posts
│ │ │ │ ├── ComingSoonAlert.tsx
│ │ │ │ ├── GeneralRanking.tsx
│ │ │ │ ├── RankChange.tsx
│ │ │ │ ├── TopRanking.tsx
│ │ │ │ ├── TrendingDescription.tsx
│ │ │ │ └── index.tsx
│ │ ├── login
│ │ │ ├── Carousel.tsx
│ │ │ ├── FirstSlide.tsx
│ │ │ ├── SecondSlide.tsx
│ │ │ ├── ThirdSlide.tsx
│ │ │ └── index.tsx
│ │ ├── not-found
│ │ │ └── index.tsx
│ │ ├── profile
│ │ │ ├── DeleteAccount.tsx
│ │ │ ├── Modal
│ │ │ │ ├── DeleteAccountModal.tsx
│ │ │ │ ├── InquiryModal.tsx
│ │ │ │ └── LogoutModal.tsx
│ │ │ ├── NonLoginProfileInfo.tsx
│ │ │ ├── ProfileHeader.tsx
│ │ │ ├── ProfileInfo.tsx
│ │ │ ├── SubmitFeedback.tsx
│ │ │ └── index.tsx
│ │ ├── quiz
│ │ │ ├── GuestQuizResult.tsx
│ │ │ ├── QuizBackModal.tsx
│ │ │ ├── QuizPlay.tsx
│ │ │ ├── QuizResult.tsx
│ │ │ ├── QuizResultDetail.tsx
│ │ │ ├── QuizResultDetailWord.tsx
│ │ │ ├── QuizScore.tsx
│ │ │ ├── QuizShareTooltip.tsx
│ │ │ └── index.tsx
│ │ ├── search
│ │ │ ├── AutoComplete.tsx
│ │ │ ├── EngOnlyAlert.tsx
│ │ │ ├── NotFoundWord.tsx
│ │ │ └── index.tsx
│ │ ├── system-check-notice
│ │ │ └── index.tsx
│ │ └── wordbook
│ │ │ ├── QuizBanner.tsx
│ │ │ ├── WordbookDropdown.tsx
│ │ │ ├── WordbookHeader.tsx
│ │ │ └── index.tsx
│ └── svg-component
│ │ ├── ArrowDownSvg.tsx
│ │ ├── ArrowUpSvg.tsx
│ │ ├── BackButtonSvg.tsx
│ │ ├── BackSpaceSvg.tsx
│ │ ├── BellSvg.tsx
│ │ ├── BigEmailSvg.tsx
│ │ ├── BigMagnifierSvg.tsx
│ │ ├── BlackBackSpaceSVG.tsx
│ │ ├── CheckSvg.tsx
│ │ ├── ClearSearchBarSvg.tsx
│ │ ├── CloseSvg.tsx
│ │ ├── CorrectSvg.tsx
│ │ ├── CrownLinearSvg.tsx
│ │ ├── CrownSvg.tsx
│ │ ├── DefaultProfileIconSvg.tsx
│ │ ├── DetailKoreanAlertIconSvg.tsx
│ │ ├── DetailLikeSvg.tsx
│ │ ├── DetailPronunciationCloseArrowSvg.tsx
│ │ ├── DetailPronunciationCorrectSvg.tsx
│ │ ├── DetailPronunciationShowArrowSvg.tsx
│ │ ├── DetailPronunciationWrongSvg.tsx
│ │ ├── DetailSoundIconSvg.tsx
│ │ ├── EmailSvg.tsx
│ │ ├── EmptyHeartSvg.tsx
│ │ ├── ErrorSvg.tsx
│ │ ├── ExternalSvg.tsx
│ │ ├── FillArrowSvg.tsx
│ │ ├── GoodSVG.tsx
│ │ ├── HamburgerMenuSvg.tsx
│ │ ├── Heart1Svg.tsx
│ │ ├── HeartSvg.tsx
│ │ ├── HyphenSvg.tsx
│ │ ├── InquirySvg.tsx
│ │ ├── KakaoIconSvg.tsx
│ │ ├── KakaoShareSvg.tsx
│ │ ├── KakaotalkSvg.tsx
│ │ ├── LandingCarouselSecond.tsx
│ │ ├── LandingCarouselThird.tsx
│ │ ├── LinkShareIconSvg.tsx
│ │ ├── LinkShareSvg.tsx
│ │ ├── LockSmallSvg.tsx
│ │ ├── LockSvg.tsx
│ │ ├── LogoColorSvg.tsx
│ │ ├── LogoSvg.tsx
│ │ ├── LogoTextSvg.tsx
│ │ ├── MagnifierSvg.tsx
│ │ ├── MenuSvg.tsx
│ │ ├── ModalXSvg.tsx
│ │ ├── MypageIconSvg.tsx
│ │ ├── NoWordSvg.tsx
│ │ ├── NonLoginImage.tsx
│ │ ├── NotFoundSvg.tsx
│ │ ├── NoticeIconSvg.tsx
│ │ ├── OSVG.tsx
│ │ ├── OneButtonSvg.tsx
│ │ ├── PowerSvg.tsx
│ │ ├── QuizSvg.tsx
│ │ ├── ResultScoreSVG.tsx
│ │ ├── RightAngleBracketSvg.tsx
│ │ ├── RightArrowSvg.tsx
│ │ ├── ScoreResultSvg.tsx
│ │ ├── ScoreSvg.tsx
│ │ ├── ShareButtonSvg.tsx
│ │ ├── SpeakerSvg.tsx
│ │ ├── SubmitFeedbackSvg.tsx
│ │ ├── TestTextSVG.tsx
│ │ ├── TriangleSvg.tsx
│ │ ├── TwoButtonSvg.tsx
│ │ ├── WarningBellSvg.tsx
│ │ ├── WordBookSvg.tsx
│ │ ├── WrongSvg.tsx
│ │ └── XSVG.tsx
├── constants
│ ├── home.constants.ts
│ ├── queryKey.ts
│ └── sortingOptions.ts
├── fetcher
│ ├── backendFetch.ts
│ ├── fetch.ts
│ ├── index.ts
│ ├── interceptors.ts
│ ├── server.ts
│ ├── serverFetch.ts
│ └── types.ts
├── hooks
│ ├── mutation
│ │ ├── useAddLike.ts
│ │ ├── useDeleteLike.ts
│ │ └── useQuizResult.ts
│ ├── query
│ │ ├── useAuthQuery.ts
│ │ ├── useGetAllPosts.ts
│ │ ├── useGetLikedWord.ts
│ │ ├── useGetQuizData.ts
│ │ ├── useGetSearchWord.ts
│ │ ├── useGetTTSUrl.ts
│ │ ├── useGetTrendList.ts
│ │ └── useGetUser.ts
│ ├── useAudioPlayer.ts
│ ├── useCopyClipboard.ts
│ ├── useDebounce.ts
│ ├── useDeviceType.ts
│ ├── useDropdown.ts
│ ├── useLoadKakaoScript.ts
│ ├── useMutationLike.ts
│ ├── useOnClickOutside.ts
│ ├── useOptimisticLike.ts
│ ├── usePagination.tsx
│ ├── useScroll.tsx
│ └── useSyncURLHomeRouteState.ts
├── middleware.ts
├── providers
│ └── QueryProvider.tsx
├── routes
│ └── path.ts
├── store
│ └── index.ts
├── types
│ ├── errorHandling.ts
│ ├── main.ts
│ └── quiz.ts
├── utils
│ └── index.ts
└── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .git
3 | .gitignore
4 | *.md
5 | dist
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | '@typescript-eslint/no-explicit-any': 'error',
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## 📌 기능 설명
2 |
3 |
7 |
8 | ## 📌 구현 내용
9 |
10 |
11 |
15 |
16 | ## 📌 구현 결과
17 |
18 |
19 | ## 📌 논의하고 싶은 점
20 |
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | .eslintcache
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # Editor directories and files
24 | .vscode/*
25 | !.vscode/extensions.json
26 | .idea
27 | .DS_Store
28 | *.suo
29 | *.ntvs*
30 | *.njsproj
31 | *.sln
32 | *.sw?
33 |
34 | # local env files
35 | .env*.local
36 |
37 | # vercel
38 | .vercel
39 |
40 | # typescript
41 | *.tsbuildinfo
42 | next-env.d.ts
43 |
44 |
45 | .env
46 | .env.development.local
47 | .env.test.local
48 | .env.production.local
49 | .env.local
50 |
51 | # workbox 캐싱 파일 무시
52 | /public/workbox-*
53 |
54 | # 서비스 워커 소스맵 무시
55 | /public/sw.js.map
56 |
57 |
--------------------------------------------------------------------------------
/.gitmessage.txt:
--------------------------------------------------------------------------------
1 | ################
2 | # <타입> : <제목> 의 형식으로 제목을 아래 공백줄에 작성
3 | # 제목은 50자 이내 / 변경사항이 "무엇"인지 명확히 작성 / 끝에 마침표 금지
4 | # 예) feat : 로그인 기능 추가
5 |
6 | # 바로 아래 공백은 지우지 마세요 (제목과 본문의 분리를 위함)
7 |
8 | ################
9 | # 본문(구체적인 내용)을 아랫줄에 작성
10 | # 여러 줄의 메시지를 작성할 땐 "-"로 구분 (한 줄은 72자 이내)
11 |
12 | ################
13 | # 꼬릿말(footer)을 아랫줄에 작성 (현재 커밋과 관련된 이슈 번호 추가 등)
14 |
15 | ################
16 | # feat : 새로운 기능 추가
17 | # fix : 버그 수정
18 | # docs : 문서 수정
19 | # test : 테스트 코드 추가
20 | # refactor : 코드 리팩토링
21 | # style : 코드 의미에 영향을 주지 않는 변경사항
22 | # chore : 빌드 부분 혹은 패키지 매니저 수정사항
23 | ################
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | # 커밋 전 lint 실행
2 | npx lint-staged --verbose
3 |
4 |
--------------------------------------------------------------------------------
/.husky/pre-push:
--------------------------------------------------------------------------------
1 | # push하기 전에 빌드 실행
2 | npm run build
3 | if [ $? -eq 0 ]; then
4 | echo "Build completed successfully."
5 | else
6 | echo "Build failed. Aborting push."
7 | exit 1
8 | fi
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "tabWidth": 2,
5 | "useTabs": false,
6 | "trailingComma": "all",
7 | "bracketSpacing": true,
8 | "jsxBracketSameLine": false,
9 | "arrowParens": "always",
10 | "printWidth": 80
11 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:lts-alpine AS base
2 |
3 | FROM base AS deps
4 | RUN apk add --no-cache libc6-compat
5 | WORKDIR /usr/src/app
6 | COPY package.json package-lock.json ./
7 | RUN npm ci
8 |
9 | # Builder
10 | FROM base AS builder
11 | WORKDIR /usr/src/app
12 | COPY --from=deps /usr/src/app/node_modules ./node_modules
13 | COPY . .
14 | RUN npm run build
15 |
16 |
17 | # Runner
18 | FROM base AS runner
19 | WORKDIR /usr/src/app
20 |
21 | ENV NODE_ENV=production
22 | RUN addgroup --system --gid 1001 nodejs
23 | RUN adduser --system --uid 1001 nextjs
24 |
25 | COPY --from=builder /usr/src/app/public ./public
26 | COPY --from=builder --chown=nextjs:nodejs /usr/src/app/.next/standalone ./
27 | COPY --from=builder --chown=nextjs:nodejs /usr/src/app/.next/static ./.next/static
28 |
29 | USER nextjs
30 | EXPOSE 3000
31 | ENV PORT 3000
32 | CMD ["node", "server.js"]
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 데브말ㅆㆍ미
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | import nextPWA from 'next-pwa'
3 |
4 | const withPWA = nextPWA({
5 | dest: 'public'
6 | })
7 |
8 | const nextConfig = {
9 | reactStrictMode: true,
10 | productionBrowserSourceMaps: false,
11 | output: 'standalone',
12 | images: {
13 | remotePatterns: [
14 | {
15 | protocol: 'https',
16 | hostname: '*.kakaocdn.net',
17 | port: '',
18 | pathname: '/**',
19 | },
20 | {
21 | protocol: 'http',
22 | hostname: '*.kakaocdn.net',
23 | port: '',
24 | pathname: '/**',
25 | },
26 | {
27 | protocol: 'https',
28 | hostname: 'dev-malssami-bucket.s3.ap-northeast-2.amazonaws.com',
29 | port: '',
30 | pathname: '/**',
31 | },
32 | ],
33 | },
34 | };
35 |
36 | export default withPWA(nextConfig);
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dev-malssami",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "next dev",
8 | "build": "next build",
9 | "start": "next start",
10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
11 | "debug": "NODE_OPTIONS='--inspect' next dev",
12 | "prepare": "husky"
13 | },
14 | "lint-staged": {
15 | "src/**/*.{js,jsx,ts,tsx}": [
16 | "eslint --cache --fix",
17 | "prettier --cache --write"
18 | ]
19 | },
20 | "dependencies": {
21 | "@next/third-parties": "^14.2.3",
22 | "@tanstack/react-query": "^5.28.14",
23 | "@tanstack/react-query-devtools": "^5.40.1",
24 | "clsx": "^2.1.1",
25 | "jotai": "^2.8.3",
26 | "next": "^14.2.2",
27 | "next-pwa": "^5.6.0",
28 | "react": "^18.2.0",
29 | "react-dom": "^18.2.0"
30 | },
31 | "devDependencies": {
32 | "@types/node": "^20.12.5",
33 | "@types/react": "^18.2.66",
34 | "@types/react-dom": "^18.2.22",
35 | "@typescript-eslint/eslint-plugin": "^7.2.0",
36 | "@typescript-eslint/parser": "^7.2.0",
37 | "autoprefixer": "^10.4.19",
38 | "eslint": "^8.57.0",
39 | "eslint-config-next": "14.1.4",
40 | "eslint-plugin-react-hooks": "^4.6.0",
41 | "eslint-plugin-react-refresh": "^0.4.6",
42 | "husky": "^9.1.5",
43 | "lint-staged": "^15.2.7",
44 | "postcss": "^8.4.38",
45 | "prettier": "3.2.5",
46 | "tailwindcss": "^3.4.3",
47 | "typescript": "^5.2.2"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/icon-16x16.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/public/icon-16x16.ico
--------------------------------------------------------------------------------
/public/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/public/icon-192x192.png
--------------------------------------------------------------------------------
/public/icon-256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/public/icon-256x256.png
--------------------------------------------------------------------------------
/public/icon-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/public/icon-384x384.png
--------------------------------------------------------------------------------
/public/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/public/icon-512x512.png
--------------------------------------------------------------------------------
/public/icon_144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/public/icon_144x144.png
--------------------------------------------------------------------------------
/public/icon_48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/public/icon_48x48.png
--------------------------------------------------------------------------------
/public/icon_96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/public/icon_96x96.png
--------------------------------------------------------------------------------
/src/actions/index.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { revalidatePath } from 'next/cache';
4 | import { serverFetch } from '@/fetcher/serverFetch.ts';
5 | import { FetchRes, DefaultRes } from '@/fetcher/types.ts';
6 |
7 | export const addLike = async (wordId: string) => {
8 | try {
9 | await serverFetch>>(`/like/${wordId}`, {
10 | method: 'PATCH',
11 | });
12 | } catch (e) {
13 | console.log(e);
14 | // NOTE: 발생할 수 있는 에러
15 | // 401 => 권한 없음
16 | // 500 => 서버 에러
17 | }
18 |
19 | revalidatePath(`word/${wordId}`);
20 | };
21 |
22 | export const subLike = async (wordId: string) => {
23 | try {
24 | await serverFetch>>(`/like/${wordId}`, {
25 | method: 'DELETE',
26 | });
27 | } catch (e) {
28 | console.log(e);
29 | // NOTE: 발생할 수 있는 에러
30 | // 401 => 권한 없음
31 | // 500 => 서버 에러
32 | }
33 | // NOTE: refresh하고 싶은 path를 작성
34 | revalidatePath(`word/${wordId}`);
35 | };
36 |
--------------------------------------------------------------------------------
/src/app/api/auth/kakao/redirect/route.ts:
--------------------------------------------------------------------------------
1 | import { login } from '@/fetcher';
2 | import { NextResponse } from 'next/server';
3 |
4 | export async function GET(request: Request) {
5 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
6 | const { searchParams } = new URL(request.url);
7 | // NOTE: params에 포함된 인가 코드를 추출한다.
8 | const code = searchParams.get('code');
9 |
10 | if (!code) {
11 | console.log('인증에 필요한 code parameter가 query에 없습니다.');
12 | return NextResponse.redirect(`${baseUrl}/api/auth/kakao`);
13 | }
14 |
15 | try {
16 | // NOTE: backend에 인가 코드를 보내 로그인을 시도한다.
17 | const { headers, data, status } = await login(code);
18 |
19 | if (data) {
20 | const redirectToHome = NextResponse.redirect(
21 | `${baseUrl}/home?trend=all&page=1`,
22 | );
23 | const cookie = headers.get('Set-Cookie');
24 |
25 | if (cookie) {
26 | redirectToHome.headers.append('Set-Cookie', cookie);
27 | }
28 | return redirectToHome;
29 | }
30 |
31 | // NOTE: Data 가 정상적으로 오지 않았다면 Error를 발생시킨다.
32 | throw new Error(
33 | JSON.stringify({
34 | statusCode: status,
35 | message: 'data가 존재하지 않습니다.',
36 | }),
37 | );
38 | } catch (err) {
39 | console.log(err);
40 | return NextResponse.redirect(`${baseUrl}`);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/api/auth/kakao/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | const KAKAO_OAUTH_URL = 'https://kauth.kakao.com/oauth/authorize';
4 | const DEV_KAKAO_REDIRECT_URL = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/kakao/redirect`;
5 |
6 | export async function GET() {
7 | const kakaoRestApiKey = process.env.KAKAO_CLIENT_ID;
8 | const kakaoOauthRedirectUri = process.env.KAKAO_REDIRECT_URI;
9 |
10 | if (!kakaoRestApiKey) {
11 | return NextResponse.json(null, {
12 | status: 500,
13 | statusText: 'Kakao Rest Api key 가 없습니다.',
14 | });
15 | }
16 |
17 | const kakaoOauthUrl = new URL(KAKAO_OAUTH_URL);
18 | kakaoOauthUrl.searchParams.set('client_id', kakaoRestApiKey);
19 | kakaoOauthUrl.searchParams.set('response_type', 'code');
20 | kakaoOauthUrl.searchParams.set(
21 | 'redirect_uri',
22 | kakaoOauthRedirectUri || DEV_KAKAO_REDIRECT_URL,
23 | );
24 |
25 | return NextResponse.redirect(kakaoOauthUrl.toString());
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/apple-icon.png
--------------------------------------------------------------------------------
/src/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Error from '@/components/pages/error';
4 |
5 | export default function ErrorPage() {
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/fonts/Gugi-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/fonts/Gugi-Regular.woff2
--------------------------------------------------------------------------------
/src/app/fonts/Pretendard-Black.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/fonts/Pretendard-Black.woff2
--------------------------------------------------------------------------------
/src/app/fonts/Pretendard-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/fonts/Pretendard-Bold.woff2
--------------------------------------------------------------------------------
/src/app/fonts/Pretendard-ExtraBold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/fonts/Pretendard-ExtraBold.woff2
--------------------------------------------------------------------------------
/src/app/fonts/Pretendard-ExtraLight.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/fonts/Pretendard-ExtraLight.woff2
--------------------------------------------------------------------------------
/src/app/fonts/Pretendard-Light.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/fonts/Pretendard-Light.woff2
--------------------------------------------------------------------------------
/src/app/fonts/Pretendard-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/fonts/Pretendard-Medium.woff2
--------------------------------------------------------------------------------
/src/app/fonts/Pretendard-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/fonts/Pretendard-Regular.woff2
--------------------------------------------------------------------------------
/src/app/fonts/Pretendard-SemiBold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/fonts/Pretendard-SemiBold.woff2
--------------------------------------------------------------------------------
/src/app/fonts/Pretendard-Thin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/fonts/Pretendard-Thin.woff2
--------------------------------------------------------------------------------
/src/app/global.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | html,
7 | body {
8 | font-family:
9 | 'Pretendard',
10 | 'Gugi',
11 | -apple-system,
12 | BlinkMacSystemFont,
13 | 'Segoe UI',
14 | Roboto,
15 | 'Helvetica Neue',
16 | Arial,
17 | 'Noto Sans',
18 | sans-serif,
19 | 'Apple Color Emoji',
20 | 'Segoe UI Emoji',
21 | 'Segoe UI Symbol',
22 | 'Noto Color Emoji';
23 | }
24 | }
25 |
26 | .customButton {
27 | --svg-stroke-color: #0c3fc1;
28 | --svg-fill-color: #e3ecff;
29 | }
30 |
31 | .customButton.hover-disabled {
32 | --svg-stroke-color: #d7dceb;
33 | --svg-fill-color: #fbfcfe;
34 | }
35 |
36 | @keyframes slow-spin {
37 | 0% {
38 | opacity: 0;
39 | transform: scale(0) rotate(0deg);
40 | }
41 | 50% {
42 | opacity: 1;
43 | transform: scale(1) rotate(180deg);
44 | }
45 | 100% {
46 | opacity: 0;
47 | transform: scale(0) rotate(360deg);
48 | }
49 | }
50 |
51 | .circle-spin {
52 | animation: slow-spin 2.5s ease-in-out infinite;
53 | }
54 |
55 | .login-height {
56 | height: var(--login-height, 100dvh);
57 | }
58 |
59 | .login-padding-top {
60 | padding-top: var(--login-padding-top, 30vh);
61 | }
62 |
--------------------------------------------------------------------------------
/src/app/home/page.tsx:
--------------------------------------------------------------------------------
1 | import Header from '@/components/layout/Header';
2 | import QUERY_KEYS from '@/constants/queryKey';
3 | import {
4 | HydrationBoundary,
5 | QueryClient,
6 | dehydrate,
7 | } from '@tanstack/react-query';
8 | import { Suspense } from 'react';
9 | import {
10 | getAllPostsServer,
11 | getCurrentWeekTrendList,
12 | } from '@/fetcher/server.ts';
13 | import HomeSkeleton from '@/components/pages/home/HomeSkeleton';
14 | import type { MainItemType, MainResponse } from '@/types/main';
15 | import HomeClientPage from '@/components/pages/home';
16 |
17 | export default async function HomePage({
18 | searchParams: { page },
19 | }: {
20 | searchParams: { page: string };
21 | }) {
22 | const queryClient = new QueryClient();
23 |
24 | await Promise.all([
25 | queryClient.prefetchQuery({
26 | queryFn: () => getAllPostsServer(Number(page)),
27 | queryKey: [QUERY_KEYS.HOME_KEY, Number(page)],
28 | }),
29 | queryClient.prefetchQuery({
30 | queryFn: () => getCurrentWeekTrendList(),
31 | queryKey: [QUERY_KEYS.TREND],
32 | }),
33 | ]);
34 |
35 | const postsData: MainResponse | undefined =
36 | queryClient.getQueryData([QUERY_KEYS.HOME_KEY, Number(page)]);
37 |
38 | return (
39 |
40 |
41 | }
43 | >
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/app/loading.tsx:
--------------------------------------------------------------------------------
1 | import Spinner from '@/components/common/Spinner';
2 |
3 | export default function loading() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/manifest.ts:
--------------------------------------------------------------------------------
1 | import { MetadataRoute } from 'next';
2 |
3 | export default function manifest(): MetadataRoute.Manifest {
4 | return {
5 | name: '데브말싸미',
6 | short_name: '데브말싸미',
7 | icons: [
8 | {
9 | src: '/icon-192x192.png',
10 | sizes: '192x192',
11 | type: 'image/png',
12 | },
13 | {
14 | src: '/icon-256x256.png',
15 | sizes: '256x256',
16 | type: 'image/png',
17 | },
18 | {
19 | src: '/icon-384x384.png',
20 | sizes: '384x384',
21 | type: 'image/png',
22 | },
23 | {
24 | src: '/icon-512x512.png',
25 | sizes: '512x512',
26 | type: 'image/png',
27 | },
28 | ],
29 | theme_color: '#0C3FC1',
30 | background_color: '#FFFFFF',
31 | start_url: '/',
32 | display: 'standalone',
33 | orientation: 'portrait',
34 | };
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import NotFound from "@/components/pages/not-found";
2 |
3 |
4 | export default function NotFoundPage() {
5 | return ;
6 | }
7 |
--------------------------------------------------------------------------------
/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devminjeong-eum/frontend/b5f03acb9384f8addedb01cbec20300131754d3f/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @진입_로그인_페이지
3 | */
4 |
5 | import Login_Client from '@/components/pages/login';
6 |
7 | export default function page() {
8 | return ;
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/profile/delete/page.tsx:
--------------------------------------------------------------------------------
1 | import DeleteAccount from '@/components/pages/profile/DeleteAccount';
2 | import { serverFetch } from '@/fetcher/serverFetch';
3 | import { FetchRes, DefaultRes, UserData } from '@/fetcher/types';
4 | import { notFound } from 'next/navigation';
5 |
6 | const getUserInfo = async () => {
7 | try {
8 | return await serverFetch>>(`/user`);
9 | } catch (e) {
10 | console.log('error', e);
11 | notFound();
12 | }
13 | };
14 |
15 | export default async function DeleteAccountPage() {
16 | const {
17 | data: {
18 | data: { userId },
19 | },
20 | } = await getUserInfo();
21 |
22 | return ;
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/profile/page.tsx:
--------------------------------------------------------------------------------
1 | import Profile from '@/components/pages/profile';
2 | import { serverFetch } from '@/fetcher/serverFetch';
3 | import { DefaultRes, FetchRes, UserData } from '@/fetcher/types';
4 | import { cookies } from 'next/headers';
5 | import { notFound } from 'next/navigation';
6 |
7 | const getUserInfo = async () => {
8 | try {
9 | return await serverFetch>>(`/user`);
10 | } catch (e) {
11 | console.log('error', e);
12 | notFound();
13 | }
14 | };
15 |
16 | export default async function ProfilePage() {
17 | const isToken = cookies().has('accessToken');
18 |
19 | if (!isToken) return ;
20 |
21 | const {
22 | data: {
23 | data: { userId, likeCount, name, profileImage },
24 | },
25 | } = await getUserInfo();
26 |
27 | return (
28 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/quiz/page.tsx:
--------------------------------------------------------------------------------
1 | import Quiz from '@/components/pages/quiz';
2 | import { ResolvingMetadata } from 'next';
3 |
4 | // eslint-disable-next-line react-refresh/only-export-components
5 | export async function generateMetadata(
6 | _: { [x: string]: never },
7 | parent: ResolvingMetadata,
8 | ) {
9 | const parentMetadata = (await parent) || [];
10 |
11 | const openGraph = parentMetadata?.openGraph ?? {};
12 | const twitter = parentMetadata?.twitter ?? {};
13 |
14 | return {
15 | ...parentMetadata,
16 | title: '개발 용어 발음 테스트',
17 | description: '개발 용어 발음을 올바르게 알고 있는지 테스트해보세요.',
18 | openGraph: {
19 | ...openGraph,
20 | title: '개발 용어 발음 테스트',
21 | description: '개발 용어 발음을 올바르게 알고 있는지 테스트해보세요.',
22 | url: 'https://dev-malssami.site/quiz',
23 | },
24 | twitter: {
25 | ...twitter,
26 | title: '개발 용어 발음 테스트',
27 | description: '개발 용어 발음을 올바르게 알고 있는지 테스트해보세요.',
28 | },
29 | };
30 | }
31 |
32 | export default function QuizPage() {
33 | return ;
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/quiz/result/[quizResultId]/@modal/(.)share/page.tsx:
--------------------------------------------------------------------------------
1 | import ShareModal from '@/components/common/ShareModal.tsx';
2 |
3 | export default function Modal() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/quiz/result/[quizResultId]/@modal/default.tsx:
--------------------------------------------------------------------------------
1 | export default function Modal() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/quiz/result/[quizResultId]/@modal/page.tsx:
--------------------------------------------------------------------------------
1 | export default function ModalPage() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/quiz/result/[quizResultId]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | export default function Layout({
4 | children,
5 | modal,
6 | }: {
7 | children: ReactNode;
8 | modal: ReactNode;
9 | }) {
10 | return (
11 |
12 | {children}
13 | {modal}
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/quiz/result/[quizResultId]/page.tsx:
--------------------------------------------------------------------------------
1 | import QuizResult from '@/components/pages/quiz/QuizResult';
2 | import { serverFetch } from '@/fetcher/serverFetch';
3 | import { notFound } from 'next/navigation';
4 | import { FetchRes, DefaultRes, QuizResultData } from '@/fetcher/types';
5 | import GuestQuizResult from '@/components/pages/quiz/GuestQuizResult.tsx';
6 |
7 | export type Props = {
8 | params: { quizResultId: string };
9 | };
10 |
11 | const getQuizResultData = async (id: string) => {
12 | try {
13 | return await serverFetch>>(
14 | `/quiz/result/${id}`,
15 | );
16 | } catch (e) {
17 | console.log('error', e);
18 | notFound();
19 | }
20 | };
21 |
22 | export default async function QuizResultPage({ params }: Props) {
23 | const { quizResultId } = params;
24 |
25 | if (quizResultId === 'guest') {
26 | return ;
27 | }
28 |
29 | const {
30 | data: {
31 | data: { userName, score, correctWords, incorrectWords },
32 | },
33 | } = await getQuizResultData(quizResultId);
34 |
35 | return (
36 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/quiz/result/[quizResultId]/share/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 | import { QUIZ_PATH } from '@/routes/path.ts';
3 |
4 | export default function SharePage() {
5 | // NOTE: 새로고침 시에는(Server Side Route) redirect
6 | redirect(QUIZ_PATH);
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/robots.txt:
--------------------------------------------------------------------------------
1 | User-Agent: *
2 | Allow: /
3 |
4 | Sitemap: https://dev-malssami.site/sitemap.xml
--------------------------------------------------------------------------------
/src/app/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
11 | https://dev-malssami.site/
12 | 2024-10-12T01:05:12+00:00
13 | 1.00
14 |
15 |
16 | https://dev-malssami.site/home?view=trend&page=1
17 | 2024-10-12T01:05:12+00:00
18 | 0.80
19 |
20 |
21 | https://dev-malssami.site/quiz
22 | 2024-10-12T01:05:12+00:00
23 | 0.64
24 |
25 |
26 | https://dev-malssami.site/profile
27 | 2024-10-12T01:05:12+00:00
28 | 0.64
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/app/user/wordbook/page.tsx:
--------------------------------------------------------------------------------
1 | import Wordbook from '@/components/pages/wordbook';
2 | import QUERY_KEYS from '@/constants/queryKey';
3 | import {
4 | DROPDOWN_DEFAULT_OPTION,
5 | sortOptionMapping,
6 | } from '@/constants/sortingOptions';
7 | import { getLikedWord } from '@/fetcher';
8 | import {
9 | HydrationBoundary,
10 | QueryClient,
11 | dehydrate,
12 | } from '@tanstack/react-query';
13 | import { ResolvingMetadata } from 'next';
14 |
15 | // eslint-disable-next-line react-refresh/only-export-components
16 | export async function generateMetadata(
17 | _: { [x: string]: never },
18 | parent: ResolvingMetadata,
19 | ) {
20 | const parentMetadata = (await parent) || [];
21 |
22 | const openGraph = parentMetadata?.openGraph ?? {};
23 | const twitter = parentMetadata?.twitter ?? {};
24 |
25 | return {
26 | ...parentMetadata,
27 | title: '단어장',
28 | description: '좋아요한 단어를 확인하고 관리해보세요.',
29 | robots: {
30 | index: false,
31 | follow: false,
32 | },
33 | openGraph: {
34 | ...openGraph,
35 | title: '단어장',
36 | description: '좋아요한 단어를 확인하고 관리해보세요.',
37 | url: 'https://dev-malssami.site/user/wordbook',
38 | },
39 | twitter: {
40 | ...twitter,
41 | title: '단어장',
42 | description: '좋아요한 단어를 확인하고 관리해보세요.',
43 | },
44 | };
45 | }
46 |
47 | export default async function WordbookPage() {
48 | const queryClient = new QueryClient();
49 |
50 | await queryClient.prefetchQuery({
51 | queryFn: () =>
52 | getLikedWord(1, 10, sortOptionMapping[DROPDOWN_DEFAULT_OPTION]),
53 | queryKey: [
54 | QUERY_KEYS.LIKEDWORD_KEY,
55 | 1,
56 | sortOptionMapping[DROPDOWN_DEFAULT_OPTION],
57 | ],
58 | });
59 |
60 | return (
61 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/app/word/search/page.tsx:
--------------------------------------------------------------------------------
1 | import { ResolvingMetadata } from 'next';
2 | import Header from '@/components/layout/Header';
3 | import Search from '@/components/pages/search';
4 |
5 | // eslint-disable-next-line react-refresh/only-export-components
6 | export async function generateMetadata(
7 | { searchParams }: { searchParams: { keyword: string } },
8 | parent: ResolvingMetadata,
9 | ) {
10 | const parentMetadata = (await parent) || [];
11 | const decodedKeyword = decodeURI(searchParams.keyword);
12 |
13 | const openGraph = parentMetadata?.openGraph ?? {};
14 | const twitter = parentMetadata?.twitter ?? {};
15 |
16 | return {
17 | ...parentMetadata,
18 | title: {
19 | absolute: `'${decodedKeyword}'의 검색결과 | 데브말싸미`,
20 | },
21 | description: `'${decodedKeyword}'에 대한 검색 결과를 확인해보세요.`,
22 | robots: {
23 | index: false,
24 | follow: false,
25 | },
26 | openGraph: {
27 | ...openGraph,
28 | title: { absolute: `'${decodedKeyword}'의 검색결과 | 데브말싸미` },
29 | description: `'${decodedKeyword}'에 대한 검색 결과를 확인해보세요.`,
30 | url: `https://dev-malssami.site/word/search?keyword=${decodedKeyword}`,
31 | },
32 | twitter: {
33 | ...twitter,
34 | title: { absolute: `'${decodedKeyword}'의 검색결과 | 데브말싸미` },
35 | description: `'${decodedKeyword}'에 대한 검색 결과를 확인해보세요.`,
36 | },
37 | };
38 | }
39 |
40 | type Props = {
41 | searchParams: { keyword: string };
42 | };
43 |
44 | export default async function SearchPage({ searchParams }: Props) {
45 | const word = decodeURI(searchParams.keyword);
46 |
47 | return (
48 |
49 |
50 |
51 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/app/words/[wordName]/loading.tsx:
--------------------------------------------------------------------------------
1 | import DetailLoading from '@/components/pages/detail/DetailLoading.tsx';
2 |
3 | export default function loading() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/common/BackButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import BackButtonSvg from '@/components/svg-component/BackButtonSvg';
4 |
5 | import { useRouter } from 'next/navigation';
6 |
7 | export default function BackButton() {
8 | const router = useRouter();
9 | return (
10 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/common/CopiedNotice.tsx:
--------------------------------------------------------------------------------
1 | import CheckSvg from '@/components/svg-component/CheckSvg.tsx';
2 |
3 | interface Props {
4 | isOpen: boolean;
5 | handleClose: () => void;
6 | }
7 |
8 | export function CopiedNotice({ isOpen, handleClose }: Props) {
9 | if (!isOpen) return null;
10 |
11 | return (
12 |
16 |
17 |
18 |
19 | 링크 복사 완료
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/common/ErrorHandlingMarkup.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorHandlingProps } from '@/types/errorHandling';
2 | import { WORD_LIST_PATH } from '@/routes/path.ts';
3 | import Link from 'next/link';
4 |
5 | export const ErrorHandlingMarkup = ({
6 | title,
7 | description,
8 | svg,
9 | }: ErrorHandlingProps) => {
10 | return (
11 |
12 | {svg}
13 |
14 | {title}
15 |
16 |
17 | {description}
18 |
19 |
23 |
홈으로 돌아가기
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/common/LoginAlertModal.tsx:
--------------------------------------------------------------------------------
1 | import LockSmallSvg from '@/components/svg-component/LockSmallSvg';
2 | import clsx from 'clsx';
3 | import { useRouter } from 'next/navigation';
4 | import { useState } from 'react';
5 | import { createPortal } from 'react-dom';
6 |
7 | type Props = {
8 | isOpen: boolean;
9 | };
10 |
11 | export default function LoginAlertModal({ isOpen }: Props) {
12 | const [isHovered, setIsHovered] = useState(false);
13 |
14 | const router = useRouter();
15 |
16 | if (!isOpen) return null;
17 |
18 | return createPortal(
19 | setIsHovered(true)}
21 | onMouseLeave={() => setIsHovered(false)}
22 | onClick={() => router.push('/')}
23 | className={clsx(
24 | 'fixed bottom-0 left-1/2 -translate-x-1/2 cursor-pointer',
25 | 'w-[calc(100%-40px)] max-w-[390px]',
26 | 'h-[54px] rounded-lg bg-[#3D4FF3]/95',
27 | 'flex justify-between items-center text-white',
28 | 'text-[14px] px-[20px]',
29 | isOpen ? 'animate-slideUpBounce opacity-100' : 'opacity-0',
30 | )}
31 | >
32 |
33 |
34 |
로그인이 필요한 서비스에요.
35 |
36 |
44 |
,
45 | document.body,
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/common/Modal.tsx:
--------------------------------------------------------------------------------
1 | import { createPortal } from 'react-dom';
2 |
3 | interface Props {
4 | isOpen: boolean;
5 | title: string;
6 | buttonLabel: string;
7 | onClickButton: () => void;
8 | }
9 |
10 | export default function Modal({
11 | isOpen,
12 | title,
13 | buttonLabel,
14 | onClickButton,
15 | }: Props) {
16 | if (!isOpen) {
17 | return null;
18 | }
19 |
20 | return createPortal(
21 |
22 |
23 | {title}
24 |
30 |
31 |
,
32 | document.body,
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/common/ShareModal.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import KakaoIconSvg from '@/components/svg-component/KakaoIconSvg.tsx';
4 | import LinkShareIconSvg from '@/components/svg-component/LinkShareIconSvg.tsx';
5 | import ModalXSvg from '@/components/svg-component/ModalXSvg.tsx';
6 | import useLoadKakaoScript from '@/hooks/useLoadKakaoScript.ts';
7 | import useCopyClipboard from '@/hooks/useCopyClipboard.ts';
8 | import { CopiedNotice } from '@/components/common/CopiedNotice.tsx';
9 | import { useRouter } from 'next/navigation';
10 |
11 | export default function ShareModal() {
12 | const { handleShare } = useLoadKakaoScript();
13 | const router = useRouter();
14 | const shareURL = window.location.href;
15 | const { isCopied, onCopyClipboard, onCloseCopyClipboard } = useCopyClipboard({
16 | url: shareURL,
17 | });
18 |
19 | return (
20 |
21 |
22 |
25 |
26 | 퀴즈 결과 공유하기
27 |
28 |
29 |
30 |
36 |
37 |
38 |
44 |
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/common/Spinner.tsx:
--------------------------------------------------------------------------------
1 | export default function Spinner() {
2 | return (
3 |
4 |
5 |
61 |
62 | 잠시만 기다려주세요...
63 |
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/common/TTSPlayer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import useDeviceType from '@/hooks/useDeviceType';
4 | import clsx from 'clsx';
5 | import SpeakerSvg from '../svg-component/SpeakerSvg';
6 | import useAudioPlayer from '@/hooks/useAudioPlayer';
7 | import { getTTSUrl } from '@/fetcher';
8 | import { useQueryClient } from '@tanstack/react-query';
9 | import QUERY_KEYS from '@/constants/queryKey';
10 |
11 | type Props = {
12 | id: string;
13 | diacritic: string;
14 | };
15 |
16 | export default function TTSPlayer({ diacritic, id }: Props) {
17 | const deviceType = useDeviceType();
18 | const queryClient = useQueryClient();
19 | const { audioRef, startAudio, isPlaying } = useAudioPlayer(id);
20 |
21 | const handleClick = async (e: React.MouseEvent) => {
22 | e.stopPropagation();
23 |
24 | try {
25 | // Fixme: 타입 추론되도록 만들고 싶어요
26 | const audioUrl = await queryClient.fetchQuery({
27 | queryKey: [QUERY_KEYS.TTS_KEY, id],
28 | queryFn: () => getTTSUrl(id),
29 | gcTime: Infinity,
30 | staleTime: Infinity,
31 | });
32 | if (audioUrl) {
33 | startAudio(audioUrl);
34 | }
35 | } catch {
36 | // Note: 오디오 에러는 서비스에 큰 지장을 주지 않기 때문에 에러 전파 X
37 | // Todo: 추후 여러번 발생할 때 리포팅 할 수 있는 Sentry를 연동하면 좋겠다.
38 | console.error('오디오 url 생성 중 에러');
39 | }
40 | };
41 |
42 | return (
43 |
50 |
51 |
58 |
59 |
60 |
{diacritic}
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/common/WordItem.tsx:
--------------------------------------------------------------------------------
1 | import { MainItemType } from '@/types/main';
2 | import { getWordDetailPath } from '@/routes/path.ts';
3 | import { useRouter } from 'next/navigation';
4 | import HeartSvg from '@/components/svg-component/HeartSvg';
5 | import clsx from 'clsx';
6 | import { useMutationLike } from '@/hooks/useMutationLike';
7 | import { Dispatch, SetStateAction, useCallback } from 'react';
8 | import TTSPlayer from './TTSPlayer';
9 |
10 | type Props = MainItemType & {
11 | setIsOpenModal: Dispatch>;
12 | };
13 |
14 | export default function WordItem({
15 | id,
16 | name,
17 | isLike,
18 | diacritic, // 발음 기호 (영문)
19 | pronunciation, // 발음 (국문)
20 | description,
21 | setIsOpenModal,
22 | }: Props) {
23 | const router = useRouter();
24 |
25 | const { handleAddLike, handleSubLike } = useMutationLike({
26 | wordId: id,
27 | setIsOpenModal,
28 | });
29 |
30 | const handleLikeButton = useCallback(
31 | (e: React.MouseEvent) => {
32 | e.stopPropagation();
33 | isLike ? handleSubLike() : handleAddLike();
34 | },
35 | [isLike, handleAddLike, handleSubLike],
36 | );
37 |
38 | return (
39 | router.push(getWordDetailPath(name))}
45 | >
46 |
47 | {name}
48 |
57 |
58 |
59 |
60 |
61 | {pronunciation[0]}
62 |
63 |
64 |
65 |
66 |
67 |
68 | {description}
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import LogoTextSvg from '@/components/svg-component/LogoTextSvg';
4 | import SearchBar from './SearchBar';
5 | import QuizButton from '@/components/pages/home/QuizButton';
6 | import { useState } from 'react';
7 | import useScroll from '@/hooks/useScroll';
8 | import { useEffect } from 'react';
9 | import { PROFILE_PATH, QUIZ_PATH, WORD_LIST_PATH } from '@/routes/path.ts';
10 | import Link from 'next/link';
11 | import dynamic from 'next/dynamic';
12 | import MypageIconSvg from '@/components/svg-component/MypageIconSvg.tsx';
13 |
14 | const DynamicToolTip = dynamic(() => import('@/components/common/ToolTip'), {
15 | ssr: false,
16 | });
17 |
18 | export default function Header() {
19 | const isScrolled = useScroll();
20 | const [isOpen, setIsOpen] = useState(
21 | () =>
22 | typeof window !== 'undefined' &&
23 | sessionStorage.getItem('isOpen') !== 'false',
24 | );
25 |
26 | useEffect(() => {
27 | if (sessionStorage.getItem('isOpen')) setIsOpen(false);
28 | if (!isOpen) sessionStorage.setItem('isOpen', 'false');
29 | }, [isOpen]);
30 |
31 | return (
32 | <>
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | {!isScrolled && }
50 | >
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/layout/HeightWrapper.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { isMobile, isDvhSupported } from '@/utils';
4 | import { useEffect, useState } from 'react';
5 |
6 | const HEIGHTS = {
7 | DVH: { minHeight: '100dvh' },
8 | VH: { minHeight: '100vh' },
9 | };
10 |
11 | export default function HeightWrapper({
12 | children,
13 | }: {
14 | children: React.ReactNode;
15 | }) {
16 | const [height, setHeight] = useState(HEIGHTS.DVH);
17 |
18 | useEffect(() => {
19 | if (isDvhSupported()) {
20 | setHeight(HEIGHTS.DVH);
21 | } else if (isMobile()) {
22 | setHeight({ minHeight: `${window.innerHeight}px` });
23 | } else {
24 | setHeight(HEIGHTS.VH);
25 | }
26 | }, []);
27 |
28 | return (
29 |
30 | {children}
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/pages/detail/DetailHeader.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import BackButton from '@/components/common/BackButton.tsx';
4 | import URLShareButton from '@/components/pages/detail/URLShareButton.tsx';
5 |
6 | export default function DetailHeader() {
7 | return (
8 |
9 |
10 |
11 |
개발 용어
12 |
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/pages/detail/DetailLoading.tsx:
--------------------------------------------------------------------------------
1 | import Spinner from '@/components/common/Spinner.tsx';
2 | import CorrectSvg from '@/components/svg-component/CorrectSvg.tsx';
3 | import WrongSvg from '@/components/svg-component/WrongSvg.tsx';
4 | import DetailHeader from './DetailHeader.tsx';
5 |
6 | export default function DetailLoading() {
7 | return (
8 | <>
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | 올바른 발음
24 |
25 |
26 |
27 |
28 |
29 |
30 | 잘못된 발음
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
의미
40 |
41 |
42 |
43 |
44 |
48 |
49 |
50 |
51 | >
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/pages/detail/DetailTTSButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import useGetTTSUrl from '@/hooks/query/useGetTTSUrl.ts';
4 | import DetailSoundIconSvg from '@/components/svg-component/DetailSoundIconSvg.tsx';
5 |
6 | import React from 'react';
7 | import clsx from 'clsx';
8 | import useAudioPlayer from '@/hooks/useAudioPlayer.ts';
9 |
10 | interface Props {
11 | id: string;
12 | }
13 | export default function DetailTTSButton({ id }: Props) {
14 | const { data: audioUrl } = useGetTTSUrl(id);
15 | const { audioRef, startAudio, isPlaying } = useAudioPlayer(id);
16 |
17 | const handleClick = (e: React.MouseEvent) => {
18 | e.stopPropagation();
19 |
20 | if (audioUrl) {
21 | startAudio(audioUrl);
22 | }
23 | };
24 |
25 | return (
26 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/pages/detail/LikeButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useOptimisticLike } from '@/hooks/useOptimisticLike.ts';
4 | import useAuthQuery from '@/hooks/query/useAuthQuery.ts';
5 | import { useState } from 'react';
6 | import LoginAlertModal from '@/components/common/LoginAlertModal.tsx';
7 | import DetailLikeSvg from '@/components/svg-component/DetailLikeSvg.tsx';
8 |
9 | interface Props {
10 | wordId: string;
11 | initialLike: boolean;
12 | initialLikeCount: number;
13 | }
14 |
15 | export default function LikeButton({
16 | initialLike,
17 | initialLikeCount,
18 | wordId,
19 | }: Props) {
20 | const { data: isLoggedIn } = useAuthQuery();
21 | const [isOpenModal, setIsOpenModal] = useState(false);
22 | const { optimisticLikeState, handleSubLike, handleAddLike } =
23 | useOptimisticLike({
24 | wordId,
25 | isLike: initialLike,
26 | likeCount: initialLikeCount,
27 | });
28 |
29 | // TODO: loading 시 클릭 안되게 할 필요 있음
30 |
31 | function handleClick(isLike: boolean) {
32 | // NOTE: 2초간 로그인 toast UI
33 | if (isLoggedIn?.error) {
34 | setIsOpenModal(true);
35 | setTimeout(() => {
36 | setIsOpenModal(false);
37 | }, 2000);
38 |
39 | return;
40 | }
41 |
42 | isLike ? handleSubLike() : handleAddLike();
43 | }
44 |
45 | return (
46 | <>
47 |
48 |
51 |
52 | {optimisticLikeState.likeCount}
53 |
54 |
55 |
56 | >
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/pages/detail/PronunciationDetail.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import React, { useState } from 'react';
5 | import DetailPronunciationCorrectSvg from '@/components/svg-component/DetailPronunciationCorrectSvg.tsx';
6 | import DetailPronunciationWrongSvg from '@/components/svg-component/DetailPronunciationWrongSvg.tsx';
7 | import DetailPronunciationShowArrowSvg from '@/components/svg-component/DetailPronunciationShowArrowSvg.tsx';
8 | import DetailPronunciationCloseArrowSvg from '@/components/svg-component/DetailPronunciationCloseArrowSvg.tsx';
9 |
10 | interface Props {
11 | pronunciation: string[];
12 | diacritic: string[];
13 | wrongPronunciation: string[];
14 | }
15 |
16 | const PronunciationDetail = ({
17 | pronunciation,
18 | diacritic,
19 | wrongPronunciation,
20 | }: Props) => {
21 | const [isShowDetail, setIsShowDetail] = useState(true);
22 |
23 | const correctWordLen = Math.min(pronunciation.length, diacritic.length);
24 |
25 | return (
26 |
27 |
30 |
31 |
32 |
33 | 올바른 발음
34 |
35 |
36 | {new Array(correctWordLen).fill(0).map((_, idx) => (
37 |
38 | {pronunciation[idx]} {diacritic[idx]}
39 | {idx !== correctWordLen - 1 ? ', ' : ''}
40 |
41 | ))}
42 |
43 |
44 |
45 |
46 |
47 | 잘못된 발음
48 |
49 |
50 | {wrongPronunciation.map((wrong, idx) => (
51 |
52 | {wrong}
53 | {idx !== wrongPronunciation.length - 1 ? ', ' : ''}
54 |
55 | ))}
56 |
57 |
58 |
59 |
setIsShowDetail((prev) => !prev)}
62 | >
63 | 용어발음 자세히 보기
64 | {isShowDetail ? (
65 |
66 | ) : (
67 |
68 | )}
69 |
70 | {/*{용어 발음 자세히 보기 버튼이 absolute이기 때문에 맞춰 58px 공간 확보}*/}
71 |
72 |
73 | );
74 | };
75 |
76 | export default PronunciationDetail;
77 |
--------------------------------------------------------------------------------
/src/components/pages/detail/ReportButton.tsx:
--------------------------------------------------------------------------------
1 | import NoticeIconSvg from '@/components/svg-component/NoticeIconSvg.tsx';
2 | import RightArrowSvg from '@/components/svg-component/RightArrowSvg.tsx';
3 | import { WORD_INQUIRY_FORM_URL } from '@/routes/path';
4 |
5 | export default function ReportButton() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
잘못된 정보가 있나요?
13 |
문의하러 가기
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/pages/detail/URLShareButton.tsx:
--------------------------------------------------------------------------------
1 | import ExternalSvg from '@/components/svg-component/ExternalSvg';
2 | import useCopyClipboard from '@/hooks/useCopyClipboard.ts';
3 | import { CopiedNotice } from '@/components/common/CopiedNotice.tsx';
4 |
5 | export default function URLShareButton() {
6 | const { isCopied, onCopyClipboard, onCloseCopyClipboard } =
7 | useCopyClipboard();
8 |
9 | return (
10 |
11 |
14 | {}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/pages/error/index.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorHandlingMarkup } from '@/components/common/ErrorHandlingMarkup';
2 | import ErrorSvg from '@/components/svg-component/ErrorSvg';
3 |
4 | export default function Error() {
5 | return (
6 |
7 |
8 | }
12 | />
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/pages/home/HomeSkeleton.tsx:
--------------------------------------------------------------------------------
1 | import HeartSvg from '@/components/svg-component/HeartSvg';
2 | import clsx from 'clsx';
3 |
4 | export default function HomeSkeleton({ limit }: { limit: number }) {
5 | return (
6 |
7 | {Array.from({ length: limit }, (_, idx) => (
8 |
14 |
20 |
21 |
28 |
29 |
30 |
31 |
32 | ))}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/pages/home/HomeToggleZone.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { type TrendingType } from './index';
3 |
4 | type Props = {
5 | handleToggle: (isTrending: 'trend' | 'all') => void;
6 | isTrending: TrendingType;
7 | };
8 |
9 | const STYLE_P_TAG =
10 | 'absolute transition-all duration-[600ms] w-1/2 text-center';
11 |
12 | export default function HomeToggleZone({ handleToggle, isTrending }: Props) {
13 | const isTrend = isTrending === 'trend';
14 |
15 | return (
16 |
22 |
28 |
29 |
37 | 트렌딩 단어
38 |
39 |
40 |
49 |
50 |
58 | 모든 용어 보기
59 |
60 |
61 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/pages/home/all-posts/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, type Dispatch, type SetStateAction } from 'react';
4 | import LoginAlertModal from '@/components/common/LoginAlertModal';
5 | import Pagination from '@/components/common/Pagination';
6 | import WordItem from '@/components/common/WordItem';
7 | import { type PaginationRes, type MainItemType } from '@/types/main';
8 |
9 | type AllPostsProps = {
10 | data: PaginationRes;
11 | currentPage: number;
12 | setCurrentPage: Dispatch>;
13 | };
14 |
15 | export default function AllPosts({
16 | data,
17 | currentPage,
18 | setCurrentPage,
19 | }: AllPostsProps) {
20 | const [isOpenModal, setIsOpenModal] = useState(false);
21 |
22 | return (
23 |
24 | {data.data.map((item) => (
25 |
26 | ))}
27 |
28 |
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/pages/home/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import HomeToggleZone from './HomeToggleZone';
4 | import AllPosts from './all-posts';
5 | import useGetAllPosts from '@/hooks/query/useGetAllPosts';
6 | import TrendingPosts from './trending-posts';
7 | import useSyncURLHomeRouteState from '@/hooks/useSyncURLHomeRouteState';
8 | import { useCallback } from 'react';
9 |
10 | export type TrendingType = 'trend' | 'all';
11 |
12 | const HomeClientPage = () => {
13 | const { currentPage, setCurrentPage, isTrending, setIsTrending } =
14 | useSyncURLHomeRouteState();
15 | const { data: allPostsData } = useGetAllPosts(currentPage).data;
16 |
17 | const handleToggle = useCallback(
18 | (prev: TrendingType) => {
19 | setIsTrending(prev);
20 | },
21 | [setIsTrending],
22 | );
23 |
24 | return (
25 |
26 |
27 | {isTrending === 'trend' ? (
28 |
29 | ) : (
30 |
35 | )}
36 |
37 | );
38 | };
39 |
40 | export default HomeClientPage;
41 |
--------------------------------------------------------------------------------
/src/components/pages/home/trending-posts/ComingSoonAlert.tsx:
--------------------------------------------------------------------------------
1 | import BellSvg from '@/components/svg-component/BellSvg';
2 | import clsx from 'clsx';
3 |
4 | export default function ComingSoonAlert() {
5 | return (
6 |
7 |
14 |
15 |
16 |
17 |
18 |
19 | 트렌딩 단어 OPEN
20 |
21 |
22 | 2024년 6월 26일
23 |
24 |
25 | 조회 기반 조회수 랭킹 페이지가
26 | 오픈될 예정입니다! 🚀
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/pages/home/trending-posts/GeneralRanking.tsx:
--------------------------------------------------------------------------------
1 | import { TrendWord } from '@/fetcher/types';
2 | import clsx from 'clsx';
3 | import { RankChange } from './RankChange';
4 | import { getWordDetailPath } from '@/routes/path';
5 | import Link from 'next/link';
6 |
7 | type Props = {
8 | generalRankingList: TrendWord[];
9 | };
10 |
11 | export default function GeneralRanking({ generalRankingList }: Props) {
12 | return (
13 |
14 | {generalRankingList.map((trendWord, index) => (
15 |
23 | {/* 순위 */}
24 |
25 | {trendWord.rank}
26 |
27 |
28 | {/* 영단어 컨테이너 */}
29 |
33 |
34 | {trendWord.name}
35 |
36 |
37 | {trendWord.pronunciation}
38 |
39 |
40 |
41 | {/* 순위 등락 컨테이너 */}
42 |
46 |
47 | ))}
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/pages/home/trending-posts/RankChange.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | import FillArrowSvg from '@/components/svg-component/FillArrowSvg';
4 | import HyphenSvg from '@/components/svg-component/HyphenSvg';
5 |
6 | type Props = {
7 | className: string;
8 | innerClassName?: string;
9 | rankChange?: number;
10 | };
11 |
12 | export const RankChange = ({
13 | className,
14 | innerClassName,
15 | rankChange,
16 | }: Props) => {
17 | const isNew = typeof rankChange !== 'number';
18 | const isSame = !isNew && rankChange === 0;
19 | const isChanged = !isNew && rankChange !== 0;
20 |
21 | return (
22 |
28 | {isNew && (
29 |
30 | New
31 |
32 | )}
33 | {isSame &&
}
34 | {isChanged && (
35 | <>
36 |
0 ? '' : 'rotate(180)'}
39 | />
40 |
41 | {Math.abs(rankChange)}
42 |
43 | >
44 | )}
45 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/src/components/pages/home/trending-posts/TrendingDescription.tsx:
--------------------------------------------------------------------------------
1 | export default function TrendingDescription() {
2 | return (
3 |
4 |
5 | 사람들이 이번 주에
6 |
7 |
8 | 이 용어를 가장 많이 찾아봤어요!
9 |
10 |
11 | * 매주 월요일 업데이트 예정
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/pages/home/trending-posts/index.tsx:
--------------------------------------------------------------------------------
1 | import TopRanking from './TopRanking';
2 | import TrendingDescription from './TrendingDescription';
3 | import GeneralRanking from './GeneralRanking';
4 | import { useGetTrendList } from '@/hooks/query/useGetTrendList';
5 |
6 | export default function TrendingPosts() {
7 |
8 | const { data: currentWeekTrendList } = useGetTrendList();
9 |
10 | const topRankingList = currentWeekTrendList.slice(0, 3);
11 | const generalRankingList = currentWeekTrendList.slice(3);
12 |
13 | return (
14 | <>
15 |
16 |
17 |
18 | >
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/pages/login/Carousel.tsx:
--------------------------------------------------------------------------------
1 | import FirstSlide from './FirstSlide';
2 | import SecondSlide from './SecondSlide';
3 | import ThirdSlide from './ThirdSlide';
4 |
5 | import { Fragment, useEffect, useState } from 'react';
6 |
7 | const slides = [, , ];
8 |
9 | const Carousel = () => {
10 | const [currentSlide, setCurrentSlide] = useState(0);
11 |
12 | useEffect(() => {
13 | const slideInterval = setInterval(() => {
14 | setCurrentSlide((prevSlide) => (prevSlide + 1) % slides.length);
15 | }, 4000);
16 |
17 | return () => {
18 | clearInterval(slideInterval);
19 | };
20 | }, []);
21 |
22 | return (
23 |
24 | {/* 캐러셀 슬라이드 */}
25 |
32 | {slides.map((slide, index) => (
33 | {slide}
34 | ))}
35 |
36 |
37 | {/*페이징 인디케이터*/}
38 |
39 | {Array.from({ length: slides.length }).map((_, index) => (
40 |
49 |
50 | );
51 | };
52 |
53 | export default Carousel;
54 |
--------------------------------------------------------------------------------
/src/components/pages/login/FirstSlide.tsx:
--------------------------------------------------------------------------------
1 | import LogoSvg from '@/components/svg-component/LogoSvg';
2 |
3 | const FirstSlide = () => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | 데브말싸미
11 |
12 |
13 | 개발 용어의 발음이 궁금할땐?
14 |
15 | 데브말싸미로 검색해 보세요!
16 |
17 |
18 | );
19 | };
20 |
21 | export default FirstSlide;
22 |
--------------------------------------------------------------------------------
/src/components/pages/login/SecondSlide.tsx:
--------------------------------------------------------------------------------
1 | import LandingCarouselSecond from '@/components/svg-component/LandingCarouselSecond';
2 |
3 | const SecondSlide = () => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | 궁금한 IT 용어를 검색해 보세요.
11 |
12 |
13 | 해당 용어의
14 |
15 | {' '}
16 | 올바른/잘못된 발음
17 |
18 | 을 알려주고
19 |
20 | 용어의 의미와 예문까지 볼 수 있어요.
21 |
22 |
23 | );
24 | };
25 |
26 | export default SecondSlide;
27 |
--------------------------------------------------------------------------------
/src/components/pages/login/ThirdSlide.tsx:
--------------------------------------------------------------------------------
1 | import LandingCarouselThird from '@/components/svg-component/LandingCarouselThird';
2 |
3 | const ThirdSlide = () => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | 퀴즈를 풀고, 친구와 공유해요.
11 |
12 |
13 | IT 용어 발음 퀴즈
14 | 로 나의 실력을 확인하고
15 |
16 | 나의 점수를 친구에게 공유해 보세요.
17 |
18 |
19 | );
20 | };
21 |
22 | export default ThirdSlide;
23 |
--------------------------------------------------------------------------------
/src/components/pages/login/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import KakaotalkSvg from '@/components/svg-component/KakaotalkSvg';
4 | import { WORD_LIST_PATH } from '@/routes/path.ts';
5 | import Link from 'next/link';
6 | import Carousel from './Carousel';
7 |
8 | export default function Login() {
9 | const handleKakaoLogin = () => {
10 | window.location.href = '/api/auth/kakao';
11 | };
12 |
13 | return (
14 |
15 |
16 |
17 |
28 |
29 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/pages/not-found/index.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorHandlingMarkup } from '@/components/common/ErrorHandlingMarkup';
2 | import NotFoundSvg from '@/components/svg-component/NotFoundSvg';
3 | export default function NotFound() {
4 | return (
5 |
6 |
7 |
11 | 주소가 잘못 입력되거나 변경 혹은 삭제되어
12 | 페이지를 찾을 수 없어요.
13 |
14 | }
15 | svg={
}
16 | />
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/pages/profile/Modal/DeleteAccountModal.tsx:
--------------------------------------------------------------------------------
1 | import { deleteAccount } from '@/fetcher';
2 | import { useRouter } from 'next/navigation';
3 | import { createPortal } from 'react-dom';
4 | import { postFeedback } from '@/fetcher';
5 | import { PROFILE_PATH } from '@/routes/path';
6 |
7 | interface Props {
8 | isOpen: boolean;
9 | handleModalClick: () => void;
10 | userId: string;
11 | text: string;
12 | selectedOption: string;
13 | }
14 |
15 | export default function DeleteAccountModal({
16 | isOpen,
17 | handleModalClick,
18 | userId,
19 | text,
20 | selectedOption,
21 | }: Props) {
22 | const router = useRouter();
23 |
24 | const handleDeleteAccount = async () => {
25 | await postFeedback(selectedOption, text);
26 | await deleteAccount(userId);
27 | handleModalClick();
28 | router.push(PROFILE_PATH);
29 | router.refresh();
30 | };
31 |
32 | if (!isOpen) return null;
33 |
34 | return createPortal(
35 |
36 |
37 |
38 | 탈퇴 전 확인
39 |
40 |
41 | 탈퇴 시 계정 및 이용 기록은 모두 삭제되며,
42 | 삭제된 데이터는 복구가 불가능합니다.
43 | 정말로 탈퇴를 진행하시겠습니까?
44 |
45 |
46 |
47 |
53 |
59 |
60 |
61 |
,
62 | document.body,
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/pages/profile/Modal/LogoutModal.tsx:
--------------------------------------------------------------------------------
1 | import { logout } from '@/fetcher';
2 | import { useRouter } from 'next/navigation';
3 | import { createPortal } from 'react-dom';
4 |
5 | interface Props {
6 | isOpen: boolean;
7 | handleModalClick: () => void;
8 | }
9 |
10 | export default function LogoutModal({ isOpen, handleModalClick }: Props) {
11 | const router = useRouter();
12 |
13 | const handleLogout = async () => {
14 | await logout();
15 | handleModalClick();
16 | router.refresh();
17 | };
18 |
19 | if (!isOpen) return null;
20 |
21 | return createPortal(
22 |
23 |
24 |
25 | 로그아웃
26 |
27 |
28 | 정말 로그아웃 하시겠습니까?
29 |
30 |
31 |
32 |
38 |
44 |
45 |
46 |
,
47 | document.body,
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/pages/profile/NonLoginProfileInfo.tsx:
--------------------------------------------------------------------------------
1 | import NonLoginImage from '@/components/svg-component/NonLoginImage';
2 | import Link from 'next/link';
3 | import { LOGIN_PATH } from '@/routes/path';
4 |
5 | export default function NonLoginProfileInfo() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 | 로그인이 필요해요.
14 |
15 |
16 |
17 |
18 | 로그인하기
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/pages/profile/ProfileHeader.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/navigation';
2 | import BlackBackSpaceSVG from '@/components/svg-component/BlackBackSpaceSVG';
3 | import { WORD_LIST_PATH } from '@/routes/path';
4 |
5 | type Props = {
6 | text: string;
7 | userId?: string;
8 | };
9 |
10 | export default function ProfileHeader({ text, userId }: Props) {
11 | const router = useRouter();
12 |
13 | const handleback = () => {
14 | if (userId) {
15 | router.back();
16 | } else {
17 | router.push(WORD_LIST_PATH);
18 | }
19 | };
20 |
21 | return (
22 |
23 |
26 |
{text}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/pages/profile/ProfileInfo.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import DefaultProfileIconSvg from '@/components/svg-component/DefaultProfileIconSvg.tsx';
3 | import { useState } from 'react';
4 |
5 | type Props = {
6 | profileImage: string;
7 | name: string;
8 | };
9 |
10 | export default function ProfileInfo({ profileImage, name }: Props) {
11 | const [isImageError, setImageError] = useState(false);
12 |
13 | return (
14 |
15 |
16 | {isImageError ? (
17 |
18 | ) : (
19 | {
27 | setImageError(true);
28 | }}
29 | />
30 | )}
31 |
32 |
33 | {name}
34 | 님
35 |
36 |
37 |
오늘도 화이팅!
38 |
39 | {/*
40 | 내 정보 수정
41 |
*/}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/pages/profile/SubmitFeedback.tsx:
--------------------------------------------------------------------------------
1 | import SubmitFeedbackSvg from '@/components/svg-component/SubmitFeedbackSvg';
2 | import { WORD_LIST_PATH } from '@/routes/path';
3 | import Link from 'next/link';
4 |
5 | export default function SubmitFeedback() {
6 | return (
7 |
8 |
9 |
10 |
11 | 의견을 전달했어요.
12 |
13 |
14 | 여러분의 소중한 의견을 통해
더 나은 데브말싸미가 되도록
15 | 노력할게요.
16 |
17 |
18 |
19 |
20 | 홈으로 돌아가기
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/pages/quiz/GuestQuizResult.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useAtomValue } from 'jotai/react';
4 | import { guestQuizAtom } from '@/store';
5 | import QuizResult from '@/components/pages/quiz/QuizResult.tsx';
6 |
7 | export default function GuestQuizResult() {
8 | const { correctWordData, incorrectWordData } = useAtomValue(guestQuizAtom);
9 | const score = correctWordData.length * 10;
10 |
11 | const correctWords = correctWordData.map((data) => {
12 | return {
13 | ...data,
14 | pronunciation: data.correct,
15 | isLike: false,
16 | };
17 | });
18 |
19 | const incorrectWords = incorrectWordData.map((data) => {
20 | return {
21 | ...data,
22 | pronunciation: data.correct,
23 | isLike: false,
24 | };
25 | });
26 |
27 | return (
28 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/pages/quiz/QuizBackModal.tsx:
--------------------------------------------------------------------------------
1 | import { createPortal } from 'react-dom';
2 |
3 | type Props = {
4 | handleBackModalOpen: () => void;
5 | handleBack: () => void;
6 | };
7 |
8 | export default function QuizBackModal({
9 | handleBackModalOpen,
10 | handleBack,
11 | }: Props) {
12 | return createPortal(
13 |
14 |
15 |
16 | 퀴즈를 중단하시나요?
17 |
18 |
19 | 중간에 퀴즈를 중단하면 지금까지
20 | 풀었던 내역이 모두 사라져요.
21 | 정말 중단하시겠어요?
22 |
23 |
24 |
30 |
36 |
37 |
38 |
,
39 | document.body,
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/pages/quiz/QuizResultDetail.tsx:
--------------------------------------------------------------------------------
1 | import ArrowDownSvg from '@/components/svg-component/ArrowDownSvg';
2 | import ArrowUpSvg from '@/components/svg-component/ArrowUpSvg';
3 | import { useState } from 'react';
4 | import type { QuizResultWordData } from '@/fetcher/types';
5 | import QuizResultDetailWord from './QuizResultDetailWord';
6 | import clsx from 'clsx';
7 |
8 | type Props = {
9 | correctWords: QuizResultWordData[];
10 | incorrectWords: QuizResultWordData[];
11 | };
12 |
13 | export default function QuizResultDetail({
14 | correctWords,
15 | incorrectWords,
16 | }: Props) {
17 | const [isDetail, setIsDetail] = useState(true);
18 | const words = [...correctWords, ...incorrectWords];
19 | const handleIsDetailChange = () => {
20 | setIsDetail(!isDetail);
21 | };
22 |
23 | return (
24 |
25 | {isDetail ? (
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | ) : null}
37 |
40 |
41 |
퀴즈 정답이에요!
42 |
43 |
44 | 맞았어요
45 |
46 | 틀렸어요
47 |
48 |
49 | {words.map((data) => (
50 |
55 | ))}
56 |
57 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/pages/quiz/QuizResultDetailWord.tsx:
--------------------------------------------------------------------------------
1 | import type { QuizResultWordData } from '@/fetcher/types';
2 | import EmptyHeartSvg from '@/components/svg-component/EmptyHeartSvg';
3 | import clsx from 'clsx';
4 | import { startTransition, useState } from 'react';
5 | import Heart1Svg from '@/components/svg-component/Heart1Svg';
6 | import { useOptimisticLike } from '@/hooks/useOptimisticLike';
7 | import useAuthQuery from '@/hooks/query/useAuthQuery.ts';
8 | import LoginAlertModal from '@/components/common/LoginAlertModal.tsx';
9 | import { getWordDetailPath } from '@/routes/path.ts';
10 | import { useRouter } from 'next/navigation';
11 |
12 | type Props = {
13 | data: QuizResultWordData;
14 | correctWords: QuizResultWordData[];
15 | };
16 |
17 | export default function QuizResultDetailWord({ data, correctWords }: Props) {
18 | const router = useRouter();
19 |
20 | const { data: authData } = useAuthQuery();
21 | const [isOpenModal, setIsOpenModal] = useState(false);
22 | const isLoggedIn = authData?.error ?? false;
23 | const { optimisticLikeState, handleSubLike, handleAddLike } =
24 | useOptimisticLike({
25 | wordId: data.wordId,
26 | isLike: data.isLike,
27 | likeCount: 0,
28 | });
29 |
30 | const handleNeedLogin = () => {
31 | // NOTE: 2초간 로그인 toast UI
32 |
33 | setIsOpenModal(true);
34 | setTimeout(() => {
35 | setIsOpenModal(false);
36 | }, 2000);
37 | };
38 |
39 | const handleLikeClick = (
40 | e: React.MouseEvent,
41 | ) => {
42 | e.stopPropagation();
43 |
44 | if (isLoggedIn) {
45 | startTransition(() => {
46 | optimisticLikeState.isLike ? handleSubLike() : handleAddLike();
47 | });
48 | } else {
49 | handleNeedLogin();
50 | }
51 | };
52 |
53 | const handleClickWordDetail = () => {
54 | router.push(getWordDetailPath(data.name));
55 | };
56 |
57 | return (
58 |
62 |
63 |
64 |
70 | {data.name}
71 |
72 |
73 | {data.pronunciation}
74 |
75 |
{`${data.diacritic}`}
76 |
77 |
80 |
81 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/pages/quiz/QuizScore.tsx:
--------------------------------------------------------------------------------
1 | import ScoreResultSvg from '@/components/svg-component/ScoreResultSvg';
2 |
3 | type Props = {
4 | score: number;
5 | userName: string;
6 | };
7 |
8 | export default function QuizScore({ score, userName }: Props) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | {userName}
16 |
17 |
18 | 님의
19 | 개발 용어 발음 실력은
20 |
21 |
22 |
2 ? `text-[64px]` : `text-[70px]`}`}
24 | >
25 | {score}점
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/pages/quiz/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import BackButtonSvg from '@/components/svg-component/BackButtonSvg';
4 | import ScoreSVG from '@/components/svg-component/ScoreSvg';
5 | import QuizPlay from './QuizPlay';
6 | import { useState } from 'react';
7 | import { useRouter } from 'next/navigation';
8 | import { useSetAtom } from 'jotai/react';
9 | import { guestQuizAtom } from '@/store';
10 | import QUERY_KEYS from '@/constants/queryKey.ts';
11 | import { useQueryClient } from '@tanstack/react-query';
12 |
13 | export default function Quiz() {
14 | const [isShow, setIsShow] = useState(false);
15 | const router = useRouter();
16 | const queryClient = useQueryClient();
17 | const setQuizAtom = useSetAtom(guestQuizAtom);
18 |
19 | const handleClickPlayButton = () => {
20 | setQuizAtom(() => {
21 | return {
22 | correctWordData: [],
23 | incorrectWordData: [],
24 | };
25 | });
26 |
27 | queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.QUIZ_KEY] });
28 | setIsShow(true);
29 | };
30 |
31 | if (isShow) {
32 | return ;
33 | }
34 |
35 | return (
36 |
37 |
42 |
43 |
44 |
45 |
46 |
47 | 개발 용어
48 |
49 | 발음 퀴즈
50 |
51 |
52 | 나의 개발 용어 발음 지식은?
53 |
54 |
55 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/pages/search/AutoComplete.tsx:
--------------------------------------------------------------------------------
1 | import RightArrowSvg from '@/components/svg-component/RightArrowSvg';
2 | import { AutoCompleteWordData } from '@/fetcher/types';
3 | import { WORD_REPORT_FORM_URL } from '@/routes/path';
4 | import clsx from 'clsx';
5 | import Link from 'next/link';
6 |
7 | type Props = {
8 | searchWordResult: AutoCompleteWordData[] | null;
9 | setSelectedIndex: (data: number) => void;
10 | selectedIndex: number;
11 | searchInput: string;
12 | handleNavigateToDetailPage: (data: string) => void;
13 | };
14 |
15 | export default function AutoComplete({
16 | searchWordResult,
17 | setSelectedIndex,
18 | selectedIndex,
19 | searchInput,
20 | handleNavigateToDetailPage,
21 | }: Props) {
22 | const isSearchWordEmpty = searchWordResult && !searchWordResult.length;
23 |
24 | if (!searchWordResult) return null;
25 |
26 | return (
27 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/pages/search/EngOnlyAlert.tsx:
--------------------------------------------------------------------------------
1 | import WarningBellSvg from '@/components/svg-component/WarningBellSvg';
2 |
3 | export default function EngOnlyAlert() {
4 | return (
5 |
6 |
7 |
8 | 검색은 영어로만
9 | 가능해요.
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/pages/search/NotFoundWord.tsx:
--------------------------------------------------------------------------------
1 | import BigMagnifierSvg from '@/components/svg-component/BigMagnifierSvg';
2 | import { WORD_REPORT_FORM_URL } from '@/routes/path';
3 | import Link from 'next/link';
4 |
5 | export default function NotFoundWord() {
6 | return (
7 |
8 |
9 |
10 | 앗! 찾으시는 검색 결과가 없어요.
11 |
16 |
17 | 용어 제보하러 가기
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/pages/search/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | // import type { SearchWordData } from '@/fetcher/types';
4 | import WordItem from '@/components/common/WordItem';
5 | import LoginAlertModal from '@/components/common/LoginAlertModal';
6 | import { useState } from 'react';
7 | import { useGetSearchWord } from '@/hooks/query/useGetSearchWord';
8 | import NotFoundWord from './NotFoundWord';
9 |
10 | type Props = {
11 | word: string;
12 | };
13 |
14 | export default function Search({ word }: Props) {
15 | const [isOpenModal, setIsOpenModal] = useState(false);
16 |
17 | const {
18 | data: {
19 | data: { data, totalCount },
20 | },
21 | } = useGetSearchWord(word.toLowerCase());
22 |
23 | return (
24 |
25 | {!totalCount && }
26 | {data.map((item) => (
27 |
28 | ))}
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/pages/system-check-notice/index.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorHandlingMarkup } from '@/components/common/ErrorHandlingMarkup';
2 | import LockSvg from '@/components/svg-component/LockSvg';
3 |
4 | export default function SystemCheckNotice() {
5 | return (
6 | }
10 | />
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/pages/wordbook/QuizBanner.tsx:
--------------------------------------------------------------------------------
1 | import { QUIZ_PATH } from '@/routes/path';
2 | import Link from 'next/link';
3 |
4 | export default function QuizBanner() {
5 | return (
6 |
10 |
11 |
12 | 퀴즈 풀고 내 점수 알아보자!
13 |
14 |
15 | 나의 개발 용어 발음 실력은?
16 |
17 |
18 |
19 |
20 | Q
21 | UIZ
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/pages/wordbook/WordbookDropdown.tsx:
--------------------------------------------------------------------------------
1 | import ArrowDownSvg from '@/components/svg-component/ArrowDownSvg';
2 | import { dropdownOptions } from '@/constants/sortingOptions';
3 | import clsx from 'clsx';
4 | import { Dispatch, SetStateAction } from 'react';
5 |
6 | interface Props {
7 | selectedOption: string;
8 | setSelectedOption: Dispatch>;
9 | }
10 |
11 | export default function WordbookDropdown({
12 | selectedOption,
13 | setSelectedOption,
14 | }: Props) {
15 | return (
16 |
17 |
21 |
22 | {dropdownOptions.map((option, index) => (
23 | - setSelectedOption(option)}
32 | >
33 | {option}
34 |
35 | ))}
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/pages/wordbook/WordbookHeader.tsx:
--------------------------------------------------------------------------------
1 | import BackButton from '@/components/common/BackButton';
2 |
3 | export default function WordbookHeader() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | 좋아요를 누른 용어
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/svg-component/ArrowDownSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function ArrowDownSvg({ className }: { className?: string }) {
2 | return (
3 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/svg-component/ArrowUpSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function ArrowUpSvg() {
2 | return (
3 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/svg-component/BackButtonSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function BackButtonSvg() {
2 | return (
3 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/svg-component/BackSpaceSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function BackSpaceSVG() {
2 | return (
3 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/svg-component/BellSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function BellSvg() {
2 | return (
3 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/svg-component/BigEmailSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function BigEmailSvg() {
2 | return (
3 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/svg-component/BigMagnifierSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function BigMagnifierSvg() {
2 | return (
3 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/svg-component/BlackBackSpaceSVG.tsx:
--------------------------------------------------------------------------------
1 | export default function BlackBackSpaceSVG() {
2 | return (
3 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/svg-component/CheckSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function CheckSvg() {
2 | return (
3 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/svg-component/ClearSearchBarSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function ClearSearchBarSvg({
2 | className,
3 | }: {
4 | className?: string;
5 | }) {
6 | return (
7 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/svg-component/CloseSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function CloseSvg() {
2 | return (
3 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/svg-component/CorrectSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function CorrectSvg() {
2 | return (
3 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/svg-component/CrownLinearSvg.tsx:
--------------------------------------------------------------------------------
1 | type TCrownStyles = {
2 | [key: string]: {
3 | base: string[];
4 | stop: string[];
5 | id: string;
6 | fillUrl: string;
7 | };
8 | };
9 |
10 | const crownStyles: TCrownStyles = {
11 | '1': {
12 | base: ['#4F72EA', '#5072E7'],
13 | stop: ['#6583EE', '#6583EE'],
14 | id: 'paint0_linear_1110_2229',
15 | fillUrl: 'url(#paint0_linear_1110_2229)',
16 | },
17 | '2': {
18 | base: ['#878CFF', '#9296FF'],
19 | stop: ['#86A1FF', '#86A1FF'],
20 | id: 'paint0_linear_1110_2254',
21 | fillUrl: 'url(#paint0_linear_1110_2254)',
22 | },
23 | '3': {
24 | base: ['#B1C2FF', '#D2DCFF'],
25 | stop: ['#C6D3FF', '#C6D3FF'],
26 | id: 'paint0_linear_1319_2235',
27 | fillUrl: 'url(#paint0_linear_1319_2235)',
28 | },
29 | };
30 |
31 | type Props = {
32 | className?: string;
33 | rank: string;
34 | };
35 |
36 | export default function CrownLinearSvg({ className, rank }: Props) {
37 | return (
38 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/svg-component/CrownSvg.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function CrownSvg({ className }: { className?: string }) {
4 | return (
5 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/svg-component/DefaultProfileIconSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function DefaultProfileIconSvg() {
2 | return (
3 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/svg-component/DetailKoreanAlertIconSvg.tsx:
--------------------------------------------------------------------------------
1 | export function DetailKoreanAlertIconSvg() {
2 | return (
3 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/svg-component/DetailLikeSvg.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | isLike: boolean;
3 | }
4 | export default function DetailLikeSvg({ isLike }: Props) {
5 | return (
6 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/svg-component/DetailPronunciationCloseArrowSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function DetailPronunciationCloseArrowSvg() {
2 | return (
3 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/svg-component/DetailPronunciationCorrectSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function DetailPronunciationCorrectSvg() {
2 | return (
3 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/svg-component/DetailPronunciationShowArrowSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function DetailPronunciationShowArrowSvg() {
2 | return (
3 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/svg-component/DetailPronunciationWrongSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function DetailPronunciationWrongSvg() {
2 | return (
3 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/svg-component/DetailSoundIconSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function DetailSoundIconSvg() {
2 | return (
3 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/svg-component/EmptyHeartSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function EmptyHeartSvg() {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/svg-component/ErrorSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function ErrorSvg() {
2 | return (
3 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/svg-component/ExternalSvg.tsx:
--------------------------------------------------------------------------------
1 | function ExternalSvg() {
2 | return (
3 |
24 | );
25 | }
26 |
27 | export default ExternalSvg;
28 |
--------------------------------------------------------------------------------
/src/components/svg-component/FillArrowSvg.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from 'react';
2 |
3 | export default function FillArrowSvg(props: ComponentProps<'svg'>) {
4 | return (
5 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/svg-component/HamburgerMenuSvg.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function HamburgerMenuSvg() {
4 | return (
5 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/svg-component/Heart1Svg.tsx:
--------------------------------------------------------------------------------
1 | export default function Heart1Svg() {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/svg-component/HeartSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function HeartSvg() {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/svg-component/HyphenSvg.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from 'react';
2 |
3 | export default function HyphenSvg(props: ComponentProps<'svg'>) {
4 | return (
5 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/svg-component/InquirySvg.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | width: number;
3 | height: number;
4 | };
5 | export default function InquirySvg({ width, height }: Props) {
6 | return (
7 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/svg-component/LinkShareIconSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function LinkShareIconSvg() {
2 | return (
3 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/svg-component/LinkShareSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function LinkShareSvg() {
2 | return (
3 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/svg-component/LockSmallSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function LockSmallSvg() {
2 | return (
3 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/svg-component/LockSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function LockSvg() {
2 | return (
3 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/svg-component/LogoSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function LogoSvg() {
2 | return (
3 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/svg-component/MagnifierSvg.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | className: string;
3 | color: string;
4 | };
5 | export default function MagnifierSvg({ className, color }: Props) {
6 | return (
7 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/svg-component/MenuSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function MenuSvg() {
2 | return (
3 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/svg-component/ModalXSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function ModalXSvg() {
2 | return (
3 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/svg-component/MypageIconSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function MypageIconSvg() {
2 | return (
3 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/svg-component/NoWordSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function NoWordSvg() {
2 | return (
3 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/components/svg-component/NonLoginImage.tsx:
--------------------------------------------------------------------------------
1 | export default function NonLoginImage() {
2 | return (
3 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/svg-component/NotFoundSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function NotFoundSvg() {
2 | return (
3 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/svg-component/OSVG.tsx:
--------------------------------------------------------------------------------
1 | export default function OSVG() {
2 | return (
3 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/svg-component/OneButtonSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function OneButtonSvg() {
2 | return (
3 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/svg-component/PowerSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function PowerSvg() {
2 | return (
3 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/svg-component/QuizSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function QuizSvg() {
2 | return (
3 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/svg-component/RightAngleBracketSvg.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | color: string;
3 | };
4 |
5 | export default function RightAngleBracketSvg({ color }: Props) {
6 | return (
7 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/svg-component/RightArrowSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function RightArrowSvg() {
2 | return (
3 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/svg-component/ShareButtonSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function ShareButtonSvg() {
2 | return (
3 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/svg-component/SpeakerSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function SpeakerSvg() {
2 | return (
3 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/svg-component/SubmitFeedbackSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function SubmitFeedbackSvg() {
2 | return (
3 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/svg-component/TriangleSvg.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function TriangleSvg({ className }: { className?: string }) {
4 | return (
5 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/svg-component/TwoButtonSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function TwoButtonSvg() {
2 | return (
3 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/svg-component/WarningBellSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function WarningBellSvg() {
2 | return (
3 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/svg-component/WordBookSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function WordBookSvg() {
2 | return (
3 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/svg-component/WrongSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function WrongSvg() {
2 | return (
3 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/svg-component/XSVG.tsx:
--------------------------------------------------------------------------------
1 | export default function XSVG() {
2 | return (
3 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/constants/home.constants.ts:
--------------------------------------------------------------------------------
1 | export const MAIN_PAGE_ITEM_LIMIT = 16;
2 |
--------------------------------------------------------------------------------
/src/constants/queryKey.ts:
--------------------------------------------------------------------------------
1 | const QUERY_KEYS = Object.freeze({
2 | HOME_KEY: 'posts',
3 | QUIZ_KEY: 'quiz',
4 | SEARCH_KEY: 'search',
5 | LIKEDWORD_KEY: 'likedWord',
6 | AUTH_KEY: 'auth',
7 | USER_KEY: 'user',
8 | TREND: 'trend',
9 | TTS_KEY: 'ttsUrl',
10 | } as const);
11 |
12 | export default QUERY_KEYS;
13 |
--------------------------------------------------------------------------------
/src/constants/sortingOptions.ts:
--------------------------------------------------------------------------------
1 | export const sortOptionMapping: { [key: string]: string } = {
2 | 최신순: 'CREATED',
3 | 좋아요순: 'LIKED',
4 | // 조회순: 'VIEWED',
5 | 알파벳순: 'ALPHABET',
6 | };
7 |
8 | export const dropdownOptions = Object.keys(sortOptionMapping);
9 | export const DROPDOWN_DEFAULT_OPTION = '최신순';
10 |
--------------------------------------------------------------------------------
/src/fetcher/backendFetch.ts:
--------------------------------------------------------------------------------
1 | import httpClient from '@/fetcher/fetch.ts';
2 | import * as process from 'process';
3 |
4 | import { responseInterceptor } from '@/fetcher/interceptors.ts';
5 | import { isServer } from '@/utils';
6 |
7 | // NOTE: 별도의 쿠키 조작을 하지 않는 기본 fetch 입니다. backend api에 request를 전송합니다.
8 | // client side 에서 사용할 수 있습니다.
9 | export const backendFetch = httpClient({
10 | baseUrl: process.env.NEXT_PUBLIC_BACKEND_BASE_URL,
11 | headers: { 'Content-Type': 'application/json' },
12 | cache: 'no-store',
13 | credentials: 'include',
14 | interceptors: {
15 | request: async (url, init) => {
16 | if (isServer()) {
17 | console.log(
18 | '********* before sending request in server with backendFetch *********',
19 | );
20 | console.log('request to: ', url.toString());
21 | }
22 | return init;
23 | },
24 | response: responseInterceptor,
25 | },
26 | });
27 |
--------------------------------------------------------------------------------
/src/fetcher/fetch.ts:
--------------------------------------------------------------------------------
1 | /*
2 | fetch Type
3 | function fetch(
4 | input: string | URL | Request,
5 | init?: RequestInit,
6 | ): Promise;
7 | */
8 |
9 | type FetchParameters = Parameters;
10 | type Promiseable = T | Promise;
11 |
12 | type Params = {
13 | params?: Record;
14 | };
15 |
16 | type ValueOf = T[keyof T];
17 |
18 | type Options = NonNullable & Params;
19 |
20 | export type HTTPClient = ReturnType>;
21 |
22 | export interface HTTPClientOption
23 | extends Omit, 'body'> {
24 | baseUrl?: string;
25 | interceptors?: {
26 | request?(
27 | input: NonNullable,
28 | init: NonNullable,
29 | ): Promiseable;
30 | response?(response: Response): Promiseable;
31 | };
32 | }
33 |
34 | const applyBaseUrl = (input: FetchParameters[0], baseUrl?: string) => {
35 | if (!baseUrl) {
36 | return input;
37 | }
38 |
39 | if (typeof input === 'object' && 'url' in input) {
40 | return new URL(input.url, baseUrl);
41 | }
42 |
43 | return new URL(input, baseUrl);
44 | };
45 |
46 | const applyQueryParams = (
47 | baseUrl: ReturnType,
48 | params: ValueOf,
49 | ) => {
50 | if (!params) {
51 | return baseUrl;
52 | }
53 |
54 | let url = null;
55 |
56 | if (typeof baseUrl === 'string') {
57 | url = new URL(baseUrl);
58 | } else if (typeof baseUrl === 'object' && 'url' in baseUrl) {
59 | url = new URL(baseUrl.url);
60 | } else if (baseUrl instanceof URL) {
61 | url = baseUrl;
62 | } else {
63 | throw new Error('Invalid baseUrl type');
64 | }
65 |
66 | Object.keys(params).forEach((key) => {
67 | if (params[key] !== null && params[key] !== undefined) {
68 | url.searchParams.append(key, String(params[key]));
69 | }
70 | });
71 |
72 | return url;
73 | };
74 |
75 | export default function httpClient({
76 | baseUrl,
77 | interceptors = {},
78 | ...requestInit
79 | }: HTTPClientOption = {}) {
80 | // fetch 함수 Return
81 | return async function (
82 | input: FetchParameters[0],
83 | init?: Options,
84 | ): Promise {
85 | let url = applyBaseUrl(input, baseUrl);
86 |
87 | if (init && init.params) {
88 | url = applyQueryParams(url, init.params);
89 | }
90 |
91 | // 기존 option 과 현재 받은 option을 합친다
92 | const option = { ...requestInit, ...init };
93 |
94 | const interceptorAppliedOption = interceptors.request
95 | ? await interceptors.request(url, option)
96 | : option;
97 |
98 | const response = await fetch(url, interceptorAppliedOption);
99 |
100 | if (interceptors.response) {
101 | return (await interceptors.response(response)) as Res;
102 | }
103 |
104 | return response as Res;
105 | };
106 | }
107 |
--------------------------------------------------------------------------------
/src/fetcher/interceptors.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorRes } from '@/fetcher/types.ts';
2 | import { isServer } from '@/utils';
3 |
4 | export async function responseInterceptor(response: Response) {
5 | if (isServer()) {
6 | console.log('********* after sending request in server *********\n');
7 | console.log(response.status);
8 | }
9 |
10 | const data = await response.json();
11 |
12 | if (!response.ok) {
13 | // NOTE: 서버사이드에서 호출 시에는 로깅합니다.
14 | if (isServer()) {
15 | if (400 <= response.status && response.status < 500) {
16 | console.log('ClientError: ', response.status);
17 | } else {
18 | console.error('ServerError : ', response.status);
19 | }
20 | }
21 |
22 | //NOTE: 에러 시 내려오는 객체의 형식으로 type assertion 합니다.
23 | const res = data as ErrorRes;
24 |
25 | // NOTE: axios 기본 동작과 동일하게, response.ok가 아니면 Error 를 throw 합니다.
26 | throw new Error(
27 | JSON.stringify({
28 | statusCode: response.status,
29 | message: res.message,
30 | }),
31 | );
32 | }
33 |
34 | return {
35 | status: response.status,
36 | statusText: response.statusText,
37 | headers: response.headers,
38 | data,
39 | };
40 | }
41 |
--------------------------------------------------------------------------------
/src/fetcher/server.ts:
--------------------------------------------------------------------------------
1 | import { MAIN_PAGE_ITEM_LIMIT } from '@/constants/home.constants';
2 | import { serverFetch } from '@/fetcher/serverFetch.ts';
3 | import {
4 | DefaultRes,
5 | FetchRes,
6 | TrendWordData,
7 | UserData,
8 | } from '@/fetcher/types.ts';
9 | import { MainItemType, PaginationRes } from '@/types/main.ts';
10 | import { notFound } from 'next/navigation';
11 |
12 | export const getAllPostsServer = async (currentPage: number) => {
13 | try {
14 | const res = await serverFetch<
15 | FetchRes>>
16 | >(`/word/list`, {
17 | params: {
18 | page: currentPage,
19 | limit: MAIN_PAGE_ITEM_LIMIT,
20 | },
21 | });
22 |
23 | return res.data;
24 | } catch (e) {
25 | console.log('error', e);
26 | notFound();
27 | }
28 | };
29 |
30 | export const getUserInfoServer = async () => {
31 | return await serverFetch>>(`/user`);
32 | };
33 |
34 | export const getCurrentWeekTrendList = async () => {
35 | try {
36 | return await serverFetch>>(
37 | `ranking/current`,
38 | );
39 | } catch (e) {
40 | console.log('error', e);
41 | notFound();
42 | }
43 | };
44 |
--------------------------------------------------------------------------------
/src/fetcher/serverFetch.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import httpClient from '@/fetcher/fetch.ts';
4 | import * as process from 'process';
5 | import { cookies } from 'next/headers';
6 | import { responseInterceptor } from '@/fetcher/interceptors.ts';
7 |
8 | // NOTE: 서버 사이드 전용으로 사용하는 fetch 입니다. backend api에 request를 요청합니다.
9 | // request interceptors 에서 Cookie header에 쿠키를 넣어줍니다.
10 |
11 | export const serverFetch = httpClient({
12 | baseUrl: process.env.NEXT_PUBLIC_BACKEND_BASE_URL,
13 | headers: { 'Content-Type': 'application/json' },
14 | cache: 'no-store',
15 | credentials: 'include',
16 | interceptors: {
17 | request: async (url, init) => {
18 | // NOTE: 로깅
19 | console.log(
20 | '********* before sending request in server with serverFetch *********',
21 | );
22 | console.log('request to: ', url.toString());
23 |
24 | // NOTE: Cookie를 직접 세팅해줘야 합니다.
25 | const accessToken = cookies().get('accessToken')?.value;
26 | const refreshToken = cookies().get('refreshToken')?.value;
27 | console.log('accessToken: ', accessToken);
28 | console.log('refreshToken: ', refreshToken);
29 |
30 | if (accessToken || refreshToken) {
31 | init.headers = {
32 | ...init.headers,
33 | Cookie: `refreshToken=${refreshToken}; accessToken=${accessToken}`,
34 | };
35 | }
36 | return init;
37 | },
38 |
39 | response: responseInterceptor,
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/src/hooks/mutation/useAddLike.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@tanstack/react-query';
2 | import { updateLike } from '@/fetcher';
3 |
4 | const useAddLike = () => {
5 | return useMutation({
6 | mutationFn: (wordId: string) => updateLike(wordId),
7 | });
8 | };
9 |
10 | export default useAddLike;
11 |
--------------------------------------------------------------------------------
/src/hooks/mutation/useDeleteLike.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@tanstack/react-query';
2 | import { deleteLike } from '@/fetcher';
3 |
4 | const useDeleteLike = () => {
5 | return useMutation({
6 | mutationFn: (wordId: string) => deleteLike(wordId),
7 | });
8 | };
9 |
10 | export default useDeleteLike;
11 |
--------------------------------------------------------------------------------
/src/hooks/mutation/useQuizResult.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@tanstack/react-query';
2 | import { postQuizResult } from '@/fetcher';
3 |
4 | const usePostsQuizResult = () => {
5 | return useMutation({
6 | mutationFn: ({
7 | correctWordIds,
8 | incorrectWordIds,
9 | }: {
10 | correctWordIds: string[];
11 | incorrectWordIds: string[];
12 | }) => postQuizResult(correctWordIds, incorrectWordIds),
13 | });
14 | };
15 |
16 | export default usePostsQuizResult;
17 |
--------------------------------------------------------------------------------
/src/hooks/query/useAuthQuery.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import { checkUserAuthentication } from '@/fetcher';
3 | import QUERY_KEYS from '@/constants/queryKey';
4 |
5 | export default function useAuthQuery() {
6 | return useQuery({
7 | queryKey: [QUERY_KEYS.AUTH_KEY],
8 | queryFn: checkUserAuthentication,
9 | });
10 | }
11 |
--------------------------------------------------------------------------------
/src/hooks/query/useGetAllPosts.ts:
--------------------------------------------------------------------------------
1 | import QUERY_KEYS from '@/constants/queryKey';
2 | import { useSuspenseQuery } from '@tanstack/react-query';
3 | import { getAllPostsClient } from '@/fetcher';
4 |
5 | const useGetAllPosts = (pageNumber: number) => {
6 | return useSuspenseQuery({
7 | queryKey: [QUERY_KEYS.HOME_KEY, pageNumber],
8 | queryFn: () => getAllPostsClient(pageNumber),
9 | staleTime: 1000 * 60 * 3600,
10 | });
11 | };
12 |
13 | export default useGetAllPosts;
14 |
--------------------------------------------------------------------------------
/src/hooks/query/useGetLikedWord.ts:
--------------------------------------------------------------------------------
1 | import QUERY_KEYS from '@/constants/queryKey';
2 | import { getLikedWord } from '@/fetcher';
3 | import { useSuspenseQuery } from '@tanstack/react-query';
4 |
5 | const useGetLikedWord = (
6 | pageNumber: number,
7 | limit: number,
8 | selectedOption: string,
9 | ) => {
10 | return useSuspenseQuery({
11 | queryKey: [QUERY_KEYS.LIKEDWORD_KEY, pageNumber, selectedOption],
12 | queryFn: () => getLikedWord(pageNumber, limit, selectedOption),
13 | staleTime: 1000 * 60 * 3600,
14 | });
15 | };
16 | export default useGetLikedWord;
17 |
--------------------------------------------------------------------------------
/src/hooks/query/useGetQuizData.ts:
--------------------------------------------------------------------------------
1 | import { useSuspenseQuery } from '@tanstack/react-query';
2 | import { getQuizData } from '@/fetcher';
3 | import QUERY_KEYS from '@/constants/queryKey';
4 |
5 | export const useGetQuizData = () => {
6 | return useSuspenseQuery({
7 | queryKey: [QUERY_KEYS.QUIZ_KEY],
8 | queryFn: () => getQuizData(),
9 | staleTime: Infinity,
10 | gcTime: Infinity,
11 | refetchOnWindowFocus: false,
12 | refetchOnReconnect: false,
13 | });
14 | };
15 |
--------------------------------------------------------------------------------
/src/hooks/query/useGetSearchWord.ts:
--------------------------------------------------------------------------------
1 | import QUERY_KEYS from '@/constants/queryKey';
2 | import { useSuspenseQuery } from '@tanstack/react-query';
3 | import { getWordSearch } from '@/fetcher';
4 |
5 | export const useGetSearchWord = (word: string) => {
6 | return useSuspenseQuery({
7 | queryKey: [QUERY_KEYS.SEARCH_KEY, word],
8 | queryFn: () => getWordSearch(word),
9 | });
10 | };
11 |
--------------------------------------------------------------------------------
/src/hooks/query/useGetTTSUrl.ts:
--------------------------------------------------------------------------------
1 | import QUERY_KEYS from '@/constants/queryKey';
2 | import { getTTSUrl } from '@/fetcher';
3 | import { useQuery } from '@tanstack/react-query';
4 |
5 | const useGetTTSUrl = (id: string) => {
6 | return useQuery({
7 | queryKey: [QUERY_KEYS.TTS_KEY, id],
8 | queryFn: () => getTTSUrl(id),
9 | gcTime: Infinity,
10 | staleTime: Infinity,
11 | });
12 | };
13 |
14 | export default useGetTTSUrl;
15 |
--------------------------------------------------------------------------------
/src/hooks/query/useGetTrendList.ts:
--------------------------------------------------------------------------------
1 | import QUERY_KEYS from '@/constants/queryKey';
2 | import { useSuspenseQuery } from '@tanstack/react-query';
3 | import { getCurrentWeekTrendList } from '@/fetcher/server';
4 |
5 | export const useGetTrendList = () => {
6 | return useSuspenseQuery({
7 | queryKey: [QUERY_KEYS.TREND],
8 | queryFn: () => getCurrentWeekTrendList(),
9 | select: (response) => response.data.data
10 | });
11 | };
12 |
--------------------------------------------------------------------------------
/src/hooks/query/useGetUser.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import { getUserInfo } from '@/fetcher';
3 | import QUERY_KEYS from '@/constants/queryKey';
4 |
5 | export default function useGetUser() {
6 | return useQuery({
7 | queryKey: [QUERY_KEYS.USER_KEY],
8 | queryFn: getUserInfo,
9 | });
10 | }
11 |
--------------------------------------------------------------------------------
/src/hooks/useAudioPlayer.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useState, useEffect } from 'react';
2 |
3 | const useAudioPlayer = (id: string) => {
4 | const audioRef = useRef(null);
5 | const [isPlaying, setIsPlaying] = useState(false);
6 |
7 | const startAudio = async (url: string) => {
8 | const currentAudioRef = audioRef.current;
9 |
10 | if (currentAudioRef) {
11 | if (currentAudioRef.src !== url) {
12 | currentAudioRef.src = url;
13 | }
14 | try {
15 | await currentAudioRef.play();
16 | setIsPlaying(true);
17 | } catch (error) {
18 | console.error('Error startAudio:', error);
19 | }
20 | }
21 | };
22 |
23 | useEffect(() => {
24 | const currentAudioRef = audioRef.current;
25 |
26 | const handleEnded = () => {
27 | setIsPlaying(false);
28 | if (currentAudioRef) {
29 | currentAudioRef.pause();
30 | currentAudioRef.currentTime = 0;
31 | }
32 | };
33 |
34 | if (currentAudioRef) {
35 | currentAudioRef.addEventListener('ended', handleEnded);
36 | }
37 |
38 | return () => {
39 | if (currentAudioRef) {
40 | currentAudioRef.removeEventListener('ended', handleEnded);
41 | }
42 | };
43 | }, [id]);
44 |
45 | return {
46 | audioRef,
47 | isPlaying,
48 | startAudio,
49 | };
50 | };
51 |
52 | export default useAudioPlayer;
53 |
--------------------------------------------------------------------------------
/src/hooks/useCopyClipboard.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react';
2 |
3 | async function copyURL(url?: string) {
4 | const targetUrl = url ? url : window.document.location.href;
5 | await navigator.clipboard.writeText(targetUrl);
6 | }
7 | interface Props {
8 | ms?: number;
9 | url?: string;
10 | }
11 |
12 | const DEFAULT_COPIED_ALERT_TIME = 600;
13 |
14 | export default function useCopyClipboard(props?: Props) {
15 | const [isCopied, setIsCopied] = useState(false);
16 |
17 | useEffect(() => {
18 | let id: NodeJS.Timeout | null;
19 | if (isCopied) {
20 | id = setTimeout(
21 | () => setIsCopied(false),
22 | props?.ms ?? DEFAULT_COPIED_ALERT_TIME,
23 | );
24 | }
25 |
26 | return () => {
27 | if (id) clearTimeout(id);
28 | };
29 | }, [isCopied, props?.ms]);
30 |
31 | const onCopyClipboard = useCallback(async () => {
32 | await copyURL(props?.url);
33 | setIsCopied(true);
34 | }, [props?.url]);
35 |
36 | const onCloseCopyClipboard = useCallback(() => {
37 | setIsCopied(false);
38 | }, []);
39 |
40 | return { isCopied, setIsCopied, onCopyClipboard, onCloseCopyClipboard };
41 | }
42 |
--------------------------------------------------------------------------------
/src/hooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef } from 'react';
2 |
3 | /**
4 | * 일정 주기 동안 같은 함수가 Trigger 되지 않도록 debounce 처리된 함수를 반환하는 useDebounce
5 | * delay 내로 debounce 함수가 다시 호출될 경우, timeout 이 초기화되어 delay 만큼 기다려야 함.
6 | */
7 | export const useDebounce = () => {
8 | const timer = useRef();
9 |
10 | const debounce = useCallback(
11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
12 | any>(callback: T, delay: number) =>
13 | (...args: Parameters) => {
14 | clearTimeout(timer.current);
15 | timer.current = setTimeout(() => {
16 | callback(...args);
17 | }, delay);
18 | },
19 | [],
20 | );
21 |
22 | return { debounce };
23 | };
--------------------------------------------------------------------------------
/src/hooks/useDeviceType.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export default function useDeviceType() {
4 | const [deviceType, setDeviceType] = useState('');
5 |
6 | useEffect(() => {
7 | const userAgent = navigator.userAgent;
8 |
9 | if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
10 | setDeviceType('iOS');
11 | return;
12 | }
13 |
14 | if (/android/i.test(userAgent)) {
15 | setDeviceType('Android');
16 | return;
17 | }
18 |
19 | if (
20 | /Mobile|mini|Fennec|Android|iP(ad|od|hone)|IEMobile|BlackBerry|BB10|Silk/.test(
21 | userAgent,
22 | )
23 | ) {
24 | setDeviceType('Mobile');
25 | return;
26 | }
27 |
28 | setDeviceType('PC');
29 | }, []);
30 |
31 | return deviceType;
32 | }
33 |
--------------------------------------------------------------------------------
/src/hooks/useDropdown.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 |
5 | export default function useDropdown(initialOption = '최신순') {
6 | const [selectedOption, setSelectedOption] = useState(initialOption);
7 | const [currentPage, setCurrentPage] = useState(1);
8 |
9 | useEffect(() => {
10 | setCurrentPage(1);
11 | }, [selectedOption]);
12 |
13 | return {
14 | selectedOption,
15 | setSelectedOption,
16 | currentPage,
17 | setCurrentPage,
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/src/hooks/useLoadKakaoScript.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import useAuthQuery from './query/useAuthQuery';
5 | import { DefaultRes, UserInfo } from '@/fetcher/types';
6 |
7 | declare global {
8 | interface Window {
9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
10 | Kakao: any;
11 | }
12 | }
13 |
14 | function isDefaultRes(response: unknown): response is DefaultRes {
15 | return (response as DefaultRes).data !== undefined;
16 | }
17 |
18 | export default function useLoadKakaoScript() {
19 | const { Kakao } = window;
20 | const { data } = useAuthQuery();
21 |
22 | const userName = isDefaultRes(data) ? data.data.name : '사용자';
23 |
24 | const title = `${userName}님의 개발 용어 점수는?`;
25 | const desc = `${userName}님의 개발 용어 점수는 몇 점일까요? 클릭해서 확인해보고, 함께 도전해보세요!`;
26 |
27 | const urlEndPoint = window.location.href.split('/').slice(3).join('/');
28 | const path = urlEndPoint;
29 |
30 | console.log(path);
31 | useEffect(() => {
32 | if (!Kakao.isInitialized()) {
33 | Kakao.init(process.env.NEXT_PUBLIC_KAKAO_SHARE_KEY);
34 | }
35 | }, [Kakao]);
36 |
37 | const handleShare = () => {
38 | Kakao.Share.sendCustom({
39 | templateId: Number(process.env.NEXT_PUBLIC_KAKAO_SHARE_TEMPLATE_ID),
40 | templateArgs: {
41 | title: title,
42 | desc: desc,
43 | path: path,
44 | url: '${path}',
45 | },
46 | });
47 | };
48 |
49 | return { handleShare };
50 | }
51 |
--------------------------------------------------------------------------------
/src/hooks/useMutationLike.ts:
--------------------------------------------------------------------------------
1 | import QUERY_KEYS from '@/constants/queryKey';
2 | import { deleteLike, updateLike } from '@/fetcher';
3 | import { useQueryClient, useMutation } from '@tanstack/react-query';
4 | import { Dispatch, SetStateAction } from 'react';
5 | import useAuthQuery from './query/useAuthQuery';
6 |
7 | type Props = {
8 | wordId: string;
9 | setIsOpenModal: Dispatch>;
10 | };
11 |
12 | export const useMutationLike = ({ wordId, setIsOpenModal }: Props) => {
13 | const queryClient = useQueryClient();
14 |
15 | // 로그인 유무 판별
16 | const { data: isLoggedIn } = useAuthQuery();
17 |
18 | const updateLikeMutation = useMutation({
19 | mutationFn: () => updateLike(wordId),
20 | onSuccess: () => {
21 | queryClient.invalidateQueries({
22 | queryKey: [QUERY_KEYS.HOME_KEY],
23 | });
24 | queryClient.invalidateQueries({
25 | queryKey: [QUERY_KEYS.LIKEDWORD_KEY],
26 | });
27 | queryClient.invalidateQueries({
28 | queryKey: [QUERY_KEYS.SEARCH_KEY],
29 | });
30 | },
31 | onError: (error) => {
32 | console.error('Failed to update like:', error);
33 | },
34 | });
35 |
36 | const deleteLikeMutation = useMutation({
37 | mutationFn: () => deleteLike(wordId),
38 | onSuccess: () => {
39 | queryClient.invalidateQueries({
40 | queryKey: [QUERY_KEYS.HOME_KEY],
41 | });
42 | queryClient.invalidateQueries({
43 | queryKey: [QUERY_KEYS.LIKEDWORD_KEY],
44 | });
45 | queryClient.invalidateQueries({
46 | queryKey: [QUERY_KEYS.SEARCH_KEY],
47 | });
48 | },
49 | onError: (error) => {
50 | console.error('Failed to delete like:', error);
51 | },
52 | });
53 |
54 | const handleAddLike = () => {
55 | if (isLoggedIn?.error) {
56 | setIsOpenModal(true);
57 | setTimeout(() => {
58 | setIsOpenModal(false);
59 | }, 2000);
60 | }
61 | updateLikeMutation.mutate();
62 | };
63 |
64 | const handleSubLike = () => {
65 | deleteLikeMutation.mutate();
66 | };
67 |
68 | return { handleAddLike, handleSubLike };
69 | };
70 |
--------------------------------------------------------------------------------
/src/hooks/useOnClickOutside.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | interface PropsType {
4 | onClickOutside: (event: PointerEvent) => void;
5 | }
6 |
7 | /**
8 | * 특정 Element 외부를 클릭할 경우에 대한 로직을 리스너에 추가하는 Hook useOnClickOutside
9 | */
10 | export const useOnClickOutside = ({
11 | onClickOutside,
12 | }: PropsType) => {
13 | const targetRef = useRef(null);
14 |
15 | useEffect(() => {
16 | const eventListener = (event: PointerEvent) => {
17 | // NOTE : ref 에 element 가 없거나, 클릭한 대상이 ref의 자식 요소인지를 판별
18 | if (
19 | !targetRef.current ||
20 | targetRef.current.contains(event.target as Node)
21 | )
22 | return;
23 | onClickOutside?.(event);
24 | };
25 | document.addEventListener('pointerdown', eventListener);
26 | return () => document.removeEventListener('pointerdown', eventListener);
27 | }, [onClickOutside, targetRef]);
28 |
29 | return { targetRef };
30 | };
--------------------------------------------------------------------------------
/src/hooks/useOptimisticLike.ts:
--------------------------------------------------------------------------------
1 | import { useOptimistic } from 'react';
2 |
3 | import { addLike, subLike } from '@/actions';
4 |
5 | interface Props {
6 | wordId: string;
7 | isLike: boolean;
8 | likeCount: number;
9 | }
10 | export const useOptimisticLike = ({ wordId, isLike, likeCount }: Props) => {
11 | const [optimisticLikeState, setOptimisticLike] = useOptimistic<
12 | {
13 | isLike: boolean;
14 | likeCount: number;
15 | },
16 | number
17 | >(
18 | {
19 | isLike,
20 | likeCount,
21 | },
22 | (currentState, optimisticValue) => {
23 | // merge and return new state wih optimistic value
24 | return {
25 | isLike: !currentState.isLike,
26 | likeCount: currentState.likeCount + optimisticValue,
27 | };
28 | },
29 | );
30 |
31 | const handleAddLike = async () => {
32 | setOptimisticLike(1);
33 | await addLike(wordId);
34 | };
35 |
36 | const handleSubLike = async () => {
37 | setOptimisticLike(-1);
38 | await subLike(wordId);
39 | };
40 |
41 | return {
42 | optimisticLikeState,
43 | setOptimisticLike,
44 | handleAddLike,
45 | handleSubLike,
46 | };
47 | };
48 |
--------------------------------------------------------------------------------
/src/hooks/usePagination.tsx:
--------------------------------------------------------------------------------
1 | import { PaginationPropType } from '@/types/main';
2 |
3 | export default function usePagination({
4 | limit = 10,
5 | total = 100,
6 | viewPaginationNums = 4,
7 | setCurrent,
8 | current,
9 | }: PaginationPropType) {
10 | const totalPages = Math.ceil(total / limit); // 총 페이지 개수
11 |
12 | const noPrev = current === 1;
13 | const noNext = current >= totalPages;
14 |
15 | const onChangePage = (newPage: number) =>
16 | setCurrent(Math.max(1, Math.min(newPage, totalPages)));
17 |
18 | const calculateStartPage = () => {
19 | const startPage = current - Math.floor(viewPaginationNums / 2);
20 | return Math.max(
21 | 1,
22 | Math.min(startPage, totalPages - viewPaginationNums + 1),
23 | );
24 | };
25 |
26 | const goToFirstPage = () => setCurrent(1);
27 |
28 | const goToLastPage = () => setCurrent(totalPages);
29 |
30 | const goToPrevPage = () =>
31 | setCurrent((current: number) => Math.max(1, current - 1));
32 |
33 | const goToNextPage = () =>
34 | setCurrent((current: number) => Math.min(totalPages, current + 1));
35 |
36 | return {
37 | onChangePage,
38 | calculateStartPage,
39 | noPrev,
40 | noNext,
41 | current,
42 | totalPages,
43 | goToFirstPage,
44 | goToLastPage,
45 | goToPrevPage,
46 | goToNextPage,
47 | };
48 | }
49 |
--------------------------------------------------------------------------------
/src/hooks/useScroll.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | export default function useScroll() {
4 | const [isScrolled, setIsScrolled] = useState(false);
5 |
6 | useEffect(() => {
7 | const handleScroll = () => {
8 | const scrolled = window.scrollY > 0;
9 | setIsScrolled(scrolled);
10 | };
11 |
12 | window.addEventListener('scroll', handleScroll);
13 | return () => window.removeEventListener('scroll', handleScroll);
14 | }, []);
15 |
16 | return isScrolled;
17 | }
18 |
--------------------------------------------------------------------------------
/src/hooks/useSyncURLHomeRouteState.ts:
--------------------------------------------------------------------------------
1 | import { type TrendingType } from '@/components/pages/home';
2 | import { useRouter, useSearchParams } from 'next/navigation';
3 | import { useEffect, useState } from 'react';
4 |
5 | const isTrendingType = (value: unknown): value is TrendingType => {
6 | return value === 'trend' || value === 'all';
7 | };
8 |
9 | export default function useSyncURLHomeRouteState() {
10 | const router = useRouter();
11 | const searchParams = useSearchParams();
12 | const page = searchParams.get('page');
13 | const view = searchParams.get('view');
14 |
15 | const [currentPage, setCurrentPage] = useState(() => {
16 | return page ? Number(page) : 1;
17 | });
18 |
19 | const [isTrending, setIsTrending] = useState(() => {
20 | return isTrendingType(view) ? view : 'trend';
21 | });
22 |
23 | useEffect(() => {
24 | router.push(`/home?view=${isTrending}&page=${currentPage}`);
25 | }, [currentPage, isTrending, router]);
26 |
27 | useEffect(() => {
28 | const nextPage = page ? Number(page) : 1;
29 | const nextView: TrendingType = isTrendingType(view) ? view : 'trend';
30 |
31 | setCurrentPage(nextPage);
32 | setIsTrending(nextView);
33 | }, [page, view]);
34 |
35 | return { currentPage, setCurrentPage, isTrending, setIsTrending };
36 | }
37 |
--------------------------------------------------------------------------------
/src/providers/QueryProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3 | import { PropsWithChildren } from 'react';
4 |
5 | function makeQueryClient() {
6 | return new QueryClient({
7 | defaultOptions: {
8 | queries: {
9 | staleTime: 60 * 1000,
10 | },
11 | },
12 | });
13 | }
14 |
15 | let browserQueryClient: QueryClient | undefined = undefined;
16 |
17 | function getQueryClient() {
18 | if (typeof window === 'undefined') {
19 | // Server: always make a new query client
20 | return makeQueryClient();
21 | } else {
22 | if (!browserQueryClient) browserQueryClient = makeQueryClient();
23 | return browserQueryClient;
24 | }
25 | }
26 |
27 | export default function QueryProvider({ children }: PropsWithChildren) {
28 | const queryClient = getQueryClient();
29 |
30 | return (
31 | {children}
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/routes/path.ts:
--------------------------------------------------------------------------------
1 | export const WORD_LIST_PATH = '/home?view=trend&page=1';
2 | export const LOGIN_PATH = '/';
3 | export const QUIZ_PATH = '/quiz';
4 | export const WORDBOOK_PATH = '/user/wordbook';
5 | export const NOTICE_PATH = '/notice';
6 | export const SEARCH_PATH = '/word/search/:wordName';
7 | export const PROFILE_PATH = '/profile';
8 | export const PROFILE_DELETE_PATH = '/profile/delete';
9 |
10 | export const WORD_INQUIRY_FORM_URL = 'https://forms.gle/McGVzfsVT9SQkt1g8';
11 | export const OTHER_INQUIRY_FORM_URL =
12 | 'https://docs.google.com/forms/d/e/1FAIpQLSd2XQqzR3dDb1aq_ipTmagcZr3f-uSwTqQsLpSB6u_vq9oxBA/viewform';
13 | export const WORD_REPORT_FORM_URL = 'https://forms.gle/2d64B9JmWSEbVHiN7';
14 |
15 | export const getWordDetailPath = (wordName: string) =>
16 | `/words/${encodeURIComponent(wordName)}`;
17 | export const getSearchPath = (wordName: string) => `/word/search/${wordName}`;
18 | export const getQuizResultPath = (quizId: string) => `/quiz/result/${quizId}`;
19 | export const getQuizResultSharePath = (quizId: string) =>
20 | `/quiz/result/${quizId}/share`;
21 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'jotai';
2 | import { QuizData } from '@/fetcher/types.ts';
3 |
4 | export const guestQuizAtom = atom<{
5 | correctWordData: QuizData[];
6 | incorrectWordData: QuizData[];
7 | }>({
8 | correctWordData: [],
9 | incorrectWordData: [],
10 | });
11 |
--------------------------------------------------------------------------------
/src/types/errorHandling.ts:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | export type ErrorHandlingProps = {
4 | title: string;
5 | description: string | ReactNode;
6 | svg: ReactNode;
7 | };
8 |
--------------------------------------------------------------------------------
/src/types/main.ts:
--------------------------------------------------------------------------------
1 | export type MainItemType = {
2 | description: string;
3 | diacritic: string[];
4 | id: string;
5 | isLike: boolean;
6 | name: string;
7 | pronunciation: string[];
8 | likeCount?: number;
9 | };
10 |
11 | export type MainResponse = {
12 | status: number;
13 | data: {
14 | data: TData[];
15 | page: number;
16 | limit: number;
17 | isLast: boolean;
18 | totalCount: number;
19 | };
20 | };
21 |
22 | export type PaginationRes = {
23 | data: TData;
24 | page: number;
25 | limit: number;
26 | isLast: boolean;
27 | totalCount: number;
28 | };
29 |
30 | export type PaginationPropType = {
31 | limit: number; // 페이지당 보여줄 데이터 개수
32 | total: number; // 전체 데이터 개수
33 | viewPaginationNums?: number; // 보여줄 페이지 개수, 기본값 4
34 | setCurrent: (value: number | ((prevCurrent: number) => number)) => void;
35 | current: number;
36 | style?: string;
37 | };
38 |
39 | export type TextSlicePrams = {
40 | text: string;
41 | limitLength: number;
42 | sliceLength: number;
43 | };
44 |
--------------------------------------------------------------------------------
/src/types/quiz.ts:
--------------------------------------------------------------------------------
1 | export type UserAnswer = {
2 | id: number;
3 | answer: string;
4 | wordDiacritic: string;
5 | isAnswer: boolean;
6 | isLike: boolean;
7 | };
8 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export function isServer() {
2 | return typeof window === 'undefined';
3 | }
4 |
5 | export const PRODUCTION_URL = 'https://dev-malssami.site';
6 |
7 | export const BASE_URL =
8 | process.env.NODE_ENV === 'production'
9 | ? PRODUCTION_URL
10 | : process.env.NEXT_PUBLIC_BASE_URL;
11 |
12 | export const isDvhSupported = () => {
13 | return (
14 | typeof CSS !== 'undefined' && CSS.supports && CSS.supports('height', '1dvh')
15 | );
16 | };
17 |
18 | export const isMobile = () => {
19 | return /android|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile|silk|fennec|bb10|tablet|webos/i.test(
20 | navigator.userAgent,
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "noEmit": true,
14 | "jsx": "preserve",
15 | /* Linting */
16 | "strict": true,
17 | "noUnusedLocals": true,
18 | "noUnusedParameters": true,
19 | "noFallthroughCasesInSwitch": true,
20 | "baseUrl": ".",
21 | "incremental": true,
22 | "esModuleInterop": true,
23 | "plugins": [
24 | {
25 | "name": "next"
26 | }
27 | ],
28 | "paths": {
29 | "@/*": ["./src/*"]
30 | },
31 | "allowJs": false
32 | },
33 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
34 | "exclude": ["node_modules"]
35 | }
36 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------