├── .eslintrc.json ├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierrc.json ├── .storybook ├── decorators │ └── EmotionThemeProvider.tsx ├── main.js └── preview.js ├── README.md ├── next.config.js ├── package.json ├── public ├── assets │ ├── audio │ │ └── mixkit-positive-notification-951.wav │ ├── developerwiki.png │ ├── favicon │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── site.webmanifest │ └── profile-default.jpg ├── favicon.ico ├── mockServiceWorker.js └── vercel.svg ├── src ├── assets │ ├── fonts │ │ ├── Pretendard-Black.woff │ │ ├── Pretendard-Bold.woff │ │ ├── Pretendard-ExtraBold.woff │ │ ├── Pretendard-ExtraLight.woff │ │ ├── Pretendard-Light.woff │ │ ├── Pretendard-Medium.woff │ │ ├── Pretendard-Regular.woff │ │ ├── Pretendard-SemiBold.woff │ │ ├── Pretendard-Thin.woff │ │ └── font.css │ ├── icons │ │ ├── alert-circle.svg │ │ ├── arrow-down.svg │ │ ├── arrow-left.svg │ │ ├── arrow-right.svg │ │ ├── arrow-up.svg │ │ ├── close.svg │ │ ├── comment.svg │ │ ├── eye.svg │ │ ├── google.svg │ │ ├── hamburger.svg │ │ ├── logo-icon.svg │ │ ├── menu.svg │ │ ├── microphone.svg │ │ ├── pause.svg │ │ ├── pencil.svg │ │ ├── play.svg │ │ ├── refresh.svg │ │ ├── star.svg │ │ ├── stop.svg │ │ └── upload.svg │ └── images │ │ ├── logo-text.svg │ │ └── logo.svg ├── components │ ├── base │ │ ├── BackgroundDim │ │ │ └── index.tsx │ │ ├── Button │ │ │ ├── index.tsx │ │ │ └── types.ts │ │ ├── Checkbox │ │ │ └── index.tsx │ │ ├── ErrorBoundary │ │ │ ├── ApiErrorBoundary.tsx │ │ │ └── AuthFallback.tsx │ │ ├── FallbackComponent │ │ │ ├── ApiFallback.tsx │ │ │ └── ErrorComponent.tsx │ │ ├── Favicon │ │ │ └── index.tsx │ │ ├── Icon │ │ │ ├── IconButton.tsx │ │ │ ├── index.tsx │ │ │ └── svg.ts │ │ ├── Input │ │ │ ├── AddInput.tsx │ │ │ └── index.tsx │ │ ├── Label │ │ │ └── index.tsx │ │ ├── Link.tsx │ │ ├── PageTitle │ │ │ └── index.tsx │ │ ├── SSRSafeSuspense │ │ │ └── index.tsx │ │ └── Select │ │ │ └── index.tsx │ ├── common │ │ ├── AddForm │ │ │ └── index.tsx │ │ ├── Article │ │ │ └── index.tsx │ │ ├── Avatar │ │ │ ├── index.tsx │ │ │ └── types.ts │ │ ├── ClientPortal │ │ │ └── index.tsx │ │ ├── CloseButton │ │ │ └── index.tsx │ │ ├── Comment │ │ │ ├── AddCommentForm.tsx │ │ │ ├── CommentContent.tsx │ │ │ ├── CommentItem.tsx │ │ │ ├── CommentList.tsx │ │ │ ├── CommentTextArea.tsx │ │ │ ├── EditCommentForm.tsx │ │ │ ├── PasswordConfirm.tsx │ │ │ ├── TotalCount.tsx │ │ │ ├── context │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── ErrorMessage │ │ │ └── index.tsx │ │ ├── Footer │ │ │ └── index.tsx │ │ ├── Header │ │ │ ├── CategoryListItem.tsx │ │ │ ├── ProfileDropdown.tsx │ │ │ ├── Slide.tsx │ │ │ └── index.tsx │ │ ├── InputField │ │ │ ├── SubCategoryField.tsx │ │ │ ├── TailQuestionField.tsx │ │ │ ├── TitleField.tsx │ │ │ └── index.tsx │ │ ├── Logo │ │ │ └── index.tsx │ │ ├── MainContainer │ │ │ └── index.tsx │ │ ├── MiddleCategory │ │ │ └── index.tsx │ │ ├── Modal │ │ │ ├── Portal.tsx │ │ │ └── index.tsx │ │ ├── MoveButtons │ │ │ └── index.tsx │ │ ├── PageContainer │ │ │ └── index.tsx │ │ ├── PageDescription │ │ │ └── index.tsx │ │ ├── Pagination │ │ │ └── index.tsx │ │ ├── SEO │ │ │ └── index.tsx │ │ ├── Spinner │ │ │ └── index.tsx │ │ ├── TextArea │ │ │ └── index.tsx │ │ ├── Toast │ │ │ ├── ToastItem.tsx │ │ │ ├── ToastManager.tsx │ │ │ └── index.tsx │ │ └── UserProfile │ │ │ └── index.tsx │ └── domain │ │ ├── QuestionList │ │ ├── BookmarkButton.tsx │ │ ├── QuestionItem.tsx │ │ └── index.tsx │ │ ├── profile │ │ ├── DeleteAccount.tsx │ │ ├── EditAvatar.tsx │ │ ├── EditUserInfo.tsx │ │ ├── ImageEditModal.tsx │ │ ├── Tab │ │ │ ├── Bookmark.tsx │ │ │ ├── BookmarkList.tsx │ │ │ ├── Comment.tsx │ │ │ ├── CommentItem.tsx │ │ │ ├── NoResult.tsx │ │ │ ├── PageInfo.tsx │ │ │ └── index.tsx │ │ └── UserInfo.tsx │ │ ├── question │ │ ├── PostHeader │ │ │ └── index.tsx │ │ ├── QuestionMoveButtons │ │ │ └── index.tsx │ │ ├── Recorder │ │ │ └── index.tsx │ │ ├── TailQuestionList │ │ │ └── index.tsx │ │ ├── TailQuestionModal │ │ │ └── index.tsx │ │ └── TailQuestions │ │ │ └── index.tsx │ │ └── random │ │ ├── MainCategoryField.tsx │ │ ├── RandomContent.tsx │ │ ├── StepOne.tsx │ │ ├── StepTwo.tsx │ │ ├── SubCategoryCheckbox.tsx │ │ ├── SubCategoryField.tsx │ │ └── TypeField.tsx ├── hooks │ ├── useAxios.ts │ ├── useClickAway.ts │ ├── useForm.ts │ ├── useHover.ts │ ├── useMounted.ts │ ├── useStorage.ts │ ├── useTab.ts │ ├── useTimeout.ts │ ├── useTimeoutFn.ts │ ├── useTimer.ts │ ├── useUrlState.ts │ └── useUserWithGuard.ts ├── lib │ └── gtm.js ├── mocks │ ├── browser.ts │ ├── data.ts │ ├── handlers.ts │ ├── index.ts │ └── server.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── index.tsx │ ├── login │ │ └── index.tsx │ ├── profile │ │ ├── edit.tsx │ │ └── index.tsx │ ├── question │ │ ├── [id] │ │ │ └── index.tsx │ │ └── create.tsx │ ├── random │ │ ├── create.tsx │ │ ├── text │ │ │ └── [idx].tsx │ │ └── voice │ │ │ └── [idx].tsx │ └── suggestion │ │ └── index.tsx ├── react-query │ ├── hooks │ │ ├── useAuth.ts │ │ ├── useAuthMutation.ts │ │ ├── useAuthQuery.ts │ │ ├── useBookmark.ts │ │ ├── useBookmarkList.ts │ │ ├── useComment.ts │ │ ├── useCreateQuestion.ts │ │ ├── useDetailView.ts │ │ ├── useEditProfile.ts │ │ ├── useProfileBookmark.ts │ │ ├── useProfileComment.ts │ │ ├── useQuestion.ts │ │ ├── useQuestionList.ts │ │ └── useUser.ts │ ├── queryClient.ts │ └── queryKey.ts ├── service │ ├── base.ts │ ├── bookmark.ts │ ├── comment.ts │ ├── handler.ts │ ├── oauth.ts │ ├── question.ts │ ├── token.ts │ └── user.ts ├── stories │ ├── Avatar.stories.tsx │ ├── Button.stories.tsx │ ├── Checkbox.stories.tsx │ ├── Input.stories.tsx │ ├── Label.stories.tsx │ ├── Select.stories.tsx │ ├── Spinner.stories.tsx │ ├── Toast.stories.tsx │ └── UserProfile.stories.tsx ├── styles │ ├── globals.css │ └── reset.css ├── types │ ├── comment.ts │ ├── declarations.d.ts │ ├── question.ts │ ├── theme.ts │ ├── user.ts │ └── utilityType.ts └── utils │ ├── constant │ ├── category.ts │ ├── random.ts │ └── user.ts │ └── helper │ ├── categorySelect.ts │ ├── checkType.ts │ ├── converter.ts │ ├── device.ts │ ├── formatting.ts │ ├── mediaQuery.ts │ └── validation.ts ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["react-app", "prettier/prettier"], 3 | "plugins": ["react-hooks", "simple-import-sort", "prettier"], 4 | "rules": { 5 | "prettier/prettier": "error", 6 | "react-hooks/rules-of-hooks": "error", 7 | "simple-import-sort/imports": "error", 8 | "simple-import-sort/exports": "error", 9 | "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], 10 | "comma-dangle": ["error", "always-multiline"], 11 | "object-curly-spacing": ["error", "always"], 12 | "space-in-parens": ["error", "never"], 13 | "computed-property-spacing": ["error", "never"], 14 | "comma-spacing": ["error", { "before": false, "after": true }], 15 | "eol-last": ["error", "always"], 16 | "quotes": ["error", "single"], 17 | "no-tabs": "error", 18 | "semi": ["error", "never"], 19 | "import/no-anonymous-default-export": 0, 20 | "object-shorthand": "error", 21 | "padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "return" }], 22 | "@typescript-eslint/no-redeclare": 0 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | close # 2 | 3 | ## ✅ 작업 내용 4 | 5 | ## 📌 이슈 사항 6 | 7 | ## ✍ 궁금한 점 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | 39 | # vscode 40 | .vscode -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "tabWidth": 2, 7 | "bracketSpacing": true, 8 | "endOfLine": "auto", 9 | "useTabs": false 10 | } 11 | -------------------------------------------------------------------------------- /.storybook/decorators/EmotionThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ThemeProvider } from '@emotion/react'; 3 | import { theme } from '../../src/types/theme'; 4 | 5 | const EmotionThemeProvider = (storyFn) => {storyFn()}; 6 | 7 | export default EmotionThemeProvider; 8 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const resolvePath = (_path) => path.join(process.cwd(), _path); 4 | 5 | module.exports = { 6 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 7 | addons: [ 8 | '@storybook/addon-links', 9 | '@storybook/addon-essentials', 10 | '@storybook/addon-interactions', 11 | ], 12 | framework: '@storybook/react', 13 | core: { 14 | builder: '@storybook/builder-webpack5', 15 | }, 16 | webpackFinal: async (config) => { 17 | /* rules */ 18 | config.module.rules.unshift({ 19 | test: /\.svg$/, 20 | use: ['@svgr/webpack'], 21 | }); 22 | 23 | config.module.rules[config.module.rules.length - 2].test = 24 | /\.(ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/; 25 | 26 | /* modules */ 27 | config.resolve.modules = [path.resolve(__dirname, '..'), 'node_modules', 'styles']; 28 | 29 | /* alias */ 30 | config.resolve.alias = { 31 | ...config.resolve.alias, 32 | '~': path.resolve(__dirname, '../src'), 33 | '@emotion/core': resolvePath('node_modules/@emotion/react'), 34 | '@emotion/styled': resolvePath('node_modules/@emotion/styled'), 35 | 'emotion-theming': resolvePath('node_modules/@emotion/react'), 36 | }; 37 | 38 | return config; 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { addDecorator } from '@storybook/react'; 2 | import EmotionThemeProvider from './decorators/EmotionThemeProvider'; 3 | import '../src/assets/fonts/font.css'; 4 | import '../src/styles/globals.css'; 5 | import '../src/styles/reset.css'; 6 | import * as NextImage from 'next/image'; 7 | 8 | const OriginalNextImage = NextImage.default; 9 | 10 | Object.defineProperty(NextImage, 'default', { 11 | configurable: true, 12 | value: (props) => src} />, 13 | }); 14 | export const parameters = { 15 | actions: { argTypesRegex: '^on[A-Z].*' }, 16 | controls: { 17 | matchers: { 18 | color: /(background|color)$/i, 19 | date: /Date$/, 20 | }, 21 | }, 22 | }; 23 | 24 | export const decorators = [ 25 | (Story) => ( 26 | <> 27 | 28 | 29 | ), 30 | ]; 31 | 32 | addDecorator(EmotionThemeProvider); 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | FE 2 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | // reactStrictMode: true, 4 | async rewrites() { 5 | return [ 6 | { 7 | source: '/user', 8 | destination: 'https://ak-47.shop/api/v1/user', 9 | }, 10 | ]; 11 | }, 12 | swcMinify: true, 13 | webpack(config) { 14 | config.module.rules.push({ 15 | test: /\.svg$/i, 16 | issuer: /\.[jt]sx?$/, 17 | use: ['@svgr/webpack'], 18 | }); 19 | 20 | return config; 21 | }, 22 | env: { 23 | BASE_URL: process.env.BASE_URL, 24 | EMAILJS_SERVICE_ID: process.env.EMAILJS_SERVICE_ID, 25 | EMAILJS_TEMPLATE_ID: process.env.EMAILJS_TEMPLATE_ID, 26 | EMAILJS_PUBLIC_KEY: process.env.EMAILJS_PUBLIC_KEY, 27 | GTM_ID: process.env.GTM_ID, 28 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, 29 | }, 30 | images: { 31 | // lh3.googleusercontent.com: google 기본 프로필 이미지 32 | // kr.object.ncloudstorage.com: cloud 이미지 33 | domains: ['lh3.googleusercontent.com', 'kr.object.ncloudstorage.com'], 34 | formats: ['image/avif', 'image/webp'], 35 | }, 36 | }; 37 | 38 | module.exports = nextConfig; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "storybook": "start-storybook -p 6006 -s ./public", 11 | "build-storybook": "build-storybook" 12 | }, 13 | "dependencies": { 14 | "@emotion/react": "^11.10.4", 15 | "@emotion/styled": "^11.10.4", 16 | "@tanstack/react-query": "^4.14.5", 17 | "@tanstack/react-query-devtools": "^4.14.5", 18 | "axios": "^0.27.2", 19 | "date-fns": "^2.29.3", 20 | "emailjs-com": "^3.2.0", 21 | "heic2any": "^0.0.3", 22 | "next": "12.3.1", 23 | "react": "18.2.0", 24 | "react-dom": "18.2.0", 25 | "react-error-boundary": "^3.1.4" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.20.2", 29 | "@storybook/addon-actions": "^6.5.13", 30 | "@storybook/addon-essentials": "^6.5.13", 31 | "@storybook/addon-interactions": "^6.5.13", 32 | "@storybook/addon-links": "^6.5.13", 33 | "@storybook/builder-webpack5": "^6.5.13", 34 | "@storybook/manager-webpack5": "^6.5.13", 35 | "@storybook/react": "^6.5.13", 36 | "@storybook/testing-library": "^0.0.13", 37 | "@svgr/webpack": "^6.3.1", 38 | "@types/node": "18.7.21", 39 | "@types/react": "18.0.21", 40 | "@types/react-dom": "18.0.6", 41 | "babel-loader": "^8.3.0", 42 | "eslint": "^8.24.0", 43 | "eslint-config-next": "12.3.1", 44 | "eslint-plugin-storybook": "^0.6.7", 45 | "msw": "0.29.0", 46 | "typescript": "4.8.3" 47 | }, 48 | "msw": { 49 | "workerDirectory": "public" 50 | }, 51 | "resolutions": { 52 | "enhanced-resolve": "5.10.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/assets/audio/mixkit-positive-notification-951.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/public/assets/audio/mixkit-positive-notification-951.wav -------------------------------------------------------------------------------- /public/assets/developerwiki.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/public/assets/developerwiki.png -------------------------------------------------------------------------------- /public/assets/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/public/assets/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/assets/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/public/assets/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/assets/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/public/assets/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /public/assets/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/public/assets/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/assets/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/public/assets/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/assets/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /public/assets/profile-default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/public/assets/profile-default.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/fonts/Pretendard-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/src/assets/fonts/Pretendard-Black.woff -------------------------------------------------------------------------------- /src/assets/fonts/Pretendard-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/src/assets/fonts/Pretendard-Bold.woff -------------------------------------------------------------------------------- /src/assets/fonts/Pretendard-ExtraBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/src/assets/fonts/Pretendard-ExtraBold.woff -------------------------------------------------------------------------------- /src/assets/fonts/Pretendard-ExtraLight.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/src/assets/fonts/Pretendard-ExtraLight.woff -------------------------------------------------------------------------------- /src/assets/fonts/Pretendard-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/src/assets/fonts/Pretendard-Light.woff -------------------------------------------------------------------------------- /src/assets/fonts/Pretendard-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/src/assets/fonts/Pretendard-Medium.woff -------------------------------------------------------------------------------- /src/assets/fonts/Pretendard-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/src/assets/fonts/Pretendard-Regular.woff -------------------------------------------------------------------------------- /src/assets/fonts/Pretendard-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/src/assets/fonts/Pretendard-SemiBold.woff -------------------------------------------------------------------------------- /src/assets/fonts/Pretendard-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Wikis/FE/54e4d1725555112af7d7a0bcafe6136a417f737a/src/assets/fonts/Pretendard-Thin.woff -------------------------------------------------------------------------------- /src/assets/fonts/font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Pretendard'; 3 | font-weight: 400; 4 | font-style: normal; 5 | font-display: swap; 6 | src: url('./Pretendard-Regular.woff') format('woff'); 7 | } 8 | 9 | @font-face { 10 | font-family: 'Pretendard'; 11 | font-weight: 500; 12 | font-style: normal; 13 | font-display: swap; 14 | src: url('./Pretendard-Medium.woff') format('woff'); 15 | } 16 | 17 | @font-face { 18 | font-family: 'Pretendard'; 19 | font-weight: 600; 20 | font-style: normal; 21 | font-display: swap; 22 | src: url('./Pretendard-SemiBold.woff') format('woff'); 23 | } 24 | 25 | @font-face { 26 | font-family: 'Pretendard'; 27 | font-weight: 700; 28 | font-style: normal; 29 | font-display: swap; 30 | src: url('./Pretendard-Bold.woff') format('woff'); 31 | } 32 | -------------------------------------------------------------------------------- /src/assets/icons/alert-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/comment.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/eye.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | -------------------------------------------------------------------------------- /src/assets/icons/google.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/icons/hamburger.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | 11 | 13 | 15 | 18 | 21 | 24 | -------------------------------------------------------------------------------- /src/assets/icons/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/microphone.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/pencil.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/icons/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/stop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/icons/upload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/base/BackgroundDim/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | const BackgroundDim = styled.div` 4 | position: fixed; 5 | top: 0; 6 | width: 100%; 7 | height: 100%; 8 | background-color: rgba(0, 0, 0, 0.5); 9 | z-index: 1000; 10 | margin: 0 auto; 11 | left: 0; 12 | right: 0; 13 | `; 14 | 15 | export default BackgroundDim; 16 | -------------------------------------------------------------------------------- /src/components/base/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ButtonHTMLAttributes, CSSProperties, MouseEvent, ReactNode } from 'react'; 3 | import Spinner from '~/components/common/Spinner'; 4 | import { buttonSizes, buttonStyle } from './types'; 5 | 6 | interface ButtonProps extends ButtonHTMLAttributes { 7 | /** 8 | * Button의 내용이 들어갑니다. 9 | */ 10 | children?: ReactNode; 11 | /** 12 | * Button의 ColorType을 설정합니다. 13 | */ 14 | variant?: keyof typeof buttonStyle; 15 | /** 16 | * Button의 size를 설정합니다. 17 | */ 18 | size?: keyof typeof buttonSizes; 19 | /** 20 | * true일 경우 좌우 공간을 모두 차지합니다. 21 | */ 22 | fullWidth?: boolean; 23 | /** 24 | * true일 경우 button이 disabled 됩니다. 25 | */ 26 | disabled?: boolean; 27 | /** 28 | * true일 경우 로딩 스피너가 나타납니다. 29 | */ 30 | loading?: boolean; 31 | /** 32 | * Button의 앞부분에 요소를 추가합니다. 33 | */ 34 | startIcon?: ReactNode; 35 | /** 36 | * Button의 뒷부분에 요소를 추가합니다. 37 | */ 38 | endIcon?: ReactNode; 39 | } 40 | 41 | const Button = ({ 42 | children, 43 | variant = 'black', 44 | size = 'md', 45 | fullWidth, 46 | disabled, 47 | loading, 48 | startIcon, 49 | endIcon, 50 | ...props 51 | }: ButtonProps) => { 52 | return ( 53 | 61 | {startIcon && {startIcon}} 62 | {loading && } 63 | {children} 64 | {endIcon && {endIcon}} 65 | 66 | ); 67 | }; 68 | 69 | export default Button; 70 | 71 | const StyledButton = styled.button` 72 | display: inline-flex; 73 | align-items: center; 74 | justify-content: center; 75 | position: relative; 76 | white-space: nowrap; 77 | margin: 0; 78 | 79 | ${({ variant }) => variant && buttonStyle[variant]}; 80 | ${({ size }) => size && buttonSizes[size]}; 81 | ${({ fullWidth }) => fullWidth && `width: 100%;`} 82 | ${({ isLoading }) => isLoading && `color: transparent;`} 83 | 84 | &:disabled { 85 | opacity: 0.7; 86 | cursor: not-allowed; 87 | } 88 | `; 89 | 90 | const StartIcon = styled.span` 91 | margin-left: -3px; 92 | margin-right: 6px; 93 | `; 94 | 95 | const EndIcon = styled.span` 96 | margin-right: -3px; 97 | margin-left: 6px; 98 | `; 99 | -------------------------------------------------------------------------------- /src/components/base/Button/types.ts: -------------------------------------------------------------------------------- 1 | import { theme } from '~/types/theme'; 2 | 3 | export const buttonStyle = { 4 | black: ` 5 | background-color: ${theme.colors.gray700}; 6 | color: ${theme.colors.white}; 7 | border-radius: 4px; 8 | `, 9 | borderGray: ` 10 | border: 1px solid ${theme.colors.gray300}; 11 | background-color: ${theme.colors.white}; 12 | color: ${theme.colors.gray600}; 13 | border-radius: 4px; 14 | 15 | &:not(:disabled):hover { 16 | background-color: ${theme.colors.gray100}; 17 | } 18 | `, 19 | gray: ` 20 | background-color: ${theme.colors.gray500}; 21 | color: ${theme.colors.white}; 22 | border-radius: 4px; 23 | 24 | &:not(:disabled):hover { 25 | background-color: ${theme.colors.gray600}; 26 | } 27 | `, 28 | red: ` 29 | background-color: ${theme.colors.red}; 30 | color: ${theme.colors.white}; 31 | border-radius: 4px; 32 | `, 33 | }; 34 | 35 | export const buttonSizes = { 36 | xs: ` 37 | font-size: 12px; 38 | padding: 5px 11px; 39 | line-height: 20px; 40 | font-weight: 500; 41 | `, 42 | sm: ` 43 | ${theme.fontStyle.body2_500} 44 | padding: 8px 12px; 45 | `, 46 | md: ` 47 | ${theme.fontStyle.body2_500} 48 | padding: 12px 26px; 49 | `, 50 | lg: ` 51 | ${theme.fontStyle.body1_500} 52 | padding: 12px 16px; 53 | `, 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/base/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ReactNode } from 'react'; 3 | 4 | interface CheckboxProps { 5 | id: string; 6 | name: string; 7 | required?: boolean; 8 | checked: boolean; 9 | onChange: () => void; 10 | /** 11 | * Checkbox의 내용이 들어갑니다. 12 | */ 13 | children: ReactNode; 14 | } 15 | const Checkbox = ({ 16 | id, 17 | name, 18 | required = false, 19 | checked, 20 | onChange, 21 | children, 22 | ...props 23 | }: CheckboxProps) => { 24 | return ( 25 | 26 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default Checkbox; 40 | 41 | const Container = styled.div` 42 | display: flex; 43 | justify-content: center; 44 | align-items: center; 45 | 46 | input { 47 | margin: 0; 48 | width: 16px; 49 | height: 16px; 50 | accent-color: ${({ theme }) => theme.colors.red}; 51 | } 52 | 53 | label { 54 | margin-left: 6px; 55 | ${({ theme }) => theme.fontStyle.body2} 56 | color: ${({ theme }) => theme.colors.gray600}; 57 | } 58 | `; 59 | -------------------------------------------------------------------------------- /src/components/base/ErrorBoundary/ApiErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryErrorResetBoundary } from '@tanstack/react-query'; 2 | import React from 'react'; 3 | import { ReactNode, useCallback } from 'react'; 4 | import { ErrorBoundary, ErrorBoundaryPropsWithComponent } from 'react-error-boundary'; 5 | import { useRouter } from 'next/router'; 6 | 7 | interface ApiErrorBoundaryProps extends ErrorBoundaryPropsWithComponent { 8 | children: ReactNode; 9 | } 10 | 11 | const ApiErrorBoundary = ({ 12 | FallbackComponent, 13 | children, 14 | onReset, 15 | resetKeys = [], 16 | ...props 17 | }: ApiErrorBoundaryProps) => { 18 | const { reset } = useQueryErrorResetBoundary(); 19 | const { pathname } = useRouter(); 20 | 21 | const handleReset = useCallback(() => { 22 | reset(); 23 | onReset && onReset(); 24 | }, [onReset]); 25 | 26 | return ( 27 | 33 | {children} 34 | 35 | ); 36 | }; 37 | 38 | export default ApiErrorBoundary; 39 | -------------------------------------------------------------------------------- /src/components/base/ErrorBoundary/AuthFallback.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { FallbackProps } from 'react-error-boundary'; 3 | import { useUser } from '~/react-query/hooks/useUser'; 4 | import ErrorComponent from '../FallbackComponent/ErrorComponent'; 5 | 6 | const AuthFallback = ({ error, resetErrorBoundary }: FallbackProps) => { 7 | if (!axios.isAxiosError(error)) { 8 | throw error; 9 | } 10 | 11 | switch (error.response?.status) { 12 | case 401: 13 | return ; 14 | } 15 | 16 | return <>; 17 | }; 18 | 19 | export default AuthFallback; 20 | -------------------------------------------------------------------------------- /src/components/base/FallbackComponent/ApiFallback.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { FallbackProps } from 'react-error-boundary'; 3 | import PageContainer from '~/components/common/PageContainer'; 4 | import Button from '../Button'; 5 | import Link from '../Link'; 6 | 7 | const ApiFallback = ({ error, resetErrorBoundary }: FallbackProps) => { 8 | if (!axios.isAxiosError(error)) { 9 | throw error; 10 | } 11 | 12 | switch (error.response?.status) { 13 | case 401: 14 | return ( 15 | 16 |
17 |

18 | 로그인이 필요한 서비스입니다. 19 |
20 | 로그인 후 이용해주세요. 21 |

22 | 23 | 로그인 24 | 25 |
26 |
27 | ); 28 | case 404: 29 | // 404 handler 30 | case 500: 31 | return ( 32 | 33 |
34 |

시스템 점검 중입니다.

35 |

36 | 보다 안정적인 서비스 이용을 위해 현재 developerwiki를 점검 중입니다. 37 |
38 | 서비스 이용에 불편을 드려 죄송합니다. 39 |

40 |
41 |
42 | ); 43 | } 44 | 45 | return ( 46 | 47 |
48 | 일시적인 오류로 불러오지 못했습니다. 49 | 50 |
51 |
52 | ); 53 | }; 54 | 55 | export default ApiFallback; 56 | -------------------------------------------------------------------------------- /src/components/base/FallbackComponent/ErrorComponent.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useEffect } from 'react'; 3 | import { FallbackProps } from 'react-error-boundary'; 4 | import { useUser } from '~/react-query/hooks/useUser'; 5 | 6 | const ErrorComponent = ({ error, resetErrorBoundary }: FallbackProps) => { 7 | useEffect(() => { 8 | if (!axios.isAxiosError(error)) { 9 | throw error; 10 | } 11 | 12 | window.location.href = '/login'; 13 | alert('로그인이 필요한 서비스입니다.'); 14 | }, []); 15 | 16 | return <>; 17 | }; 18 | 19 | export default ErrorComponent; 20 | -------------------------------------------------------------------------------- /src/components/base/Favicon/index.tsx: -------------------------------------------------------------------------------- 1 | const Favicon = () => { 2 | return ( 3 | <> 4 | 5 | 6 | 7 | 8 | 9 | ); 10 | }; 11 | 12 | export default Favicon; 13 | -------------------------------------------------------------------------------- /src/components/base/Icon/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, HTMLAttributes, MouseEvent } from 'react'; 2 | import Icon, { IconProps } from '.'; 3 | 4 | interface IconButtonProps extends IconProps, Omit, 'color'> { 5 | onClick?: (e: MouseEvent) => void; 6 | type?: 'submit' | 'reset' | 'button' | undefined; 7 | style?: CSSProperties; 8 | } 9 | 10 | const IconButton = ({ 11 | name, 12 | size = '20', 13 | color = 'white', 14 | onClick, 15 | type = 'button', 16 | style, 17 | fill, 18 | stroke, 19 | block, 20 | width, 21 | height, 22 | ...props 23 | }: IconButtonProps) => { 24 | const iconProps = { name, size, color, fill, stroke, block, width, height }; 25 | 26 | return ( 27 | 30 | ); 31 | }; 32 | 33 | export default IconButton; 34 | -------------------------------------------------------------------------------- /src/components/base/Icon/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { theme, ThemeColors } from '~/types/theme'; 3 | import IconButton from './IconButton'; 4 | import * as icons from './svg'; 5 | export interface IconProps { 6 | name: keyof typeof icons; 7 | size?: string; 8 | color?: ThemeColors; 9 | stroke?: ThemeColors; 10 | block?: boolean; 11 | width?: string; 12 | height?: string; 13 | fill?: ThemeColors; 14 | } 15 | 16 | const Icon = ({ 17 | name, 18 | size = '20', 19 | color = 'white', 20 | block = true, 21 | fill, 22 | stroke, 23 | width, 24 | height, 25 | ...props 26 | }: IconProps) => { 27 | const SvgIcon = icons[name]; 28 | const colorStyle = 29 | fill && stroke 30 | ? { fill: theme.colors[fill], stroke: theme.colors[stroke] } 31 | : { color: theme.colors[color] }; 32 | 33 | const StyledSvgIcon = styled(SvgIcon)<{ block: boolean }>` 34 | display: ${block ? 'block' : ''}; 35 | width: ${width ? width : size}px; 36 | height: ${height ? height : size}px; 37 | ${colorStyle} 38 | `; 39 | 40 | return ; 41 | }; 42 | 43 | Icon.Button = IconButton; 44 | 45 | export default Icon; 46 | -------------------------------------------------------------------------------- /src/components/base/Icon/svg.ts: -------------------------------------------------------------------------------- 1 | export { default as Close } from '~/assets/icons/close.svg'; 2 | export { default as Comment } from '~/assets/icons/comment.svg'; 3 | export { default as Microphone } from '~/assets/icons/microphone.svg'; 4 | export { default as Play } from '~/assets/icons/play.svg'; 5 | export { default as Refresh } from '~/assets/icons/refresh.svg'; 6 | export { default as Stop } from '~/assets/icons/stop.svg'; 7 | export { default as Pause } from '~/assets/icons/pause.svg'; 8 | export { default as AlertCircle } from '~/assets/icons/alert-circle.svg'; 9 | export { default as ArrowDown } from '~/assets/icons/arrow-down.svg'; 10 | export { default as ArrowUp } from '~/assets/icons/arrow-up.svg'; 11 | export { default as ArrowLeft } from '~/assets/icons/arrow-left.svg'; 12 | export { default as ArrowRight } from '~/assets/icons/arrow-right.svg'; 13 | export { default as Eye } from '~/assets/icons/eye.svg'; 14 | export { default as Pencil } from '~/assets/icons/pencil.svg'; 15 | export { default as Google } from '~/assets/icons/google.svg'; 16 | export { default as LogoIcon } from '~/assets/icons/logo-icon.svg'; 17 | export { default as Menu } from '~/assets/icons/menu.svg'; 18 | export { default as Hamburger } from '~/assets/icons/hamburger.svg'; 19 | export { default as Upload } from '~/assets/icons/upload.svg'; 20 | export { default as Star } from '~/assets/icons/star.svg'; 21 | 22 | -------------------------------------------------------------------------------- /src/components/base/Input/AddInput.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ChangeEvent, FormEvent, useEffect, useRef, useState } from 'react'; 3 | import Input from '.'; 4 | import Button from '../Button'; 5 | 6 | interface AddInputProps { 7 | type: string; 8 | name: string; 9 | id: string; 10 | buttonText: string; 11 | onSubmit: (value: string) => void; 12 | } 13 | 14 | const AddInputForm = ({ buttonText, onSubmit, ...props }: AddInputProps) => { 15 | const [text, setText] = useState(''); 16 | 17 | const handleSubmit = (e: FormEvent) => { 18 | e.preventDefault(); 19 | if (text.trim().length < 1) { 20 | alert('내용을 입력해 주세요.'); 21 | return; 22 | } 23 | 24 | onSubmit(text); 25 | setText(''); 26 | }; 27 | 28 | return ( 29 | 30 | ) => { 33 | setText(e.target.value); 34 | }} 35 | value={text} 36 | /> 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default AddInputForm; 43 | 44 | const Container = styled.form` 45 | display: flex; 46 | input { 47 | width: 100%; 48 | } 49 | 50 | button { 51 | margin-left: 12px; 52 | } 53 | `; 54 | -------------------------------------------------------------------------------- /src/components/base/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { forwardRef, InputHTMLAttributes, Ref } from 'react'; 3 | 4 | export const inputSize = { 5 | sm: ` 6 | padding: 5px 8px; 7 | height: 32px; 8 | `, 9 | md: ` 10 | padding: 8px 10px; 11 | `, 12 | }; 13 | 14 | type SizeType = 'sm' | 'md'; 15 | interface InputProps extends Omit, 'size'> { 16 | /** 17 | * Input의 사이즈를 설정합니다. 18 | */ 19 | size?: SizeType; 20 | } 21 | 22 | const Input = forwardRef(({ size = 'md', ...props }: InputProps, ref: Ref) => { 23 | return ; 24 | }); 25 | 26 | export default Input; 27 | 28 | const StyledInput = styled.input<{ sizeType: SizeType }>` 29 | background-color: ${({ theme }) => theme.colors.white}; 30 | border: 1px solid ${({ theme }) => theme.colors.gray300}; 31 | border-radius: 4px; 32 | width: 100%; 33 | box-sizing: border-box; 34 | 35 | ${({ sizeType }) => sizeType && inputSize[sizeType]}; 36 | ${({ theme }) => theme.fontStyle.body2}; 37 | 38 | &::placeholder { 39 | color: ${({ theme }) => theme.colors.gray500}; 40 | } 41 | 42 | &:focus { 43 | outline-color: ${({ theme }) => theme.colors.gray800}; 44 | outline-width: 1px; 45 | outline-style: solid; 46 | } 47 | 48 | &:disabled { 49 | background-color: ${({ theme }) => theme.colors.gray100}; 50 | } 51 | `; 52 | -------------------------------------------------------------------------------- /src/components/base/Label/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | const Label = styled.label` 4 | display: block; 5 | ${({ theme }) => theme.fontStyle.body1}; 6 | font-weight: 700; 7 | color: ${({ theme }) => theme.colors.gray800}; 8 | margin-bottom: 12px; 9 | `; 10 | 11 | export default Label; 12 | -------------------------------------------------------------------------------- /src/components/base/Link.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import NextLink, { LinkProps as NextLinkProps } from 'next/link'; 3 | import { ReactNode } from 'react'; 4 | import { buttonSizes, buttonStyle } from './Button/types'; 5 | 6 | type LinkTypes = { variant?: keyof typeof buttonStyle; size?: keyof typeof buttonSizes }; 7 | type LinkProps = Omit & { 8 | children: ReactNode; 9 | className?: string; 10 | } & LinkTypes; 11 | 12 | const Link = ({ 13 | variant, 14 | size, 15 | href, 16 | prefetch, 17 | replace, 18 | scroll, 19 | shallow, 20 | locale, 21 | children, 22 | onClick, 23 | className, 24 | ...props 25 | }: LinkProps) => { 26 | return ( 27 | 37 | 38 | {children} 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default Link; 45 | 46 | const StyledA = styled.a` 47 | ${({ variant }) => variant && buttonStyle[variant]}; 48 | ${({ size, variant }) => (size || variant) && `display: inline-block;`}; 49 | ${({ size }) => size && buttonSizes[size]}; 50 | `; 51 | -------------------------------------------------------------------------------- /src/components/base/PageTitle/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ReactNode } from 'react'; 3 | 4 | interface PageTitleProps { 5 | children: ReactNode; 6 | align?: 'center' | 'left' | 'right'; 7 | } 8 | 9 | const PageTitle = ({ children, align = 'center', ...props }: PageTitleProps) => { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | }; 16 | 17 | export default PageTitle; 18 | 19 | const StyleH = styled.h3` 20 | ${({ theme }) => theme.fontStyle.headline1} 21 | text-align: ${({ align }) => align}; 22 | color: ${({ theme }) => theme.colors.gray800}; 23 | `; 24 | -------------------------------------------------------------------------------- /src/components/base/SSRSafeSuspense/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, SuspenseProps } from 'react'; 2 | import useMounted from '~/hooks/useMounted'; 3 | 4 | const SSRSafeSuspense = (props: SuspenseProps) => { 5 | const isMounted = useMounted(); 6 | 7 | if (isMounted) { 8 | return ; 9 | } 10 | return <>{props.fallback}; 11 | }; 12 | 13 | export default SSRSafeSuspense; 14 | -------------------------------------------------------------------------------- /src/components/base/Select/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ChangeEvent, SelectHTMLAttributes } from 'react'; 3 | 4 | interface SelectProps extends SelectHTMLAttributes { 5 | /** 6 | * Select의 옵션 데이터를 설정합니다. 7 | */ 8 | list: { value: string; text: string }[]; 9 | name?: string; 10 | onChange: (e: ChangeEvent) => void; 11 | selected?: string; 12 | /** 13 | * true일 경우 기본 옵션을 제거합니다. 14 | */ 15 | withoutDefault?: boolean; 16 | /** 17 | * 기본 옵션의 내용을 설정합니다. 18 | */ 19 | defaultText?: string; 20 | } 21 | const Select = ({ 22 | list, 23 | name, 24 | onChange, 25 | selected, 26 | withoutDefault = false, 27 | defaultText = '선택해 주세요.', 28 | ...props 29 | }: SelectProps) => { 30 | return ( 31 | 32 | {!withoutDefault && } 33 | {list.map((category, index) => ( 34 | 37 | ))} 38 | 39 | ); 40 | }; 41 | 42 | export default Select; 43 | 44 | const StyledSelect = styled.select` 45 | width: 100%; 46 | 47 | padding: 8px 10px; 48 | border-radius: 4px; 49 | border: 1px solid ${({ theme }) => theme.colors.gray300}; 50 | ${({ theme }) => theme.fontStyle.body2}; 51 | `; 52 | -------------------------------------------------------------------------------- /src/components/common/AddForm/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ChangeEvent, FormEvent, useState } from 'react'; 3 | import Button from '~/components/base/Button'; 4 | import Input from '~/components/base/Input'; 5 | 6 | interface AddFormProps { 7 | name: string; 8 | id: string; 9 | buttonText: string; 10 | placeholder?: string; 11 | onSubmit: (value: string) => void; 12 | defaultValue?: string; 13 | reset?: boolean; 14 | } 15 | 16 | const AddForm = ({ 17 | buttonText, 18 | onSubmit, 19 | placeholder, 20 | defaultValue, 21 | reset = true, 22 | ...props 23 | }: AddFormProps) => { 24 | const [text, setText] = useState(defaultValue ? defaultValue : ''); 25 | 26 | const handleSubmit = (e: FormEvent) => { 27 | e.preventDefault(); 28 | 29 | const validText = text.trim(); 30 | setText(validText); 31 | onSubmit(validText); 32 | 33 | if (reset) { 34 | setText(''); 35 | } 36 | }; 37 | 38 | return ( 39 | 40 | { 43 | setText(e.target.value); 44 | }} 45 | value={text} 46 | placeholder={placeholder} 47 | {...props} 48 | /> 49 | 52 | 53 | ); 54 | }; 55 | 56 | export default AddForm; 57 | 58 | const Container = styled.form` 59 | display: flex; 60 | input { 61 | width: 100%; 62 | } 63 | 64 | button { 65 | margin-left: 12px; 66 | width: 80px; 67 | } 68 | `; 69 | -------------------------------------------------------------------------------- /src/components/common/Article/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { HTMLAttributes } from 'react'; 3 | import { mediaQuery } from '~/utils/helper/mediaQuery'; 4 | 5 | interface ArticleProps extends HTMLAttributes { 6 | full?: boolean; 7 | } 8 | 9 | const Article = ({ full = false, ...props }: ArticleProps) => { 10 | return ; 11 | }; 12 | 13 | export default Article; 14 | 15 | const StyledArticle = styled.article<{ full: boolean }>` 16 | width: 100%; 17 | max-width: ${({ full }) => (full ? '100%' : '440px')}; 18 | margin: 0 auto; 19 | margin-top: 50px; 20 | padding: ${({ full }) => (full ? '30px 0 50px' : '42px 28px 45px')}; 21 | border: 1px solid ${({ theme }) => theme.colors.gray300}; 22 | border-radius: 4px; 23 | background-color: ${({ theme }) => theme.colors.white}; 24 | 25 | ${mediaQuery(440)} { 26 | border: 0; 27 | width: 100%; 28 | margin-top: 0; 29 | padding: ${({ full }) => (full ? '30px 16px 50px' : '42px 16px 45px')}; 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /src/components/common/Avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Image from 'next/image'; 3 | import { MouseEvent, SyntheticEvent, useEffect, useState } from 'react'; 4 | import { AvatarSize, AvatarSizes } from './types'; 5 | 6 | interface AvatarProps { 7 | /** 8 | * 이미지의 경로를 설정합니다. 9 | */ 10 | src?: string; 11 | /** 12 | * 이미지의 사이즈를 설정합니다. 13 | */ 14 | size?: AvatarSizes; 15 | /** 16 | * 이미지를 대체할 텍스트를 설정합니다. 17 | */ 18 | alt?: string; 19 | onClick?: (e: MouseEvent) => void; 20 | } 21 | 22 | const defaultImage = '/assets/profile-default.jpg'; 23 | 24 | const Avatar = ({ src, size = 'md', alt = '프로필이미지', ...props }: AvatarProps) => { 25 | const imageUrl = src || defaultImage; 26 | 27 | const handleImageError = (e: SyntheticEvent) => { 28 | e.currentTarget.src = defaultImage; 29 | }; 30 | 31 | return ( 32 | 33 | {alt} 41 | 42 | ); 43 | }; 44 | 45 | export default Avatar; 46 | 47 | const Container = styled.div` 48 | flex-shrink: 0; 49 | width: ${({ size }) => size && AvatarSize[size]}; 50 | height: ${({ size }) => size && AvatarSize[size]}; 51 | position: relative; 52 | border-radius: 50%; 53 | overflow: hidden; 54 | border: 1px solid ${({ theme }) => theme.colors.gray300}; 55 | `; 56 | -------------------------------------------------------------------------------- /src/components/common/Avatar/types.ts: -------------------------------------------------------------------------------- 1 | export const AvatarSize = { 2 | sm: '28px', 3 | md: '46px', 4 | lg: '150px', 5 | }; 6 | 7 | export type AvatarSizes = keyof typeof AvatarSize; 8 | -------------------------------------------------------------------------------- /src/components/common/ClientPortal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, useState, ReactNode } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | interface ClientPortalProps { 5 | children: ReactNode; 6 | elementId: string; 7 | } 8 | 9 | const ClientPortal = ({ children, elementId }: ClientPortalProps) => { 10 | const ref = useRef(); 11 | const [mounted, setMounted] = useState(false); 12 | 13 | useEffect(() => { 14 | ref.current = document.getElementById(elementId); 15 | setMounted(true); 16 | }, [elementId]); 17 | 18 | return mounted && ref.current ? createPortal(children, ref.current) : null; 19 | }; 20 | 21 | export default ClientPortal; 22 | -------------------------------------------------------------------------------- /src/components/common/CloseButton/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Icon from '~/components/base/Icon'; 3 | 4 | interface CloseButtonProps { 5 | onClick: () => void; 6 | } 7 | 8 | const CloseButton = ({ onClick, ...props }: CloseButtonProps) => { 9 | return ; 10 | }; 11 | 12 | export default CloseButton; 13 | 14 | const StyledButton = styled(Icon.Button)` 15 | padding: 6px; 16 | border-radius: 4px; 17 | &:hover { 18 | background-color: ${({ theme }) => theme.colors.gray200}; 19 | } 20 | `; 21 | -------------------------------------------------------------------------------- /src/components/common/Comment/CommentList.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { 3 | useCheckPassword, 4 | useDeleteComment, 5 | useEditComment, 6 | useGetComment, 7 | } from '~/react-query/hooks/useComment'; 8 | import { useUser } from '~/react-query/hooks/useUser'; 9 | import CommentItem from './CommentItem'; 10 | import TotalCount from './TotalCount'; 11 | 12 | const CommentList = ({ questionId }: { questionId: number }) => { 13 | const { comments } = useGetComment(questionId); 14 | const deleteComment = useDeleteComment(questionId); 15 | const editComment = useEditComment(questionId); 16 | const checkPassword = useCheckPassword(questionId); 17 | const { user } = useUser(); 18 | 19 | return ( 20 | <> 21 | 22 | 23 | {comments.map((comment) => ( 24 | 32 | ))} 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default CommentList; 39 | 40 | const StyledUl = styled.ul` 41 | border-top: 2px solid ${({ theme }) => theme.colors.gray800}; 42 | margin-top: 18px; 43 | `; 44 | -------------------------------------------------------------------------------- /src/components/common/Comment/CommentTextArea.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ChangeEvent, forwardRef, Ref } from 'react'; 3 | 4 | interface CommentTextAreaProps { 5 | onChange: (e: ChangeEvent) => void; 6 | value: string; 7 | } 8 | const CommentTextArea = forwardRef( 9 | ({ onChange, value }: CommentTextAreaProps, ref?: Ref) => { 10 | return ( 11 | 18 | ); 19 | }, 20 | ); 21 | 22 | export default CommentTextArea; 23 | 24 | const StyledTextArea = styled.textarea` 25 | background-color: white; 26 | border: 1px solid ${({ theme }) => theme.colors.gray300}; 27 | border-radius: 4px; 28 | ${({ theme }) => theme.fontStyle.body2}; 29 | 30 | height: 74px; 31 | padding: 8px; 32 | box-sizing: border-box; 33 | resize: none; 34 | flex-grow: 1; 35 | `; 36 | -------------------------------------------------------------------------------- /src/components/common/Comment/EditCommentForm.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ChangeEvent, FormEvent, useContext, useRef, useState } from 'react'; 3 | import Button from '~/components/base/Button'; 4 | import { EditComment } from '~/react-query/hooks/useComment'; 5 | import { IUser } from '~/types/user'; 6 | import { Nullable } from '~/types/utilityType'; 7 | import { SUBMIT_CHECK } from '~/utils/helper/validation'; 8 | import CommentTextArea from './CommentTextArea'; 9 | import { CommentContext } from './context'; 10 | 11 | interface EditCommentFormProps { 12 | defaultValue: string; 13 | commentId: number; 14 | editComment: EditComment; 15 | user: Nullable; 16 | } 17 | 18 | const EditCommentForm = ({ user, defaultValue, commentId, editComment }: EditCommentFormProps) => { 19 | const [content, setContent] = useState(defaultValue); 20 | const contentRef = useRef>(null); 21 | 22 | const { passwordState, closeEditor } = useContext(CommentContext); 23 | 24 | const handleChange = (e: ChangeEvent) => { 25 | setContent(e.target.value); 26 | }; 27 | 28 | const handleSubmit = async (e: FormEvent) => { 29 | e.preventDefault(); 30 | 31 | if (SUBMIT_CHECK.comment.isValid(content)) { 32 | alert(SUBMIT_CHECK.comment.message); 33 | contentRef.current?.focus(); 34 | return; 35 | } 36 | 37 | if (user) { 38 | await editComment.mutateAsync({ commentId, payload: { content } }); 39 | } else { 40 | await editComment.mutateAsync({ 41 | commentId, 42 | payload: { password: passwordState.password, content }, 43 | }); 44 | } 45 | 46 | closeEditor(); 47 | }; 48 | 49 | return ( 50 | 51 | 52 | 53 | 56 | 59 | 60 | 61 | ); 62 | }; 63 | 64 | export default EditCommentForm; 65 | 66 | const Container = styled.form` 67 | display: flex; 68 | flex-direction: column; 69 | padding: 0 16px; 70 | `; 71 | 72 | const Buttons = styled.div` 73 | margin-top: 10px; 74 | display: flex; 75 | justify-content: flex-end; 76 | 77 | button { 78 | width: 80px; 79 | margin-left: 10px; 80 | } 81 | `; 82 | -------------------------------------------------------------------------------- /src/components/common/Comment/PasswordConfirm.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ChangeEvent, FormEvent, useContext, useState } from 'react'; 3 | import Button from '~/components/base/Button'; 4 | import Icon from '~/components/base/Icon'; 5 | import Input from '~/components/base/Input'; 6 | import { CheckPassword, DeleteComment } from '~/react-query/hooks/useComment'; 7 | import { CommentContext } from './context'; 8 | 9 | interface PasswordConfirmProps { 10 | commentId: number; 11 | deleteComment: DeleteComment; 12 | checkPassword: CheckPassword; 13 | } 14 | 15 | const PasswordConfirm = ({ commentId, deleteComment, checkPassword }: PasswordConfirmProps) => { 16 | const [password, setPassword] = useState(''); 17 | 18 | const { passwordState, updatePasswordState, openEditor, closePassword } = 19 | useContext(CommentContext); 20 | 21 | const handleChange = (e: ChangeEvent) => { 22 | setPassword(e.target.value); 23 | }; 24 | 25 | const handleSubmit = async (e: FormEvent) => { 26 | e.preventDefault(); 27 | if (passwordState.action === 'delete') { 28 | deleteComment.mutateAsync({ commentId, password }); 29 | closePassword(); 30 | return; 31 | } 32 | 33 | if (passwordState.action === 'edit') { 34 | const isCorrectPassword = await checkPassword.mutateAsync({ commentId, password }); 35 | 36 | if (isCorrectPassword) { 37 | openEditor(commentId); 38 | updatePasswordState({ commentId: null, action: 'edit', password }); 39 | } else { 40 | alert('비밀번호가 일치하지 않습니다.'); 41 | } 42 | } 43 | }; 44 | 45 | const handleClose = () => { 46 | closePassword(); 47 | }; 48 | 49 | return ( 50 | 51 | 52 | 58 | 59 | 확인 60 | 61 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | export default PasswordConfirm; 68 | 69 | const Container = styled.div` 70 | position: absolute; 71 | background-color: white; 72 | right: 0; 73 | top: -2px; 74 | padding: 10px 15px 10px 10px; 75 | border: 1px solid ${({ theme }) => theme.colors.gray300}; 76 | border-radius: 4px; 77 | ${({ theme }) => theme.fontStyle.body2}; 78 | box-shadow: 0px 0px 4px 1px rgb(0 0 0 / 5%); 79 | `; 80 | 81 | const PasswordForm = styled.form` 82 | display: flex; 83 | align-items: center; 84 | `; 85 | 86 | const SubmitButton = styled(Button)` 87 | margin: 0 12px; 88 | `; 89 | -------------------------------------------------------------------------------- /src/components/common/Comment/TotalCount.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | const TotalCount = ({ total }: { total: number }) => { 4 | return 댓글 {total}; 5 | }; 6 | 7 | export default TotalCount; 8 | 9 | const StyledSpan = styled.span` 10 | ${({ theme }) => theme.fontStyle.subtitle1}; 11 | `; 12 | -------------------------------------------------------------------------------- /src/components/common/Comment/context/index.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useState } from 'react'; 2 | import { CommentActionType } from '~/types/comment'; 3 | interface PasswordState { 4 | commentId: null | number; 5 | action: CommentActionType; 6 | password: string; 7 | } 8 | 9 | export interface ContextTypes { 10 | questionId: number; 11 | editId: null | number; 12 | passwordState: PasswordState; 13 | updatePasswordState: (payload: PasswordState) => void; 14 | openPassword: (commentId: number | null, action: CommentActionType) => void; 15 | openEditor: (commentId: number) => void; 16 | closeEditor: () => void; 17 | closePassword: () => void; 18 | } 19 | export const CommentContext = createContext({} as ContextTypes); 20 | 21 | interface CommentStoreProps { 22 | children: ReactNode; 23 | questionId: number; 24 | } 25 | 26 | const CommentProvider = ({ children, questionId }: CommentStoreProps) => { 27 | const [editId, setEditId] = useState(null); 28 | const [passwordState, setPasswordState] = useState({ 29 | commentId: null, 30 | action: '', 31 | password: '', 32 | }); 33 | 34 | const updatePasswordState = (payload: PasswordState) => { 35 | setPasswordState(payload); 36 | }; 37 | 38 | const openPassword = (commentId: number | null, action: CommentActionType) => { 39 | setPasswordState({ commentId, action, password: '' }); 40 | }; 41 | 42 | const openEditor = (commentId: number) => { 43 | setEditId(commentId); 44 | }; 45 | 46 | const closeEditor = () => { 47 | setEditId(null); 48 | closePassword(); 49 | }; 50 | 51 | const closePassword = () => { 52 | setPasswordState({ commentId: null, action: '', password: '' }); 53 | }; 54 | 55 | return ( 56 | 68 | {children} 69 | 70 | ); 71 | }; 72 | 73 | export default CommentProvider; 74 | -------------------------------------------------------------------------------- /src/components/common/Comment/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import AddCommentForm from './AddCommentForm'; 3 | import CommentList from './CommentList'; 4 | import CommentProvider from './context'; 5 | 6 | interface CommentProps { 7 | questionId: number; 8 | } 9 | 10 | const Comment = ({ questionId }: CommentProps) => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default Comment; 24 | 25 | const Container = styled.div` 26 | margin-top: 38px; 27 | `; 28 | 29 | const CommentContent = styled.div` 30 | align-items: center; 31 | `; 32 | -------------------------------------------------------------------------------- /src/components/common/ErrorMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Icon from '~/components/base/Icon'; 3 | 4 | interface ErrorMessageProps { 5 | message: string; 6 | } 7 | 8 | const ErrorMessage = ({ message }: ErrorMessageProps) => { 9 | if (!message) { 10 | return null; 11 | } 12 | 13 | return ( 14 | 15 | 16 | {message} 17 | 18 | ); 19 | }; 20 | 21 | export default ErrorMessage; 22 | 23 | const Message = styled.p` 24 | display: flex; 25 | margin-top: 8px; 26 | align-items: center; 27 | 28 | span { 29 | margin-left: 4px; 30 | color: ${({ theme }) => theme.colors.red}; 31 | ${({ theme }) => theme.fontStyle.caption}; 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /src/components/common/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Link from '~/components/base/Link'; 3 | import PageContainer from '../PageContainer'; 4 | import Logo from '../Logo'; 5 | 6 | const Footer = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 건의하기 14 | 15 | 16 | Copyright © developerwiki. All rights reserved. 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default Footer; 23 | 24 | const FooterContainer = styled.footer` 25 | border-top: 1px solid ${({ theme }) => theme.colors.gray300}; 26 | `; 27 | 28 | const Inner = styled(PageContainer)` 29 | padding-top: 55px; 30 | padding-bottom: 65px; 31 | `; 32 | 33 | const SuggestionButton = styled(Link)` 34 | margin-left: 12px; 35 | 36 | &::before { 37 | content: '💌 '; 38 | } 39 | &:hover::before { 40 | content: '🙇‍♀️ '; 41 | } 42 | `; 43 | 44 | const Copyright = styled.p` 45 | margin-top: 8px; 46 | color: ${({ theme }) => theme.colors.gray500}; 47 | ${({ theme }) => theme.fontStyle.caption}; 48 | `; 49 | 50 | const LogoArea = styled.div` 51 | display: flex; 52 | `; 53 | -------------------------------------------------------------------------------- /src/components/common/Header/CategoryListItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from '~/components/base/Link'; 2 | import styled from '@emotion/styled'; 3 | 4 | interface CategoryListItemProps { 5 | href: string; 6 | name: string; 7 | select: boolean; 8 | shallow?: boolean; 9 | } 10 | 11 | const CategoryListItem = ({ href, name, select, shallow = false }: CategoryListItemProps) => { 12 | return ( 13 | 14 | 15 | {name} 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default CategoryListItem; 22 | 23 | const StyledLi = styled.li` 24 | & ~ li { 25 | margin-left: 22px; 26 | } 27 | 28 | a { 29 | ${({ theme }) => theme.fontStyle.subtitle1} 30 | color: ${({ theme }) => theme.colors.gray800}; 31 | } 32 | 33 | &.selected a { 34 | color: ${({ theme }) => theme.colors.red}; 35 | font-weight: 600; 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /src/components/common/InputField/SubCategoryField.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from 'react'; 2 | import Label from '~/components/base/Label'; 3 | import Select from '~/components/base/Select'; 4 | import { MainType, SubType } from '~/utils/constant/category'; 5 | import InputField from '.'; 6 | import ErrorMessage from '../ErrorMessage'; 7 | import { getSubCategorySelectList } from '~/utils/helper/categorySelect'; 8 | 9 | interface SubCategoryFieldProps { 10 | mainCategory: MainType; 11 | handleChange: (e: ChangeEvent) => void; 12 | selected: SubType | 'none'; 13 | message: string; 14 | } 15 | 16 | const SubCategoryField = ({ 17 | mainCategory, 18 | handleChange, 19 | selected, 20 | message, 21 | }: SubCategoryFieldProps) => { 22 | return ( 23 | 24 | 25 | 23 | {message && } 24 | 25 | ); 26 | }; 27 | 28 | export default TitleField; 29 | -------------------------------------------------------------------------------- /src/components/common/InputField/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | const InputField = styled.div` 4 | margin-bottom: 42px; 5 | `; 6 | 7 | export default InputField; 8 | -------------------------------------------------------------------------------- /src/components/common/Logo/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import LogoSvg from '~/assets/images/logo.svg'; 3 | import LogoText from '~/assets/images/logo-text.svg'; 4 | 5 | interface LogoProps { 6 | type?: 'normal' | 'text'; 7 | size?: 'sm' | 'md'; 8 | } 9 | 10 | const logoSize = { 11 | normal: { 12 | sm: { 13 | width: 143, 14 | height: 25, 15 | }, 16 | md: { 17 | width: 185, 18 | height: 32, 19 | }, 20 | }, 21 | text: { 22 | sm: { 23 | width: 130, 24 | height: 20, 25 | }, 26 | md: { 27 | width: 175, 28 | height: 28, 29 | }, 30 | }, 31 | }; 32 | 33 | const Logo = ({ type = 'normal', size = 'md' }: LogoProps) => { 34 | return ( 35 | 36 | {type === 'text' ? ( 37 | 38 | ) : ( 39 | 40 | )} 41 | 42 | ); 43 | }; 44 | 45 | export default Logo; 46 | 47 | const LogoWrapper = styled.div` 48 | & > svg { 49 | display: block; 50 | } 51 | `; 52 | -------------------------------------------------------------------------------- /src/components/common/MainContainer/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ReactNode } from 'react'; 3 | 4 | interface MainContainerProps { 5 | children: ReactNode; 6 | } 7 | 8 | const MainContainer = ({ children }: MainContainerProps) => { 9 | return
{children}
; 10 | }; 11 | 12 | export default MainContainer; 13 | 14 | const Main = styled.main` 15 | margin-bottom: 50px; 16 | `; 17 | -------------------------------------------------------------------------------- /src/components/common/MiddleCategory/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { SubWithAllType } from '~/utils/constant/category'; 3 | import { convertSubCategory } from '~/utils/helper/converter'; 4 | import { mediaQuery } from '~/utils/helper/mediaQuery'; 5 | 6 | interface MiddleCategoryProps { 7 | subCategories: SubWithAllType[]; 8 | onSelect: (category: SubWithAllType) => void; 9 | currentCategory: SubWithAllType; 10 | } 11 | 12 | const MiddleCategory = ({ 13 | subCategories, 14 | onSelect, 15 | currentCategory, 16 | ...props 17 | }: MiddleCategoryProps) => { 18 | return ( 19 | 20 | {subCategories.map((subCode) => ( 21 |
  • 22 | 23 |
  • 24 | ))} 25 |
    26 | ); 27 | }; 28 | 29 | export default MiddleCategory; 30 | 31 | const CategoryList = styled.ul` 32 | display: flex; 33 | justify-content: center; 34 | flex-wrap: wrap; 35 | 36 | li { 37 | margin-right: 14px; 38 | margin-top: 12px; 39 | 40 | button { 41 | ${({ theme }) => theme.fontStyle.body1}; 42 | padding: 8px 14px; 43 | border-radius: 4px; 44 | color: ${({ theme }) => theme.colors.gray600}; 45 | cursor: pointer; 46 | 47 | &:hover { 48 | color: ${({ theme }) => theme.colors.gray800}; 49 | } 50 | } 51 | 52 | &.selected button { 53 | color: ${({ theme }) => theme.colors.gray800}; 54 | font-weight: 500; 55 | background-color: ${({ theme }) => theme.colors.gray200}; 56 | } 57 | } 58 | 59 | ${mediaQuery('sm')} { 60 | flex-wrap: nowrap; 61 | justify-content: start; 62 | overflow-x: scroll; 63 | padding: 8px 0 4px; 64 | 65 | li { 66 | flex-shrink: 0; 67 | margin-top: 0; 68 | } 69 | } 70 | `; 71 | -------------------------------------------------------------------------------- /src/components/common/Modal/Portal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | interface PortalProps { 5 | children: ReactNode; 6 | } 7 | 8 | const Portal = ({ children }: PortalProps) => { 9 | const [portalElement, setPortalElement] = useState(null); 10 | 11 | useEffect(() => { 12 | if (!document.querySelector('#portal')) { 13 | throw new Error('portal is not defined'); 14 | } 15 | 16 | setPortalElement(() => document.querySelector('#portal')); 17 | 18 | return () => { 19 | setPortalElement(null); 20 | }; 21 | }, []); 22 | 23 | return portalElement ? ReactDOM.createPortal(children, portalElement) : null; 24 | }; 25 | 26 | export default Portal; 27 | -------------------------------------------------------------------------------- /src/components/common/Modal/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ReactNode, useEffect } from 'react'; 3 | import BackgroundDim from '~/components/base/BackgroundDim'; 4 | import useClickAway from '~/hooks/useClickAway'; 5 | import Portal from './Portal'; 6 | 7 | export interface ModalProps { 8 | visible: boolean; 9 | children: ReactNode; 10 | onClose?: () => void; 11 | } 12 | 13 | const Modal = ({ children, visible = false, onClose, ...props }: ModalProps) => { 14 | const ref = useClickAway(() => { 15 | onClose && onClose(); 16 | }); 17 | 18 | useEffect(() => { 19 | if (visible) { 20 | document.querySelector('body')?.classList.add('modal-open'); 21 | ref.current?.focus(); 22 | } else { 23 | document.querySelector('body')?.classList.remove('modal-open'); 24 | } 25 | }, [visible]); 26 | 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default Modal; 39 | 40 | const ModalContainer = styled.div` 41 | width: 100%; 42 | position: fixed; 43 | top: 50%; 44 | left: 50%; 45 | transform: translate(-50%, -50%); 46 | padding: 0 ${({ theme }) => theme.space.mobileSide}; 47 | `; 48 | -------------------------------------------------------------------------------- /src/components/common/MoveButtons/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Button from '~/components/base/Button'; 3 | import Icon from '~/components/base/Icon'; 4 | 5 | interface MoveButtonProps { 6 | disabledPrev?: boolean; 7 | disabledNext?: boolean; 8 | onPrev: () => void; 9 | onNext: () => void; 10 | } 11 | 12 | const MoveButtons = ({ 13 | disabledPrev = false, 14 | disabledNext = false, 15 | onPrev, 16 | onNext, 17 | }: MoveButtonProps) => { 18 | return ( 19 | 20 | 28 | 36 | 37 | ); 38 | }; 39 | 40 | export default MoveButtons; 41 | 42 | const Buttons = styled.div` 43 | width: 100%; 44 | display: flex; 45 | justify-content: space-between; 46 | margin-top: 56px; 47 | `; 48 | -------------------------------------------------------------------------------- /src/components/common/PageContainer/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import styled from '@emotion/styled'; 3 | 4 | interface PageContainerProps { 5 | children: ReactNode; 6 | } 7 | 8 | const PageContainer = ({ children, ...props }: PageContainerProps) => { 9 | return {children}; 10 | }; 11 | 12 | export default PageContainer; 13 | 14 | const StyledContainer = styled.div` 15 | max-width: 872px; 16 | margin: 0 auto; 17 | padding-left: 16px; 18 | padding-right: 16px; 19 | `; 20 | -------------------------------------------------------------------------------- /src/components/common/PageDescription/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ReactNode } from 'react'; 3 | import { theme } from '~/types/theme'; 4 | 5 | interface PageDescriptionProps { 6 | children: ReactNode; 7 | textType?: 'normal' | 'bold'; 8 | } 9 | const PageDescription = ({ children, textType = 'normal' }: PageDescriptionProps) => { 10 | return {children}; 11 | }; 12 | 13 | const fontStyle = { 14 | normal: ` 15 | color: ${theme.colors.gray600}; 16 | ${theme.fontStyle.body2} 17 | `, 18 | bold: ` 19 | color: ${theme.colors.gray800}; 20 | ${theme.fontStyle.body1}; 21 | font-weight: 600; 22 | `, 23 | }; 24 | 25 | const StyledP = styled.p` 26 | ${({ textType }) => textType && fontStyle[textType]} 27 | text-align: center; 28 | margin-top: 14px; 29 | `; 30 | 31 | export default PageDescription; 32 | -------------------------------------------------------------------------------- /src/components/common/SEO/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | 3 | interface SEOProps { 4 | title: string; 5 | description?: string; 6 | withSuffix?: boolean; 7 | } 8 | 9 | const SEO = ({ 10 | title, 11 | description = '기술 면접 준비는 Developer Wiki에서!', 12 | withSuffix = false, 13 | }: SEOProps) => { 14 | const processedTitle = title.trim() + (withSuffix ? ' | Developer Wiki' : ''); 15 | 16 | return ( 17 | 18 | {processedTitle} 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default SEO; 28 | -------------------------------------------------------------------------------- /src/components/common/Spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { theme } from '~/types/theme'; 3 | 4 | const spinnerSize = { 5 | sm: ` 6 | width: 1.5em; 7 | height: 1.5em; 8 | `, 9 | md: ` 10 | width: 50px; 11 | height: 50px; 12 | `, 13 | } as const; 14 | 15 | const spinnerColor = { 16 | white: theme.colors.white, 17 | gray: theme.colors.gray400, 18 | } as const; 19 | 20 | interface SpinnerProps { 21 | /** 22 | * Spinner의 색상을 설정합니다. 23 | */ 24 | color?: keyof typeof spinnerColor; 25 | /** 26 | * Spinner의 사이즈를 설정합니다. 27 | */ 28 | size?: keyof typeof spinnerSize; 29 | } 30 | 31 | /** 32 | 부모 컴포넌트에 width, height, position: relative 속성이 있어야 정상적으로 나타납니다. 33 | */ 34 | const Spinner = ({ color = 'white', size = 'sm' }: SpinnerProps) => { 35 | return ( 36 | 37 |
    38 |
    39 | ); 40 | }; 41 | 42 | export default Spinner; 43 | 44 | const SpinnerContainer = styled.div` 45 | position: absolute; 46 | transform: translate(-50%, -50%); 47 | top: 50%; 48 | left: 50%; 49 | 50 | .loader, 51 | .loader:after { 52 | border-radius: 50%; 53 | ${({ size }) => size && spinnerSize[size]}; 54 | } 55 | 56 | .loader { 57 | position: relative; 58 | 59 | border-width: ${({ size }) => (size === 'md' ? '3px' : '2px')}; 60 | border-style: solid; 61 | border-color: ${({ color }) => 62 | color === 'white' ? 'rgba(255, 255, 255, 0.2)' : 'rgba(125, 125, 125, 0.1)'}; 63 | border-left-color: ${({ color }) => color}; 64 | 65 | animation: loading 1.1s infinite linear; 66 | } 67 | 68 | @keyframes loading { 69 | 0% { 70 | transform: rotate(0deg); 71 | } 72 | 100% { 73 | transform: rotate(360deg); 74 | } 75 | } 76 | `; 77 | -------------------------------------------------------------------------------- /src/components/common/TextArea/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { TextareaHTMLAttributes } from 'react'; 3 | 4 | interface TextAreaProps extends TextareaHTMLAttributes { 5 | height?: number; 6 | block?: boolean; 7 | } 8 | 9 | const TextArea = ({ height = 74, block = false, ...props }: TextAreaProps) => { 10 | return ; 11 | }; 12 | 13 | export default TextArea; 14 | 15 | const StyledTextArea = styled.textarea` 16 | background-color: white; 17 | width: 100%; 18 | border: 1px solid ${({ theme }) => theme.colors.gray300}; 19 | border-radius: 4px; 20 | ${({ theme }) => theme.fontStyle.body2}; 21 | display: ${({ block }) => (block ? 'block' : 'inline-block')}; 22 | 23 | height: ${({ height }) => height && height + 'px'}; 24 | padding: 8px; 25 | box-sizing: border-box; 26 | resize: none; 27 | `; 28 | -------------------------------------------------------------------------------- /src/components/common/Toast/ToastManager.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { useRef, useState, useEffect, useCallback } from 'react'; 3 | import { mediaQuery } from '~/utils/helper/mediaQuery'; 4 | import ToastItem, { ToastItemProps } from './ToastItem'; 5 | 6 | export type TCreateToast = (props: Omit) => void; 7 | type WithId = T & { id: number }; 8 | 9 | interface ToastManagerProps { 10 | bind: (createToastFn: TCreateToast) => void; 11 | limit: number; 12 | } 13 | 14 | const ToastManager = ({ bind, limit }: ToastManagerProps) => { 15 | const lastId = useRef(0); 16 | const [toasts, setToasts] = useState>[]>([]); 17 | 18 | const createToast: TCreateToast = useCallback((params) => { 19 | const newToast = { id: lastId.current++, ...params }; 20 | 21 | setToasts((prevToasts) => { 22 | const newToasts = [...prevToasts, newToast].map((toast, idx, newToasts) => { 23 | const isOverLimit = newToasts.length > limit && idx < newToasts.length - limit; 24 | return isOverLimit ? { ...toast, isRemoved: true } : toast; 25 | }); 26 | 27 | return newToasts; 28 | }); 29 | }, []); 30 | 31 | const removeToast = useCallback((id: number) => { 32 | setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id)); 33 | }, []); 34 | 35 | useEffect(() => { 36 | bind(createToast); 37 | }, [bind, createToast]); 38 | 39 | return ( 40 | 41 | {toasts.map(({ id, ...props }) => ( 42 | removeToast(id)} {...props} /> 43 | ))} 44 | 45 | ); 46 | }; 47 | 48 | export default ToastManager; 49 | 50 | const StyledUl = styled.ul` 51 | position: fixed; 52 | left: 50%; 53 | bottom: 40px; 54 | transform: translateX(-50%); 55 | z-index: 2000; 56 | 57 | ${mediaQuery('sm')} { 58 | width: calc(100% - calc(${({ theme }) => theme.space.mobileSide} * 2)); 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /src/components/common/Toast/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { buttonSizes, buttonStyle } from '~/components/base/Button/types'; 3 | import { Nullable } from '~/types/utilityType'; 4 | import ClientPortal from '../ClientPortal'; 5 | import ToastManager, { TCreateToast } from './ToastManager'; 6 | 7 | class Toast { 8 | private static PORTAL_ID = 'toast-portal'; 9 | private static LIMIT = 1; 10 | private createToast: Nullable = null; 11 | 12 | render() { 13 | return ( 14 | 15 | { 17 | this.createToast = createToast; 18 | }} 19 | limit={Toast.LIMIT} 20 | /> 21 | 22 | ); 23 | } 24 | 25 | showMessage(message: string, keepAlive?: boolean, duration?: number) { 26 | this.createToast && this.createToast({ message, keepAlive, duration }); 27 | } 28 | 29 | showChildren(children: ReactNode, keepAlive?: boolean, duration?: number) { 30 | this.createToast && this.createToast({ children, keepAlive, duration }); 31 | } 32 | 33 | showMessageWithLink( 34 | content: { 35 | message: string; 36 | link: { 37 | message: string; 38 | href: string; 39 | variant?: keyof typeof buttonStyle; 40 | size?: keyof typeof buttonSizes; 41 | }; 42 | }, 43 | keepAlive = true, 44 | duration?: number, 45 | ) { 46 | this.createToast && 47 | this.createToast({ 48 | message: content.message, 49 | link: content.link, 50 | keepAlive, 51 | duration, 52 | }); 53 | } 54 | } 55 | 56 | const ToastContainer = new Toast(); 57 | 58 | export default ToastContainer; 59 | export const toast = ToastContainer; 60 | -------------------------------------------------------------------------------- /src/components/common/UserProfile/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { theme } from '~/types/theme'; 3 | import Avatar from '../Avatar'; 4 | 5 | export const AvatarSize = ['sm', 'md'] as const; 6 | export const AvatarFontSize = ['sm', 'md', 'lg'] as const; 7 | 8 | interface UserProfileProps { 9 | /** 10 | * 프로필 이미지의 경로를 설정합니다. 11 | */ 12 | profileUrl?: string; 13 | /** 14 | * 프로필 이미지 사이즈를 설정합니다. 15 | */ 16 | avatarSize?: typeof AvatarSize[number]; 17 | /** 18 | * 폰트 사이즈를 설정합니다. 19 | */ 20 | fontSize?: typeof AvatarFontSize[number]; 21 | /** 22 | * 프로필에 작성할 텍스트를 설정합니다. 23 | */ 24 | text: string; 25 | } 26 | 27 | const UserProfile = ({ 28 | profileUrl, 29 | avatarSize = 'md', 30 | fontSize = 'md', 31 | text, 32 | ...props 33 | }: UserProfileProps) => { 34 | return ( 35 | 36 | 37 | {text} 38 | 39 | ); 40 | }; 41 | 42 | const textSize = { 43 | sm: ` 44 | margin-left: 4px; 45 | ${theme.fontStyle.body1} 46 | `, 47 | md: ` 48 | margin-left: 10px; 49 | ${theme.fontStyle.subtitle1} 50 | `, 51 | 52 | lg: ` 53 | margin-left: 12px; 54 | ${theme.fontStyle.headline1} 55 | `, 56 | }; 57 | 58 | const Container = styled.div` 59 | display: flex; 60 | align-items: center; 61 | `; 62 | 63 | const Text = styled.span<{ fontSize: 'sm' | 'md' | 'lg' }>` 64 | white-space: nowrap; 65 | overflow: hidden; 66 | text-overflow: ellipsis; 67 | ${({ fontSize }) => fontSize && textSize[fontSize]}; 68 | `; 69 | 70 | export default UserProfile; 71 | -------------------------------------------------------------------------------- /src/components/domain/QuestionList/BookmarkButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import React, { MouseEvent } from 'react'; 3 | import Icon from '~/components/base/Icon'; 4 | import { mediaQuery } from '~/utils/helper/mediaQuery'; 5 | 6 | export interface BookmarkButtonProps { 7 | isBookmarked: boolean; 8 | onBookmarkToggle: () => void; 9 | } 10 | 11 | const BookmarkButton = ({ isBookmarked, onBookmarkToggle }: BookmarkButtonProps) => { 12 | const handleClick = (e: MouseEvent) => { 13 | if (e.target instanceof HTMLButtonElement) { 14 | e.target.blur(); 15 | } 16 | onBookmarkToggle(); 17 | }; 18 | 19 | return ( 20 | 29 | ); 30 | }; 31 | 32 | export default BookmarkButton; 33 | 34 | const StyledIconButton = styled(Icon.Button)` 35 | display: inline-block; 36 | width: 59px; 37 | padding: 18px 19px 20px; 38 | border-right: 1px solid ${({ theme }) => theme.colors.gray300}; 39 | 40 | ${mediaQuery('sm')} { 41 | order: 1; 42 | width: auto; 43 | border-right: 0; 44 | padding: 0; 45 | 46 | svg { 47 | width: 24px; 48 | height: 23px; 49 | } 50 | } 51 | `; 52 | -------------------------------------------------------------------------------- /src/components/domain/QuestionList/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { IQuestionItem } from '~/types/question'; 3 | import { mediaQuery } from '~/utils/helper/mediaQuery'; 4 | import QuestionItem, { QuestionItemProps } from './QuestionItem'; 5 | 6 | interface QuestionListProps extends Omit { 7 | questions: IQuestionItem[]; 8 | } 9 | 10 | const QuestionList = ({ 11 | questions, 12 | currentCategory, 13 | onBookmarkToggle, 14 | ...props 15 | }: QuestionListProps) => { 16 | return ( 17 | 18 | {questions.map((question) => ( 19 | 25 | ))} 26 | 27 | ); 28 | }; 29 | 30 | export default QuestionList; 31 | 32 | const Container = styled.ul` 33 | border-top: 1px solid ${({ theme }) => theme.colors.gray300}; 34 | 35 | ${mediaQuery('sm')} { 36 | border-top: 0; 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /src/components/domain/profile/DeleteAccount.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { FormEvent, useState } from 'react'; 3 | import Button from '~/components/base/Button'; 4 | import Checkbox from '~/components/base/Checkbox'; 5 | import { mediaQuery } from '~/utils/helper/mediaQuery'; 6 | 7 | interface DeleteAccountProps { 8 | onDeleteAccount: () => void; 9 | } 10 | 11 | const DeleteAccount = ({ onDeleteAccount }: DeleteAccountProps) => { 12 | const [check, setCheck] = useState(false); 13 | 14 | const handleCheck = () => { 15 | setCheck(!check); 16 | }; 17 | 18 | const handleSubmit = (e: FormEvent) => { 19 | e.preventDefault(); 20 | 21 | if (!check) { 22 | alert('약관에 동의해주세요.'); 23 | return; 24 | } 25 | onDeleteAccount(); 26 | }; 27 | 28 | return ( 29 |
    30 | 31 | 회원 탈퇴 시 모든 개인 정보는 완전히 삭제되며 복구할 수 없게 됩니다. 32 |
    33 | 등록 신청한 질문, 댓글은 삭제되지 않습니다. 34 |
    35 | 36 | 43 | 위 내용을 모두 확인하였고 이에 동의합니다. 44 | 45 | 46 | 계정 삭제하기 47 | 48 | 49 |
    50 | ); 51 | }; 52 | 53 | export default DeleteAccount; 54 | 55 | const Notice = styled.div` 56 | margin-top: 18px; 57 | border: 1px solid ${({ theme }) => theme.colors.gray300}; 58 | border-radius: 4px; 59 | padding: 18px; 60 | color: ${({ theme }) => theme.colors.gray600}; ; 61 | `; 62 | 63 | const Confirm = styled.form` 64 | display: flex; 65 | justify-content: space-between; 66 | margin-top: 18px; 67 | 68 | ${mediaQuery('sm')} { 69 | flex-direction: column; 70 | align-items: flex-start; 71 | } 72 | `; 73 | 74 | const DeleteButton = styled(Button)` 75 | width: min-content; 76 | 77 | ${mediaQuery('sm')} { 78 | margin-top: 30px; 79 | flex-direction: column; 80 | } 81 | `; 82 | 83 | const NoticeCheckbox = styled(Checkbox)` 84 | height: min-content; 85 | `; 86 | -------------------------------------------------------------------------------- /src/components/domain/profile/EditAvatar.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Avatar from '~/components/common/Avatar'; 3 | import { AvatarSize, AvatarSizes } from '~/components/common/Avatar/types'; 4 | import { mediaQuery } from '~/utils/helper/mediaQuery'; 5 | 6 | interface EditAvatarProps { 7 | imageUrl?: string; 8 | size?: AvatarSizes; 9 | onClick?: () => void; 10 | } 11 | 12 | const EditAvatar = ({ imageUrl, size, onClick }: EditAvatarProps) => { 13 | return ( 14 | 15 |
    16 | 프로필 수정 17 |
    18 | 19 |
    20 | ); 21 | }; 22 | 23 | export default EditAvatar; 24 | 25 | const Container = styled.button` 26 | width: ${({ size }) => size && AvatarSize[size]}; 27 | height: ${({ size }) => size && AvatarSize[size]}; 28 | position: relative; 29 | border-radius: 50%; 30 | overflow: hidden; 31 | cursor: pointer; 32 | 33 | .background { 34 | opacity: 0; 35 | width: 100%; 36 | height: 100%; 37 | position: absolute; 38 | top: 0; 39 | background-color: rgba(0, 0, 0, 0.6); 40 | color: ${({ theme }) => theme.colors.white}; 41 | ${({ theme }) => theme.fontStyle.body2}; 42 | font-weight: 600; 43 | z-index: 10; 44 | 45 | ${mediaQuery('sm')} { 46 | display: none; 47 | } 48 | 49 | span { 50 | position: absolute; 51 | top: 50%; 52 | left: 50%; 53 | transform: translate(-50%, -50%); 54 | user-select: none; 55 | } 56 | } 57 | 58 | &:hover .background { 59 | opacity: 1; 60 | } 61 | `; 62 | -------------------------------------------------------------------------------- /src/components/domain/profile/Tab/BookmarkList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Paging } from '~/types/utilityType'; 3 | import { IQuestionItem } from '~/types/question'; 4 | import NoResult from './NoResult'; 5 | import QuestionItem from '../../QuestionList/QuestionItem'; 6 | import styled from '@emotion/styled'; 7 | import { mediaQuery } from '~/utils/helper/mediaQuery'; 8 | 9 | interface BookmarkListProps { 10 | data: Paging; 11 | onBookmarkToggle: (questionId: number) => void; 12 | } 13 | 14 | const BookmarkList = ({ data, onBookmarkToggle, ...props }: BookmarkListProps) => { 15 | if (data.totalElements === 0 || data.content.length === 0) { 16 | return 북마크한 질문이 없습니다.; 17 | } 18 | return ( 19 | 20 | {data.content.map((question) => ( 21 | 30 | ))} 31 | 32 | ); 33 | }; 34 | 35 | export default BookmarkList; 36 | 37 | const Container = styled.ul` 38 | border-top: 1px solid ${({ theme }) => theme.colors.gray300}; 39 | 40 | ${mediaQuery('sm')} { 41 | border-top: 0; 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /src/components/domain/profile/Tab/Comment.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import CommentItem from './CommentItem'; 3 | import Pagination from '~/components/common/Pagination'; 4 | import NoResult from './NoResult'; 5 | import useProfileComment from '~/react-query/hooks/useProfileComment'; 6 | import { useRouter } from 'next/router'; 7 | import { useEffect, useState } from 'react'; 8 | 9 | const Comment = () => { 10 | const [isReady, setIsReady] = useState(false); 11 | const { data, query, setQuery, setQueryWithoutUrl } = useProfileComment(isReady); 12 | const router = useRouter(); 13 | 14 | const handlePage = (page: number) => setQuery({ ...query, page }); 15 | 16 | useEffect(() => { 17 | if (!router.isReady || router.query.tab !== 'comment') return; 18 | 19 | const initialValues = { page: 0 }; 20 | const filteredQuery = filter({ page: router.query.page }, initialValues); 21 | 22 | setQueryWithoutUrl(filteredQuery); 23 | setIsReady(true); 24 | }, [router.isReady, router.query.page]); 25 | 26 | if (data.totalElements === 0) return 작성한 댓글이 없습니다.; 27 | return ( 28 | <> 29 | 30 | {data.content.map((comment) => ( 31 | 32 | ))} 33 | 34 | 35 | 41 | 42 | ); 43 | }; 44 | 45 | export default Comment; 46 | 47 | function filter( 48 | query: Record<'page', string | string[] | undefined>, 49 | defaultValue: { page: number }, 50 | ) { 51 | const { page } = query; 52 | 53 | if (!Number.isInteger(Number(page))) return defaultValue; 54 | return { page: Number(page) }; 55 | } 56 | 57 | const StyledUl = styled.ul` 58 | border-top: 1px solid ${({ theme }) => theme.colors.gray300}; 59 | margin-bottom: 32px; 60 | `; 61 | -------------------------------------------------------------------------------- /src/components/domain/profile/Tab/CommentItem.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Link from '~/components/base/Link'; 3 | import { IProfileCommentItem } from '~/types/comment'; 4 | import { convertSubCategory } from '~/utils/helper/converter'; 5 | import { formatDate } from '~/utils/helper/formatting'; 6 | import { mediaQuery } from '~/utils/helper/mediaQuery'; 7 | 8 | interface CommentListProps { 9 | comment: IProfileCommentItem; 10 | } 11 | 12 | const CommentItem = ({ comment }: CommentListProps) => { 13 | return ( 14 |
  • 15 | 21 | 22 | {convertSubCategory(comment.subCategory)} 23 | 24 | 25 | 26 | {comment.title} 27 | {comment.content} 28 | 29 | 30 | {formatDate(comment.createdAt)} 31 | 32 |
  • 33 | ); 34 | }; 35 | 36 | export default CommentItem; 37 | 38 | const StyledLink = styled(Link)` 39 | display: flex; 40 | align-items: center; 41 | padding: 16px 25px 16px 19px; 42 | border-bottom: 1px solid ${({ theme }) => theme.colors.gray300}; 43 | `; 44 | 45 | const CategoryName = styled.div` 46 | padding-right: 20px; 47 | width: 140px; 48 | white-space: nowrap; 49 | overflow: hidden; 50 | text-overflow: ellipsis; 51 | flex-shrink: 0; 52 | ${({ theme }) => theme.fontStyle.body2_500} 53 | color: ${({ theme }) => theme.colors.gray600}; 54 | 55 | ${mediaQuery('sm')} { 56 | width: 100px; 57 | } 58 | `; 59 | 60 | const Content = styled.div` 61 | overflow: hidden; 62 | `; 63 | 64 | const QuestionTitle = styled.span` 65 | display: block; 66 | margin-bottom: 8px; 67 | ${({ theme }) => theme.fontStyle.body2} 68 | color: ${({ theme }) => theme.colors.gray500}; 69 | white-space: nowrap; 70 | overflow: hidden; 71 | text-overflow: ellipsis; 72 | `; 73 | 74 | const CommentContent = styled.span` 75 | display: block; 76 | ${({ theme }) => theme.fontStyle.body1} 77 | color: ${({ theme }) => theme.colors.gray800}; 78 | white-space: nowrap; 79 | overflow: hidden; 80 | text-overflow: ellipsis; 81 | `; 82 | 83 | const CreatedAt = styled.span` 84 | flex-grow: 1; 85 | flex-shrink: 0; 86 | padding-left: 25px; 87 | text-align: right; 88 | ${({ theme }) => theme.fontStyle.body2} 89 | color: ${({ theme }) => theme.colors.gray500}; 90 | `; 91 | -------------------------------------------------------------------------------- /src/components/domain/profile/Tab/NoResult.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ReactNode } from 'react'; 3 | import styled from '@emotion/styled'; 4 | 5 | interface NoResultProps { 6 | children: ReactNode; 7 | } 8 | 9 | const NoResult = ({ children, ...props }: NoResultProps) => { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | }; 16 | 17 | export default NoResult; 18 | 19 | const StyledDiv = styled.div` 20 | padding: 64px 0; 21 | text-align: center; 22 | `; 23 | -------------------------------------------------------------------------------- /src/components/domain/profile/Tab/PageInfo.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | interface PageInfo { 4 | cur: number; 5 | total: number; 6 | } 7 | 8 | const PageInfo = ({ cur, total }: PageInfo) => { 9 | if (total <= 0) return null; 10 | 11 | return ( 12 | 13 | {cur + 1}/{total} 페이지 14 | 15 | ); 16 | }; 17 | 18 | export default PageInfo; 19 | 20 | const StyledSpan = styled.span` 21 | ${({ theme }) => theme.fontStyle.body2} 22 | color: ${({ theme }) => theme.colors.gray500}; 23 | white-space: nowrap; 24 | `; 25 | -------------------------------------------------------------------------------- /src/components/domain/profile/Tab/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Link from '~/components/base/Link'; 3 | import { IUser } from '~/types/user'; 4 | import { Nullable } from '~/types/utilityType'; 5 | 6 | interface Tab { 7 | user: IUser; 8 | tab: Nullable; 9 | onChange: (value: string) => void; 10 | } 11 | 12 | const Tab = ({ user, tab, onChange, ...props }: Tab) => { 13 | const getClassName = (linkTab: string) => (tab === linkTab ? 'is-active' : undefined); 14 | 15 | return ( 16 | 17 | 18 |
  • 19 | onChange('bookmark')} 25 | > 26 | 북마크한 질문 {user.bookmarkSize} 27 | 28 |
  • 29 |
  • 30 | onChange('comment')} 35 | > 36 | 작성한 댓글 {user.commentSize} 37 | 38 |
  • 39 |
    40 |
    41 | ); 42 | }; 43 | 44 | export default Tab; 45 | 46 | const StyledNav = styled.nav` 47 | border-bottom: 1px solid ${({ theme }) => theme.colors.gray300}; 48 | `; 49 | 50 | const StyledUl = styled.ul` 51 | display: flex; 52 | `; 53 | 54 | const StyledLink = styled(Link)` 55 | position: relative; 56 | color: ${({ theme }) => theme.colors.gray600}; 57 | ${({ theme }) => theme.fontStyle.subtitle1} 58 | 59 | &.is-active { 60 | color: ${({ theme }) => theme.colors.gray800}; 61 | 62 | ::after { 63 | content: ''; 64 | position: absolute; 65 | bottom: -1px; 66 | left: 0; 67 | width: 100%; 68 | height: 2px; 69 | background-color: ${({ theme }) => theme.colors.red}; 70 | } 71 | } 72 | `; 73 | -------------------------------------------------------------------------------- /src/components/domain/profile/UserInfo.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Link from '~/components/base/Link'; 3 | import UserProfile from '~/components/common/UserProfile'; 4 | import { IUser } from '~/types/user'; 5 | 6 | interface UserInfoProps { 7 | user: IUser; 8 | } 9 | 10 | const UserInfo = ({ user, ...props }: UserInfoProps) => { 11 | return ( 12 | 13 | 14 | 15 | 회원 정보 수정 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default UserInfo; 22 | 23 | const Container = styled.div` 24 | display: flex; 25 | align-items: center; 26 | `; 27 | 28 | const StyledUserProfile = styled(UserProfile)` 29 | margin-right: 14px; 30 | `; 31 | -------------------------------------------------------------------------------- /src/components/domain/question/PostHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { useRouter } from 'next/router'; 3 | import Icon from '~/components/base/Icon'; 4 | import { useCallback, useEffect, useState } from 'react'; 5 | import PageTitle from '~/components/base/PageTitle'; 6 | import useTimeoutFn from '~/hooks/useTimeoutFn'; 7 | import useBookmark from '~/react-query/hooks/useBookmark'; 8 | import { useUser } from '~/react-query/hooks/useUser'; 9 | import { SubType } from '~/utils/constant/category'; 10 | import { convertSubCategory } from '~/utils/helper/converter'; 11 | 12 | interface PostHeaderProps { 13 | subCategory: SubType; 14 | title: string; 15 | questionId: number; 16 | } 17 | 18 | const PostHeader = ({ subCategory, title, questionId }: PostHeaderProps) => { 19 | const { isBookmarked, postBookmark } = useBookmark({ questionId }); 20 | const [bookmarkState, setBookmarkState] = useState(isBookmarked); 21 | const { user } = useUser(); 22 | const router = useRouter(); 23 | 24 | const [run, clear] = useTimeoutFn( 25 | useCallback(() => { 26 | if (isBookmarked !== bookmarkState) { 27 | postBookmark(!isBookmarked); 28 | } 29 | }, [bookmarkState]), 30 | 2000, 31 | ); 32 | 33 | const onBookmarkToggle = async () => { 34 | if (!user) { 35 | alert('로그인이 필요한 서비스입니다.'); 36 | router.push('/login'); 37 | return; 38 | } 39 | 40 | setBookmarkState((state) => !state); 41 | run(); 42 | }; 43 | 44 | useEffect(() => { 45 | setBookmarkState(isBookmarked); 46 | }, [isBookmarked]); 47 | 48 | return ( 49 | 50 | 59 | {convertSubCategory(subCategory)} 60 | {title} 61 | 62 | ); 63 | }; 64 | 65 | export default PostHeader; 66 | 67 | const Container = styled.div` 68 | position: relative; 69 | text-align: center; 70 | border-bottom: 1px solid ${({ theme }) => theme.colors.gray300}; 71 | padding-bottom: 30px; 72 | `; 73 | const CategoryName = styled.div` 74 | ${({ theme }) => theme.fontStyle.body2}; 75 | color: ${({ theme }) => theme.colors.gray500}; 76 | `; 77 | 78 | const PostTitle = styled(PageTitle)` 79 | margin-top: 18px; 80 | `; 81 | 82 | const StyledIconButton = styled(Icon.Button)` 83 | width: auto; 84 | padding: 7px 8px; 85 | border: 1px solid ${({ theme }) => theme.colors.gray300}; 86 | border-radius: 4px; 87 | background-color: ${({ theme }) => theme.colors.white}; 88 | box-shadow: 0px 1px 2px rgba(204, 204, 204, 0.25); 89 | position: absolute; 90 | top: -8px; 91 | right: 18px; 92 | `; 93 | -------------------------------------------------------------------------------- /src/components/domain/question/QuestionMoveButtons/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import MoveButtons from '~/components/common/MoveButtons'; 3 | import { ICategoryQuery } from '~/types/question'; 4 | 5 | interface QuestionMoveButtonProps { 6 | categoryQuery: ICategoryQuery; 7 | prevId: number; 8 | nextId: number; 9 | } 10 | 11 | const QuestionMoveButtons = ({ categoryQuery, nextId, prevId }: QuestionMoveButtonProps) => { 12 | const router = useRouter(); 13 | 14 | const onMovePrev = () => { 15 | router.push({ pathname: `/question/${prevId}`, query: { ...categoryQuery } }, undefined, { 16 | shallow: true, 17 | }); 18 | }; 19 | 20 | const onMoveNext = () => { 21 | router.push({ pathname: `/question/${nextId}`, query: { ...categoryQuery } }, undefined, { 22 | shallow: true, 23 | }); 24 | }; 25 | 26 | return ( 27 | 33 | ); 34 | }; 35 | 36 | export default QuestionMoveButtons; 37 | -------------------------------------------------------------------------------- /src/components/domain/question/TailQuestionList/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Icon from '~/components/base/Icon'; 3 | 4 | interface TailQuestionListProps { 5 | list: string[]; 6 | onRemove: (index: number) => void; 7 | } 8 | 9 | const TailQuestionList = ({ list, onRemove }: TailQuestionListProps) => { 10 | return ( 11 | 12 | {list.map((question, index) => ( 13 |
  • 14 | {question} 15 | 18 |
  • 19 | ))} 20 |
    21 | ); 22 | }; 23 | 24 | export default TailQuestionList; 25 | 26 | const StyledUl = styled.ul` 27 | li { 28 | margin-top: 12px; 29 | ${({ theme }) => theme.fontStyle.body2}; 30 | padding: 8px 10px; 31 | background-color: ${({ theme }) => theme.colors.gray200}; 32 | border-radius: 4px; 33 | display: flex; 34 | justify-content: space-between; 35 | align-items: center; 36 | 37 | span { 38 | line-height: 1.5; 39 | } 40 | } 41 | `; 42 | -------------------------------------------------------------------------------- /src/components/domain/question/TailQuestionModal/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; 3 | import Button from '~/components/base/Button'; 4 | import Input from '~/components/base/Input'; 5 | import Label from '~/components/base/Label'; 6 | import PageTitle from '~/components/base/PageTitle'; 7 | import Article from '~/components/common/Article'; 8 | import CloseButton from '~/components/common/CloseButton'; 9 | import InputField from '~/components/common/InputField'; 10 | import questionApi from '~/service/question'; 11 | import { SUBMIT_CHECK } from '~/utils/helper/validation'; 12 | 13 | interface TailQuestionModalProps { 14 | title: string; 15 | id: number; 16 | onClose: () => void; 17 | isOpenModal: boolean; 18 | } 19 | 20 | const TailQuestionModal = ({ title, id, onClose, isOpenModal }: TailQuestionModalProps) => { 21 | const [text, setText] = useState(''); 22 | const [isLoading, setIsLoading] = useState(false); 23 | 24 | const onSubmit = async (e: FormEvent) => { 25 | e.preventDefault(); 26 | 27 | const validText = text.trim(); 28 | setText(validText); 29 | 30 | if (SUBMIT_CHECK.tailQuestion.isValid(validText)) { 31 | alert(SUBMIT_CHECK.tailQuestion.message); 32 | return; 33 | } 34 | 35 | try { 36 | setIsLoading(true); 37 | await questionApi.createTail(Number(id), text); 38 | alert('질문이 접수되었습니다. 질문은 관리자 확인 후 등록됩니다.'); 39 | onClose(); 40 | } catch { 41 | alert('질문 등록에 실패했습니다.'); 42 | } 43 | 44 | setIsLoading(false); 45 | }; 46 | 47 | useEffect(() => { 48 | if (isOpenModal) { 49 | setText(''); 50 | } 51 | }, [isOpenModal]); 52 | 53 | return ( 54 | 55 | 56 | 꼬리 질문 등록 57 | 58 | 59 | 60 |
    61 | 62 | 63 | 64 | 65 | 66 | 67 | ) => setText(e.target.value)} 72 | /> 73 | 74 | 75 | 등록 76 |
    77 |
    78 | ); 79 | }; 80 | 81 | export default TailQuestionModal; 82 | 83 | const StyledArticle = styled(Article)` 84 | margin-top: 0; 85 | `; 86 | 87 | const Form = styled.form` 88 | margin-top: 34px; 89 | display: flex; 90 | flex-direction: column; 91 | `; 92 | 93 | const SubmitButton = styled(Button)` 94 | width: fit-content; 95 | margin: 0 auto; 96 | `; 97 | 98 | const StyledCloseButton = styled(CloseButton)` 99 | position: absolute; 100 | top: 6px; 101 | right: 0; 102 | `; 103 | 104 | const ModalHeader = styled.div` 105 | position: relative; 106 | `; 107 | -------------------------------------------------------------------------------- /src/components/domain/question/TailQuestions/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { useState } from 'react'; 3 | import Button from '~/components/base/Button'; 4 | import Icon from '~/components/base/Icon'; 5 | import Modal from '~/components/common/Modal'; 6 | import TailQuestionModal from '../TailQuestionModal'; 7 | 8 | interface Props { 9 | questions: string[]; 10 | questionId: number; 11 | title: string; 12 | } 13 | 14 | const TailQuestions = ({ questions, questionId, title }: Props) => { 15 | const [isOpen, setIsOpen] = useState(questions.length === 0); 16 | const [isOpenModal, setIsOpenModal] = useState(false); 17 | 18 | const onOpenModal = () => { 19 | setIsOpenModal(true); 20 | }; 21 | 22 | const onCloseModal = () => { 23 | setIsOpenModal(false); 24 | }; 25 | 26 | const onClickButton = () => { 27 | setIsOpen(!isOpen); 28 | }; 29 | 30 | return ( 31 | <> 32 | 33 | 34 | 예상되는 꼬리 질문 보기 35 | {isOpen ? ( 36 | 37 | ) : ( 38 | 39 | )} 40 | 41 | {isOpen && ( 42 | <> 43 | 44 | {questions.length > 0 ? ( 45 | questions.map((question, index) =>
  • {question}
  • ) 46 | ) : ( 47 |

    등록된 꼬리 질문이 없습니다.

    48 | )} 49 |
    50 | 51 | 꼬리 질문 등록 52 | 53 | 54 | )} 55 |
    56 | 57 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default TailQuestions; 69 | 70 | const Container = styled.div` 71 | margin-top: 42px; 72 | max-width: 420px; 73 | width: 100%; 74 | `; 75 | 76 | const AccordionTitle = styled.div` 77 | display: flex; 78 | justify-content: space-between; 79 | padding: 16px 0; 80 | align-items: center; 81 | border-bottom: 1px solid ${({ theme }) => theme.colors.gray300}; ; 82 | `; 83 | 84 | const Title = styled.strong` 85 | ${({ theme }) => theme.fontStyle.subtitle2}; 86 | `; 87 | 88 | const AccordionContent = styled.ul` 89 | padding: 16px 0; 90 | 91 | li { 92 | list-style: disc; 93 | list-style-position: inside; 94 | padding: 8px 0; 95 | } 96 | `; 97 | 98 | const StyledButton = styled(Button)` 99 | display: block; 100 | margin: 0 auto; 101 | margin-top: 14px; 102 | `; 103 | -------------------------------------------------------------------------------- /src/components/domain/random/MainCategoryField.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from 'react'; 2 | import Label from '~/components/base/Label'; 3 | import Select from '~/components/base/Select'; 4 | import ErrorMessage from '~/components/common/ErrorMessage'; 5 | import InputField from '~/components/common/InputField'; 6 | import { getMainCategorySelectList } from '~/utils/helper/categorySelect'; 7 | 8 | interface MainCategoryFieldProps { 9 | handleChange: (e: ChangeEvent) => void; 10 | selected: string; 11 | message?: string; 12 | } 13 | 14 | const MainCategoryField = ({ handleChange, selected, message }: MainCategoryFieldProps) => { 15 | return ( 16 | 17 | 18 |