├── .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 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 33 | 34 | 40 | 41 | 42 | 43 | 47 | 52 | 58 | 59 | 60 | 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 |
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 |
12 | 13 |
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 |
45 |

예문

46 |
47 |
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 |
30 | 31 |