diff --git a/.github/workflows/frontend_cd.yml b/.github/workflows/frontend_cd.yml
deleted file mode 100644
index 7d2abc37c..000000000
--- a/.github/workflows/frontend_cd.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-name: Frontend CD
-
-on:
- push:
- branches:
- - main
- - dev/fe
-
-jobs:
- build:
- runs-on:
- - self-hosted
- - spring
- - develop
- env:
- frontend-directory: ./frontend
- steps:
- - uses: actions/checkout@v4
-
- - name: Node.js 설정
- uses: actions/setup-node@v4
- with:
- node-version: 20
-
- - name: 환경 파일 생성
- run: |
- if [ "${{ github.ref_name }}" == "main" ]; then
- echo "REACT_APP_API_URL=${{ secrets.REACT_APP_API_URL }}" > ${{ env.frontend-directory }}/.env.production
- else
- echo "REACT_APP_API_URL=${{ secrets.REACT_APP_BETA_API_URL }}" > ${{ env.frontend-directory }}/.env.production
- fi
-
- echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> ${{ env.frontend-directory }}/.env.production
- echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ${{ env.frontend-directory }}/.env.sentry-build-plugin
-
- - name: 환경 파일 권한 설정
- run: chmod 644 ${{ env.frontend-directory }}/.env.*
-
- - name: 의존성 설치
- run: npm install
- working-directory: ${{ env.frontend-directory }}
-
- - name: 빌드 실행
- run: npm run build
- working-directory: ${{ env.frontend-directory }}
-
- - name: 아티팩트 업로드
- uses: actions/upload-artifact@v4
- with:
- name: code-zap-front
- path: ${{ env.frontend-directory }}/dist/**
-
- deploy:
- needs: build
- runs-on:
- - self-hosted
- - spring
- - develop
- steps:
- - name: 아티팩트 디렉토리 생성
- run: |
- rm -rf ${{ secrets.FRONT_DIRECTORY }}
- mkdir ${{ secrets.FRONT_DIRECTORY }}
- - name: 아티팩트 다운로드
- uses: actions/download-artifact@v4
- with:
- name: code-zap-front
- path: ${{ secrets.FRONT_DIRECTORY }}
- - name: S3로 이동
- run: |
- if [ "${{ github.ref_name }}" == "main" ]; then
- aws s3 cp --recursive ${{ secrets.FRONT_DIRECTORY }} s3://techcourse-project-2024/code-zap
- else
- aws s3 cp --recursive ${{ secrets.FRONT_DIRECTORY }} s3://techcourse-project-2024/code-zap-staging
- fi
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d898aaa7d..47f7ed2ca 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -71,6 +71,7 @@
"undici": "^6.19.2",
"util": "^0.12.5",
"webpack": "^5.92.1",
+ "webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
}
@@ -4331,6 +4332,12 @@
"node": ">=18"
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.28",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
+ "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==",
+ "dev": true
+ },
"node_modules/@remix-run/router": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.1.tgz",
@@ -9397,6 +9404,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/debounce": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
+ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
+ "dev": true
+ },
"node_modules/debug": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
@@ -9817,6 +9830,12 @@
"webpack": "^4 || ^5"
}
},
+ "node_modules/duplexer": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
+ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
+ "dev": true
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -12010,6 +12029,21 @@
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
+ "node_modules/gzip-size": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
+ "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
+ "dev": true,
+ "dependencies": {
+ "duplexer": "^0.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/handle-thing": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
@@ -15943,6 +15977,15 @@
"node": ">=0.4.0"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
+ "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -16619,6 +16662,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/opener": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
+ "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+ "dev": true,
+ "bin": {
+ "opener": "bin/opener-bin.js"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -18566,6 +18618,20 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true
},
+ "node_modules/sirv": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
+ "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
+ "dev": true,
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -20025,6 +20091,15 @@
"node": ">=0.6"
}
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
@@ -20976,6 +21051,86 @@
}
}
},
+ "node_modules/webpack-bundle-analyzer": {
+ "version": "4.10.2",
+ "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
+ "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
+ "dev": true,
+ "dependencies": {
+ "@discoveryjs/json-ext": "0.5.7",
+ "acorn": "^8.0.4",
+ "acorn-walk": "^8.0.0",
+ "commander": "^7.2.0",
+ "debounce": "^1.2.1",
+ "escape-string-regexp": "^4.0.0",
+ "gzip-size": "^6.0.0",
+ "html-escaper": "^2.0.2",
+ "opener": "^1.5.2",
+ "picocolors": "^1.0.0",
+ "sirv": "^2.0.3",
+ "ws": "^7.3.1"
+ },
+ "bin": {
+ "webpack-bundle-analyzer": "lib/bin/analyzer.js"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/acorn": {
+ "version": "8.12.1",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
+ "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/acorn-walk": {
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+ "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/ws": {
+ "version": "7.5.10",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
+ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.3.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/webpack-cli": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 7ea3db807..04a0ce40d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "code-zap",
- "version": "1.1.2",
+ "version": "1.1.3",
"description": "",
"main": "index.js",
"scripts": {
@@ -10,12 +10,16 @@
"dev": "webpack-dev-server --config webpack.dev.js --open",
"tsc": "tsc --noEmit",
"build": "webpack --mode production --config webpack.prod.js",
+ "build:report": "webpack-bundle-analyzer --port 8888 dist/bundle-stats.json",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"lint:style": "stylelint '**/style.ts' --fix"
},
"keywords": [],
"author": "",
+ "sideEffects": [
+ "./src/routes/router.tsx"
+ ],
"license": "ISC",
"dependencies": {
"@emotion/react": "^11.11.4",
@@ -80,6 +84,7 @@
"undici": "^6.19.2",
"util": "^0.12.5",
"webpack": "^5.92.1",
+ "webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
},
diff --git a/frontend/playwright/tests/search.spec.ts b/frontend/playwright/tests/search.spec.ts
index cf37599fb..aeaa61ced 100644
--- a/frontend/playwright/tests/search.spec.ts
+++ b/frontend/playwright/tests/search.spec.ts
@@ -12,7 +12,7 @@ test('검색창에 `검색테스트`를 입력하면 `검색테스트`가 내용
await searchTemplates({ page, keyword });
- await waitForSuccess({ page, apiUrl: '/templates?keyword' });
+ await waitForSuccess({ page, apiUrl: '/templates/login?keyword' });
await expect(page.getByRole('link', { name: /검색테스트/ })).toBeVisible();
});
@@ -23,6 +23,6 @@ test('검색창에 `ㅁㅅㅌㅇ`를 입력할 경우 `검색 결과가 없습
await searchTemplates({ page, keyword });
- await waitForSuccess({ page, apiUrl: '/templates?keyword' });
+ await waitForSuccess({ page, apiUrl: '/templates/login?keyword' });
await expect(page.locator('div').filter({ hasText: /^검색 결과가 없습니다\.$/ })).toBeVisible();
});
diff --git a/frontend/playwright/tests/templates.actions.ts b/frontend/playwright/tests/templates.actions.ts
index 9824a6645..d722c4c3f 100644
--- a/frontend/playwright/tests/templates.actions.ts
+++ b/frontend/playwright/tests/templates.actions.ts
@@ -35,7 +35,7 @@ export const uploadTemplateToCodezap = async ({
}
// 파일명 입력
- await page.getByPlaceholder('파일명.js').fill(fileName);
+ await page.getByPlaceholder('파일명.[확장자]').fill(fileName);
// 코드 입력
await page
diff --git a/frontend/playwright/tests/templates.spec.ts b/frontend/playwright/tests/templates.spec.ts
index c1f04a18f..774454c6e 100644
--- a/frontend/playwright/tests/templates.spec.ts
+++ b/frontend/playwright/tests/templates.spec.ts
@@ -35,7 +35,7 @@ test('템플릿 제목, 설명, 파일명, 소스코드, 태그를 입력하고
tag: testTitle,
});
- const templateCard = page.getByRole('link', { name: `testTitle` }).first();
+ const templateCard = page.getByRole('link', { name: testTitle }).first();
await expect(templateCard).toBeVisible();
} catch (error) {
@@ -50,7 +50,7 @@ test('템플릿 카드를 누르면 템플릿 제목, 설명, 작성자, 생성
}) => {
await page.goto('/my-templates');
// 템플릿 목록
- await waitForSuccess({ page, apiUrl: '/templates' });
+ await waitForSuccess({ page, apiUrl: '/templates/login?keyword' });
const templateCard = page.getByRole('link', { name: '상세조회테스트' });
diff --git a/frontend/public/index.html b/frontend/public/index.html
index ff20b8126..27a121bd5 100644
--- a/frontend/public/index.html
+++ b/frontend/public/index.html
@@ -1,6 +1,8 @@
+
+
void;
+ currentValue: Category;
+ handleCurrentValue: (newValue: Category) => void;
+ getOptionLabel: (category: Category) => string;
+ createNewCategory: (categoryName: string) => Promise;
+ dropdownRef: React.MutableRefObject;
+ isPending: boolean;
+}
+
+const CategoryDropdown = ({
+ options,
+ isOpen,
+ toggleDropdown,
+ currentValue,
+ handleCurrentValue,
+ getOptionLabel,
+ createNewCategory,
+ dropdownRef,
+ isPending,
+}: Props) => {
+ const {
+ value: categoryInputValue,
+ errorMessage: categoryInputErrorMessage,
+ handleChange: handleCategoryInputChange,
+ } = useInputWithValidate('', validateCategoryName);
+
+ const handleNewCategory = async (e: React.KeyboardEvent) => {
+ if (!(e.target instanceof HTMLInputElement) || e.key !== 'Enter' || e.nativeEvent.isComposing === true) {
+ return;
+ }
+
+ if (categoryInputErrorMessage !== '') {
+ return;
+ }
+
+ const inputValue = e.target.value;
+
+ if (inputValue === '') {
+ return;
+ }
+
+ await createNewCategory(inputValue);
+
+ e.target.value = '';
+ };
+
+ return (
+ <>
+
+
+ }
+ />
+ >
+ );
+};
+
+export default CategoryDropdown;
diff --git a/frontend/src/components/CategoryGuide/CategoryGuide.tsx b/frontend/src/components/CategoryGuide/CategoryGuide.tsx
new file mode 100644
index 000000000..3484a6842
--- /dev/null
+++ b/frontend/src/components/CategoryGuide/CategoryGuide.tsx
@@ -0,0 +1,23 @@
+import { Guide, Text } from '@/components';
+import { theme } from '@/style/theme';
+
+interface Props {
+ isOpen: boolean;
+ categoryErrorMessage: string;
+}
+
+const CategoryGuide = ({ isOpen, categoryErrorMessage }: Props) => {
+ const isError = categoryErrorMessage !== '';
+
+ return (
+
+ {isError ? (
+ {categoryErrorMessage}
+ ) : (
+ 엔터로 카테고리를 등록해요
+ )}
+
+ );
+};
+
+export default CategoryGuide;
diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx
index 4cc6b91c3..0d275c604 100644
--- a/frontend/src/components/Footer/Footer.tsx
+++ b/frontend/src/components/Footer/Footer.tsx
@@ -1,23 +1,32 @@
import { Text } from '@/components';
+import { useAuth } from '@/hooks/authentication';
import * as S from './Footer.style';
-const Footer = () => (
-
-
- Copyright{' '}
-
- Codezap
- {' '}
- © All rights reserved.
-
-
-
- 문의 :
- {' '}
- codezap2024@gmail.com{' '}
-
-
-);
+const Footer = () => {
+ const { isChecking } = useAuth();
+
+ if (isChecking) {
+ return null;
+ }
+
+ return (
+
+
+ Copyright{' '}
+
+ Codezap
+ {' '}
+ © All rights reserved.
+
+
+
+ 문의 :
+ {' '}
+ codezap2024@gmail.com{' '}
+
+
+ );
+};
export default Footer;
diff --git a/frontend/src/components/Header/Header.style.ts b/frontend/src/components/Header/Header.style.ts
index 763bb6175..eebcf31fa 100644
--- a/frontend/src/components/Header/Header.style.ts
+++ b/frontend/src/components/Header/Header.style.ts
@@ -99,7 +99,7 @@ export const MobileMenuContainer = styled.div`
}
`;
-export const HamburgerIconWrapper = styled.div`
+export const HamburgerIconWrapper = styled.button`
display: none;
@media (max-width: 768px) {
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx
index 065b14156..afd2ff704 100644
--- a/frontend/src/components/Header/Header.tsx
+++ b/frontend/src/components/Header/Header.tsx
@@ -1,10 +1,10 @@
import { useEffect } from 'react';
-import { Link, useLocation, useNavigate } from 'react-router-dom';
+import { Link, useLocation } from 'react-router-dom';
import { CodeZapLogo, HamburgerIcon, PlusIcon } from '@/assets/images';
import { Button, Flex, Heading, Text } from '@/components';
import { ToastContext } from '@/contexts';
-import { useCustomContext, useToggle } from '@/hooks';
+import { useCustomContext, useCustomNavigate, useToggle } from '@/hooks';
import { useAuth } from '@/hooks/authentication/useAuth';
import { usePressESC } from '@/hooks/usePressESC';
import { useScrollDisable } from '@/hooks/useScrollDisable';
@@ -19,7 +19,7 @@ const Header = ({ headerRef }: { headerRef: React.RefObject }) =
const [menuOpen, toggleMenu] = useToggle();
const { failAlert } = useCustomContext(ToastContext);
const location = useLocation();
- const navigate = useNavigate();
+ const navigate = useCustomNavigate();
useScrollDisable(menuOpen);
usePressESC(menuOpen, toggleMenu);
@@ -64,16 +64,24 @@ const Header = ({ headerRef }: { headerRef: React.RefObject }) =
weight='bold'
hoverStyle='none'
onClick={handleTemplateUploadButton}
+ aria-description='템플릿 작성 페이지로 이동됩니다.'
>
- 새 템플릿
+ 새 템플릿
{!isChecking && isLogin ? : }
-
@@ -125,7 +133,7 @@ const LoginButton = () => (
);
const HeaderMenuButton = ({ menuOpen, toggleMenu }: { menuOpen: boolean; toggleMenu: () => void }) => (
-
+
);
diff --git a/frontend/src/components/Input/Input.style.ts b/frontend/src/components/Input/Input.style.ts
index 5d1b6cfa1..67d9d637b 100644
--- a/frontend/src/components/Input/Input.style.ts
+++ b/frontend/src/components/Input/Input.style.ts
@@ -2,6 +2,7 @@ import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { theme } from '@/style/theme';
+
import type { BaseProps, TextFieldProps } from './Input';
const sizes = {
@@ -144,6 +145,12 @@ export const Adornment = styled.div`
width: 1.5rem;
height: 1.5rem;
`}
+
+ ${({ as }) =>
+ as === 'button' &&
+ css`
+ cursor: pointer;
+ `}
`;
export const HelperText = styled.span`
diff --git a/frontend/src/components/Input/Input.tsx b/frontend/src/components/Input/Input.tsx
index 41ea5cc13..4bab9f54e 100644
--- a/frontend/src/components/Input/Input.tsx
+++ b/frontend/src/components/Input/Input.tsx
@@ -23,7 +23,9 @@ export interface TextFieldProps extends InputHTMLAttributes {
export interface LabelProps extends LabelHTMLAttributes {}
-export interface AdornmentProps extends HTMLAttributes {}
+export interface AdornmentProps extends HTMLAttributes {
+ as?: 'div' | 'button';
+}
export interface HelperTextProps extends HTMLAttributes {}
@@ -43,12 +45,15 @@ const TextField = ({ ...rests }: TextFieldProps) => ;
const Label = ({ children, ...rests }: PropsWithChildren) => {children};
-const Adornment = ({ children, ...rests }: PropsWithChildren) => (
-
- {children}
-
-);
+const Adornment = ({ children, as, ...rests }: PropsWithChildren) => {
+ const buttonProps = as === 'button' ? { type: 'button' } : {};
+ return (
+
+ {children}
+
+ );
+};
const HelperText = ({ children, ...rests }: PropsWithChildren) => (
{children}
);
diff --git a/frontend/src/components/LoadingFallback/LoadingFallback.style.ts b/frontend/src/components/LoadingFallback/LoadingFallback.style.ts
new file mode 100644
index 000000000..721106631
--- /dev/null
+++ b/frontend/src/components/LoadingFallback/LoadingFallback.style.ts
@@ -0,0 +1,10 @@
+import styled from '@emotion/styled';
+
+export const FallbackContainer = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ width: 100vw;
+ height: 100vh;
+`;
diff --git a/frontend/src/components/LoadingFallback/LoadingFallback.tsx b/frontend/src/components/LoadingFallback/LoadingFallback.tsx
new file mode 100644
index 000000000..baab22323
--- /dev/null
+++ b/frontend/src/components/LoadingFallback/LoadingFallback.tsx
@@ -0,0 +1,10 @@
+import LoadingBall from '../LoadingBall/LoadingBall';
+import * as S from './LoadingFallback.style';
+
+const LoadingFallback = () => (
+
+
+
+);
+
+export default LoadingFallback;
diff --git a/frontend/src/components/NewCategoryInput/NewCategoryInput.tsx b/frontend/src/components/NewCategoryInput/NewCategoryInput.tsx
new file mode 100644
index 000000000..f84dd4919
--- /dev/null
+++ b/frontend/src/components/NewCategoryInput/NewCategoryInput.tsx
@@ -0,0 +1,33 @@
+import { Input, LoadingBall } from '@/components';
+import { useLoaderDelay } from '@/hooks';
+import { theme } from '@/style/theme';
+
+interface Props {
+ value: string;
+ onEnterDown: (e: React.KeyboardEvent) => void;
+ onChange: (e: React.ChangeEvent, compareValue?: string) => void;
+ isPending: boolean;
+}
+
+const NewCategoryInput = ({ value, onChange, onEnterDown, isPending }: Props) => {
+ const showLoader = useLoaderDelay(isPending, 700);
+
+ return (
+
+ {showLoader ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default NewCategoryInput;
diff --git a/frontend/src/components/NonmemberAlerter/NonmemberAlerter.tsx b/frontend/src/components/NonmemberAlerter/NonmemberAlerter.tsx
index 15aba2677..6aa154551 100644
--- a/frontend/src/components/NonmemberAlerter/NonmemberAlerter.tsx
+++ b/frontend/src/components/NonmemberAlerter/NonmemberAlerter.tsx
@@ -1,6 +1,5 @@
-import { useNavigate } from 'react-router-dom';
-
import { Button, Flex, Modal, Text } from '@/components';
+import { useCustomNavigate } from '@/hooks';
import { END_POINTS } from '@/routes';
import { theme } from '@/style/theme';
@@ -11,7 +10,7 @@ interface Props {
}
const NonmemberAlerter = ({ isOpen, content, toggleModal }: Props) => {
- const navigate = useNavigate();
+ const navigate = useCustomNavigate();
const handleLoginButtonClick = () => {
navigate(END_POINTS.LOGIN);
diff --git a/frontend/src/components/ScreenReaderOnly/ScreenReaderOnly.tsx b/frontend/src/components/ScreenReaderOnly/ScreenReaderOnly.tsx
new file mode 100644
index 000000000..3a49d12f3
--- /dev/null
+++ b/frontend/src/components/ScreenReaderOnly/ScreenReaderOnly.tsx
@@ -0,0 +1,21 @@
+const ScreenReaderOnly = () => (
+
+);
+
+export default ScreenReaderOnly;
diff --git a/frontend/src/components/SourceCode/SourceCode.style.ts b/frontend/src/components/SourceCode/SourceCode.style.ts
new file mode 100644
index 000000000..23f3af870
--- /dev/null
+++ b/frontend/src/components/SourceCode/SourceCode.style.ts
@@ -0,0 +1,11 @@
+import { EditorView } from '@uiw/react-codemirror';
+
+export const CustomEditorTheme = EditorView.theme({
+ '.cm-activeLine': { backgroundColor: `rgba(0, 0, 0, 0.1) !important` },
+ '.cm-activeLineGutter': { backgroundColor: `rgba(0, 0, 0, 0.1) !important` },
+});
+
+export const CustomViewerTheme = EditorView.theme({
+ '.cm-activeLine': { backgroundColor: `transparent !important` },
+ '.cm-activeLineGutter': { backgroundColor: `transparent !important` },
+});
diff --git a/frontend/src/components/SourceCode/SourceCode.tsx b/frontend/src/components/SourceCode/SourceCode.tsx
new file mode 100644
index 000000000..fa21f914b
--- /dev/null
+++ b/frontend/src/components/SourceCode/SourceCode.tsx
@@ -0,0 +1,60 @@
+import { ViewUpdate } from '@codemirror/view';
+import { type LanguageName, loadLanguage } from '@uiw/codemirror-extensions-langs';
+import { quietlight } from '@uiw/codemirror-theme-quietlight';
+import ReactCodeMirror, { EditorView, ReactCodeMirrorRef } from '@uiw/react-codemirror';
+import { useRef } from 'react';
+
+import * as S from './SourceCode.style';
+
+export type SourceCodeMode = 'edit' | 'detailView' | 'thumbnailView';
+
+interface Props {
+ mode: SourceCodeMode;
+ language: string;
+ content: string;
+ handleContentChange?: (value: string, viewUpdate: ViewUpdate) => void;
+}
+
+const SourceCode = ({ mode = 'detailView', language, content, handleContentChange }: Props) => {
+ const codeMirrorRef = useRef(null);
+
+ const focusCodeMirror = () => {
+ if (!codeMirrorRef.current) {
+ return;
+ }
+
+ if (codeMirrorRef.current?.view) {
+ codeMirrorRef.current.view.focus();
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default SourceCode;
diff --git a/frontend/src/components/SourceCodeEditor/SourceCodeEditor.style.ts b/frontend/src/components/SourceCodeEditor/SourceCodeEditor.style.ts
index e0d7572f6..3fc573047 100644
--- a/frontend/src/components/SourceCodeEditor/SourceCodeEditor.style.ts
+++ b/frontend/src/components/SourceCodeEditor/SourceCodeEditor.style.ts
@@ -1,16 +1,20 @@
import styled from '@emotion/styled';
-import { EditorView } from '@uiw/react-codemirror';
+import { Button } from '@/components';
import { theme } from '@/style/theme';
export const SourceCodeEditorContainer = styled.div`
+ position: relative;
+
overflow: hidden;
+
width: 100%;
height: 100%;
+
border-radius: 8px;
`;
-export const SourceCodeFileNameInput = styled.input`
+export const FilenameInput = styled.input`
width: 100%;
height: 3rem;
padding: 1rem 1.5rem;
@@ -33,7 +37,9 @@ export const SourceCodeFileNameInput = styled.input`
}
`;
-export const CustomCodeMirrorTheme = EditorView.theme({
- '.cm-activeLine': { backgroundColor: `rgba(0, 0, 0, 0.1) !important` },
- '.cm-activeLineGutter': { backgroundColor: `rgba(0, 0, 0, 0.1) !important` },
-});
+export const DeleteButton = styled(Button)`
+ position: absolute;
+ top: 0.3rem;
+ right: 0.4rem;
+ height: 2.4rem;
+`;
diff --git a/frontend/src/components/SourceCodeEditor/SourceCodeEditor.tsx b/frontend/src/components/SourceCodeEditor/SourceCodeEditor.tsx
index e6cfb82e9..b101cf1c2 100644
--- a/frontend/src/components/SourceCodeEditor/SourceCodeEditor.tsx
+++ b/frontend/src/components/SourceCodeEditor/SourceCodeEditor.tsx
@@ -1,93 +1,74 @@
import { ViewUpdate } from '@codemirror/view';
-import { type LanguageName, loadLanguage } from '@uiw/codemirror-extensions-langs';
-import { quietlight } from '@uiw/codemirror-theme-quietlight';
-import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
-import { ChangeEvent, useRef } from 'react';
+import { useRef } from 'react';
-import { ToastContext } from '@/contexts';
-import { useCustomContext } from '@/hooks';
-import { validateFileName, validateSourceCode } from '@/service';
+import { TrashcanIcon } from '@/assets/images';
+import { SourceCode } from '@/components';
import { getLanguageByFilename } from '@/utils';
import * as S from './SourceCodeEditor.style';
interface Props {
- index: number;
- fileName: string;
+ filename: string;
+ filenameAutoFocus?: boolean;
content: string;
+ onChangeFilename: (newFileName: string) => void;
onChangeContent: (newContent: string) => void;
- onChangeFileName: (newFileName: string) => void;
+ isValidContentChange?: (newContent: string) => boolean;
+ handleDeleteSourceCode: () => void;
+ sourceCodeRef?: React.Ref | null;
}
-const SourceCodeEditor = ({ index, fileName, content, onChangeContent, onChangeFileName }: Props) => {
- const codeMirrorRef = useRef(null);
+const SourceCodeEditor = ({
+ filename,
+ filenameAutoFocus = false,
+ content,
+ onChangeFilename,
+ onChangeContent,
+ isValidContentChange = () => true,
+ handleDeleteSourceCode,
+ sourceCodeRef = null,
+}: Props) => {
const previousContentRef = useRef(content);
- const { failAlert } = useCustomContext(ToastContext);
- const focusCodeMirror = () => {
- if (!codeMirrorRef.current) {
- return;
- }
-
- if (codeMirrorRef.current?.view) {
- codeMirrorRef.current.view.focus();
- }
- };
-
- const handleFileNameChange = (event: ChangeEvent) => {
- const newFileName = event.target.value;
-
- const errorMessage = validateFileName(newFileName);
-
- if (errorMessage) {
- failAlert(errorMessage);
-
- return;
- }
-
- onChangeFileName(newFileName);
+ const handleFilenameChange = (event: React.ChangeEvent) => {
+ onChangeFilename(event.target.value);
};
const handleContentChange = (value: string, viewUpdate: ViewUpdate) => {
- const errorMessage = validateSourceCode(value);
-
- if (errorMessage) {
- failAlert(errorMessage);
+ const isValid = isValidContentChange(value);
+ if (!isValid) {
const previousContent = previousContentRef.current;
const transaction = viewUpdate.state.update({
changes: { from: 0, to: value.length, insert: previousContent },
});
viewUpdate.view.dispatch(transaction);
- } else {
- onChangeContent(value);
- previousContentRef.current = value;
+
+ return;
}
+
+ onChangeContent(value);
+ previousContentRef.current = value;
};
return (
-
-
+
-
+
+
+
);
};
diff --git a/frontend/src/components/SourceCodeViewer/SourceCodeViewer.style.ts b/frontend/src/components/SourceCodeViewer/SourceCodeViewer.style.ts
new file mode 100644
index 000000000..877ee1880
--- /dev/null
+++ b/frontend/src/components/SourceCodeViewer/SourceCodeViewer.style.ts
@@ -0,0 +1,68 @@
+import styled from '@emotion/styled';
+
+import { Button } from '@/components';
+
+export const SourceCodeViewerContainer = styled.div`
+ overflow: hidden;
+ width: 100%;
+ border-radius: 8px;
+`;
+
+export const FilenameContainer = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ width: 100%;
+ height: 3rem;
+ padding: 1rem 1.5rem;
+
+ background: ${({ theme }) => theme.color.light.tertiary_600};
+`;
+
+export const ToggleButton = styled.button`
+ cursor: pointer;
+
+ display: flex;
+ flex: 1;
+ gap: 0.5rem;
+ align-items: center;
+ justify-content: space-between;
+
+ min-width: 0;
+`;
+
+export const SourceCodeToggleIcon = styled.span<{ isOpen: boolean }>`
+ transform: ${({ isOpen }) => (isOpen ? 'rotate(180deg)' : 'rotate(0deg)')};
+ width: 16px;
+ height: 16px;
+ transition: transform 0.3s ease;
+`;
+
+export const NoScrollbarContainer = styled.div`
+ scrollbar-width: none;
+
+ overflow: auto;
+
+ width: 100%;
+
+ text-align: start;
+
+ -ms-overflow-style: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+`;
+
+export const CopyButton = styled(Button)`
+ width: auto;
+ min-width: fit-content;
+ padding: 0.25rem 0.5rem;
+ white-space: nowrap;
+`;
+
+export const SourceCodeWrapper = styled.div<{ isOpen: boolean }>`
+ overflow: hidden;
+ max-height: ${({ isOpen }) => (isOpen ? '1000rem' : '0')};
+ animation: ${({ isOpen }) => (!isOpen ? 'collapse' : 'expand')} 0.7s ease-in-out forwards;
+`;
diff --git a/frontend/src/components/SourceCodeViewer/SourceCodeViewer.tsx b/frontend/src/components/SourceCodeViewer/SourceCodeViewer.tsx
new file mode 100644
index 000000000..501306bf7
--- /dev/null
+++ b/frontend/src/components/SourceCodeViewer/SourceCodeViewer.tsx
@@ -0,0 +1,59 @@
+import { ChevronIcon } from '@/assets/images';
+import { SourceCode, Text } from '@/components';
+import { useToggle } from '@/hooks';
+import { useToast } from '@/hooks/useToast';
+import { theme } from '@/style/theme';
+import { getLanguageByFilename } from '@/utils';
+
+import { SourceCodeMode } from '../SourceCode/SourceCode';
+import * as S from './SourceCodeViewer.style';
+
+interface Props {
+ mode?: Exclude;
+ filename?: string;
+ content: string;
+ sourceCodeRef?: React.Ref | null;
+}
+
+const SourceCodeViewer = ({ mode = 'detailView', filename = '', content, sourceCodeRef }: Props) => {
+ const [isSourceCodeOpen, toggleSourceCode] = useToggle(true);
+
+ const { infoAlert } = useToast();
+
+ const copyCode = (content: string) => () => {
+ navigator.clipboard.writeText(content);
+ infoAlert('코드가 복사되었습니다!');
+ };
+
+ return (
+
+ {mode === 'detailView' && (
+
+
+
+
+
+
+
+ {filename}
+
+
+
+
+
+ {'복사'}
+
+
+
+ )}
+
+ {isSourceCodeOpen && }
+
+
+ );
+};
+
+export default SourceCodeViewer;
diff --git a/frontend/src/components/TagInput/TagInput.tsx b/frontend/src/components/TagInput/TagInput.tsx
index 04fbf8bd0..ce131eaa5 100644
--- a/frontend/src/components/TagInput/TagInput.tsx
+++ b/frontend/src/components/TagInput/TagInput.tsx
@@ -2,10 +2,9 @@ import { ChangeEvent, Dispatch, KeyboardEvent, SetStateAction } from 'react';
import { Flex, Input, TagButton, Text } from '@/components';
import { ToastContext } from '@/contexts';
-import { useCustomContext } from '@/hooks';
+import { useCustomContext, useScreenReader } from '@/hooks';
import { validateTagLength } from '@/service/validates';
import { theme } from '@/style/theme';
-import { removeAllWhitespace } from '@/utils/removeAllWhitespace';
interface Props {
value: string;
@@ -17,6 +16,7 @@ interface Props {
const TagInput = ({ value, handleValue, resetValue, tags, setTags }: Props) => {
const { failAlert } = useCustomContext(ToastContext);
+ const { updateScreenReaderMessage } = useScreenReader();
const handleSpaceBarAndEnterKeydown = (e: KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
@@ -27,17 +27,12 @@ const TagInput = ({ value, handleValue, resetValue, tags, setTags }: Props) => {
};
const addTag = () => {
- const newTag = removeAllWhitespace(value);
-
- if (newTag === '') {
- return;
- }
-
- if (tags.includes(newTag)) {
+ if (value === '' || tags.includes(value)) {
return;
}
- setTags((prev) => [...prev, newTag]);
+ setTags((prev) => [...prev, value]);
+ updateScreenReaderMessage(`${value} 태그 등록`);
};
const handleTagInput = (e: ChangeEvent) => {
diff --git a/frontend/src/components/TemplateCard/TemplateCard.style.ts b/frontend/src/components/TemplateCard/TemplateCard.style.ts
index 71b8112db..7fe3fa09c 100644
--- a/frontend/src/components/TemplateCard/TemplateCard.style.ts
+++ b/frontend/src/components/TemplateCard/TemplateCard.style.ts
@@ -8,6 +8,7 @@ export const TemplateCardContainer = styled.div`
display: flex;
flex-direction: column;
+ gap: 1rem;
justify-content: space-between;
box-sizing: border-box;
diff --git a/frontend/src/components/TemplateCard/TemplateCard.tsx b/frontend/src/components/TemplateCard/TemplateCard.tsx
index 3dfecd4ed..f9ed1df91 100644
--- a/frontend/src/components/TemplateCard/TemplateCard.tsx
+++ b/frontend/src/components/TemplateCard/TemplateCard.tsx
@@ -1,13 +1,8 @@
-import { type LanguageName, loadLanguage } from '@uiw/codemirror-extensions-langs';
-import { quietlight } from '@uiw/codemirror-theme-quietlight';
-import CodeMirror, { EditorView } from '@uiw/react-codemirror';
-
import { ClockIcon, PersonIcon } from '@/assets/images';
-import { Button, Flex, LikeCounter, TagButton, Text } from '@/components';
+import { Button, Flex, LikeCounter, TagButton, Text, SourceCodeViewer } from '@/components';
import { useToggle } from '@/hooks';
import { theme } from '@/style/theme';
import type { Tag, TemplateListItem } from '@/types';
-import { getLanguageByFilename } from '@/utils';
import { formatRelativeTime } from '@/utils/formatRelativeTime';
import * as S from './TemplateCard.style';
@@ -76,28 +71,8 @@ const TemplateCard = ({ template }: Props) => {
-
+
+
{tags.map((tag: Tag) => (
diff --git a/frontend/src/components/Toast/Toast.tsx b/frontend/src/components/Toast/Toast.tsx
index 073506517..ec72a6270 100644
--- a/frontend/src/components/Toast/Toast.tsx
+++ b/frontend/src/components/Toast/Toast.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
import * as S from './Toast.style';
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
index 964dc7782..6f665349e 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -11,7 +11,9 @@ export { default as Modal } from './Modal/Modal';
export { default as NonmemberAlerter } from './NonmemberAlerter/NonmemberAlerter';
export { default as PagingButtons } from './PagingButtons/PagingButtons';
export { default as SelectList } from './SelectList/SelectList';
+export { default as SourceCode } from './SourceCode/SourceCode';
export { default as SourceCodeEditor } from './SourceCodeEditor/SourceCodeEditor';
+export { default as SourceCodeViewer } from './SourceCodeViewer/SourceCodeViewer';
export { default as TagButton } from './TagButton/TagButton';
export { default as TagInput } from './TagInput/TagInput';
export { default as TemplateCard } from './TemplateCard/TemplateCard';
@@ -19,7 +21,14 @@ export { default as Text } from './Text/Text';
export { default as Toast } from './Toast/Toast';
export { default as Guide } from './Guide/Guide';
export { default as Footer } from './Footer/Footer';
+export { default as CategoryDropdown } from './CategoryDropdown/CategoryDropdown';
+export { default as CategoryGuide } from './CategoryGuide/CategoryGuide';
+export { default as NewCategoryInput } from './NewCategoryInput/NewCategoryInput';
export { default as NoSearchResults } from './NoSearchResults/NoSearchResults';
// Skeleton UI
export { default as LoadingBall } from './LoadingBall/LoadingBall';
+export { default as LoadingFallback } from './LoadingFallback/LoadingFallback';
+
+// ScreenReader
+export { default as ScreenReaderOnly } from './ScreenReaderOnly/ScreenReaderOnly';
diff --git a/frontend/src/hooks/authentication/useAuth.ts b/frontend/src/hooks/authentication/useAuth.ts
index 57d9f31ea..4283b8d86 100644
--- a/frontend/src/hooks/authentication/useAuth.ts
+++ b/frontend/src/hooks/authentication/useAuth.ts
@@ -1,13 +1,5 @@
-import { useContext } from 'react';
-
import { AuthContext } from '@/contexts';
-export const useAuth = () => {
- const context = useContext(AuthContext);
-
- if (!context) {
- throw new Error('useAuth must be used within an AuthProvider');
- }
+import { useCustomContext } from '../useCustomContext';
- return context;
-};
+export const useAuth = () => useCustomContext(AuthContext);
diff --git a/frontend/src/hooks/category/useCategory.ts b/frontend/src/hooks/category/useCategory.ts
index 1138660a8..4d8f47d3c 100644
--- a/frontend/src/hooks/category/useCategory.ts
+++ b/frontend/src/hooks/category/useCategory.ts
@@ -1,4 +1,4 @@
-import { useCategoryListQuery } from '@/queries/categories';
+import { useCategoryListQuery, useCategoryUploadMutation } from '@/queries/categories';
import type { Category } from '@/types';
import { useDropdown } from '../';
@@ -12,8 +12,35 @@ export const useCategory = (initCategory?: Category) => {
}
const { isOpen, toggleDropdown, currentValue, handleCurrentValue, dropdownRef } = useDropdown(initCategory);
+ const { mutateAsync: postCategory, isPending } = useCategoryUploadMutation(handleCurrentValue);
const getOptionLabel = (category: Category) => category.name;
- return { options, isOpen, toggleDropdown, currentValue, handleCurrentValue, getOptionLabel, dropdownRef };
+ const getExistingCategory = (value: string) => options.find((category) => getOptionLabel(category) === value);
+
+ const createNewCategory = async (categoryName: string) => {
+ const existingCategory = getExistingCategory(categoryName);
+
+ if (existingCategory) {
+ handleCurrentValue(existingCategory);
+
+ return;
+ }
+
+ const newCategory = { name: categoryName };
+
+ await postCategory(newCategory);
+ };
+
+ return {
+ options,
+ isOpen,
+ toggleDropdown,
+ currentValue,
+ handleCurrentValue,
+ getOptionLabel,
+ createNewCategory,
+ dropdownRef,
+ isPending,
+ };
};
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index afcfc30f4..108c02ca0 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -4,8 +4,11 @@ export { useDropdown } from './useDropdown';
export { useHeaderHeight } from './useHeaderHeight';
export { useInput } from './useInput';
export { useInputWithValidate } from './useInputWithValidate';
+export { useScreenReader } from './useScreenReader';
export { useScrollToTargetElement } from './useScrollToTargetElement';
export { useWindowWidth } from './useWindowWidth';
export { useToggle } from './useToggle';
export { useLoaderDelay } from './useLoaderDelay';
+export { useNoSpaceInput } from './useNoSpaceInput';
export { useSelectList } from './useSelectList';
+export { useCustomNavigate } from './useCustomNavigate';
diff --git a/frontend/src/hooks/template/index.ts b/frontend/src/hooks/template/index.ts
new file mode 100644
index 000000000..d071d71ba
--- /dev/null
+++ b/frontend/src/hooks/template/index.ts
@@ -0,0 +1,2 @@
+export { useSourceCode } from './useSourceCode';
+export { useTag } from './useTag';
diff --git a/frontend/src/hooks/template/useSourceCode.ts b/frontend/src/hooks/template/useSourceCode.ts
new file mode 100644
index 000000000..5e3584de4
--- /dev/null
+++ b/frontend/src/hooks/template/useSourceCode.ts
@@ -0,0 +1,93 @@
+import { useCallback, useState } from 'react';
+
+import { validateFilename, validateSourceCode } from '@/service';
+import { SourceCodes } from '@/types';
+
+import { useToast } from '../useToast';
+
+export const useSourceCode = (initSourceCode: SourceCodes[]) => {
+ const [sourceCodes, setSourceCodes] = useState([...initSourceCode]);
+ const [deleteSourceCodeIds, setDeleteSourceCodeIds] = useState([]);
+
+ const { failAlert } = useToast();
+
+ const handleFilenameChange = useCallback(
+ (newFilename: string, idx: number) => {
+ const errorMessage = validateFilename(newFilename);
+
+ if (errorMessage) {
+ failAlert(errorMessage);
+
+ return;
+ }
+
+ setSourceCodes((prevSourceCodes) =>
+ prevSourceCodes.map((sourceCodes, index) =>
+ index === idx ? { ...sourceCodes, filename: newFilename } : sourceCodes,
+ ),
+ );
+ },
+ [failAlert],
+ );
+
+ const handleContentChange = useCallback((newContent: string, idx: number) => {
+ setSourceCodes((prevSourceCodes) =>
+ prevSourceCodes.map((sourceCodes, index) =>
+ index === idx ? { ...sourceCodes, content: newContent } : sourceCodes,
+ ),
+ );
+ }, []);
+
+ const isValidContentChange = useCallback(
+ (newContent: string) => {
+ const errorMessage = validateSourceCode(newContent);
+
+ if (errorMessage) {
+ failAlert(errorMessage);
+
+ return false;
+ }
+
+ return true;
+ },
+ [failAlert],
+ );
+
+ const addNewEmptySourceCode = useCallback(() => {
+ setSourceCodes((prevSourceCode) => [
+ ...prevSourceCode,
+ {
+ filename: '',
+ content: '',
+ ordinal: prevSourceCode.length + 1,
+ },
+ ]);
+ }, []);
+
+ const handleDeleteSourceCode = useCallback(
+ (index: number) => {
+ const deletedSourceCodeId = sourceCodes[index].id;
+
+ if (!sourceCodes[index]) {
+ console.error('존재하지 않는 소스코드는 삭제할 수 없습니다. 삭제하려는 소스코드의 index를 다시 확인해주세요.');
+ }
+
+ if (deletedSourceCodeId) {
+ setDeleteSourceCodeIds((prevSourceCodeId) => [...prevSourceCodeId, deletedSourceCodeId]);
+ }
+
+ setSourceCodes((prevSourceCodes) => prevSourceCodes.filter((_, idx) => index !== idx));
+ },
+ [sourceCodes],
+ );
+
+ return {
+ sourceCodes,
+ deleteSourceCodeIds,
+ handleFilenameChange,
+ isValidContentChange,
+ handleContentChange,
+ addNewEmptySourceCode,
+ handleDeleteSourceCode,
+ };
+};
diff --git a/frontend/src/hooks/template/useTag.ts b/frontend/src/hooks/template/useTag.ts
new file mode 100644
index 000000000..5bf9f1dc9
--- /dev/null
+++ b/frontend/src/hooks/template/useTag.ts
@@ -0,0 +1,16 @@
+import { useState } from 'react';
+
+import { useNoSpaceInput } from '@/hooks';
+
+export const useTag = (initTags: string[]) => {
+ const [tags, setTags] = useState(initTags);
+ const [value, handleValue, resetValue] = useNoSpaceInput('');
+
+ return {
+ tags,
+ setTags,
+ value,
+ handleValue,
+ resetValue,
+ };
+};
diff --git a/frontend/src/hooks/useCustomNavigate.ts b/frontend/src/hooks/useCustomNavigate.ts
new file mode 100644
index 000000000..e0932a96e
--- /dev/null
+++ b/frontend/src/hooks/useCustomNavigate.ts
@@ -0,0 +1,31 @@
+import { useNavigate, useLocation, NavigateOptions, To } from 'react-router-dom';
+/**
+ * useCustomNavigate - 현재 위치와 대상 위치를 비교하여 불필요한 네비게이션을 방지합니다.
+ *
+ * @returns {Function} 커스텀 네비게이트 함수
+ * @param {To | number} to - 네비게이션 대상. 문자열 경로, 위치 객체 또는 히스토리 스택의 상대적 위치를 나타내는 숫자일 수 있습니다.
+ * @param {NavigateOptions} [options] - 네비게이션 옵션 (선택적)
+ *
+ */
+export const useCustomNavigate = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ return (to: To | number, options?: NavigateOptions) => {
+ if (typeof to === 'number') {
+ navigate(to);
+
+ return;
+ }
+
+ if (typeof to === 'string' && to !== location.pathname) {
+ navigate(to, options);
+
+ return;
+ }
+
+ if (typeof to === 'object' && to.pathname !== location.pathname) {
+ navigate(to, options);
+ }
+ };
+};
diff --git a/frontend/src/hooks/useNoSpaceInput.ts b/frontend/src/hooks/useNoSpaceInput.ts
new file mode 100644
index 000000000..423c4783d
--- /dev/null
+++ b/frontend/src/hooks/useNoSpaceInput.ts
@@ -0,0 +1,17 @@
+import { useState } from 'react';
+
+export const useNoSpaceInput = (initValue: string = '') => {
+ const [value, setValue] = useState(initValue);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const noSpaceValue = e.target.value.replace(/[\s\u3000\u115F\u1160\u2800\u3164\uFFA0\u200B\uFEFF]+/gu, '');
+
+ setValue(noSpaceValue);
+ };
+
+ const resetValue = () => {
+ setValue('');
+ };
+
+ return [value, handleChange, resetValue] as const;
+};
diff --git a/frontend/src/hooks/useScreenReader.ts b/frontend/src/hooks/useScreenReader.ts
new file mode 100644
index 000000000..fc8412e9a
--- /dev/null
+++ b/frontend/src/hooks/useScreenReader.ts
@@ -0,0 +1,19 @@
+import { useCallback } from 'react';
+
+export const useScreenReader = () => {
+ const updateScreenReaderMessage = useCallback((message: string) => {
+ const element = document.getElementById('screen-reader');
+
+ if (element) {
+ element.setAttribute('aria-hidden', 'false');
+ element.textContent = message;
+
+ setTimeout(() => {
+ element.setAttribute('aria-hidden', 'true');
+ element.textContent = '';
+ });
+ }
+ }, []);
+
+ return { updateScreenReaderMessage };
+};
diff --git a/frontend/src/hooks/useSelectList.ts b/frontend/src/hooks/useSelectList.ts
index f0471f3ad..9c05c49f0 100644
--- a/frontend/src/hooks/useSelectList.ts
+++ b/frontend/src/hooks/useSelectList.ts
@@ -1,32 +1,33 @@
-import { useRef, useState } from 'react';
-
-import { SourceCodes } from '@/types';
+import { useEffect, useRef, useState } from 'react';
import { useScrollToTargetElement } from './useScrollToTargetElement';
-export const useSelectList = (sourceCodes: SourceCodes[]) => {
+export const useSelectList = () => {
const scrollTo = useScrollToTargetElement();
- const [currentFile, setCurrentFile] = useState(null);
- const sourceCodeRefs = useRef<(HTMLDivElement | null)[]>([]);
+ const [currentOption, setCurrentOption] = useState(null);
- const handleSelectOption = (index: number) => (event: React.MouseEvent) => {
- event.preventDefault();
+ const linkedElementRefs = useRef<(HTMLDivElement | null)[]>([]);
- const targetElement = sourceCodeRefs.current[index];
+ useEffect(() => {
+ if (!currentOption) {
+ setCurrentOption(0);
+ }
+ }, [currentOption, setCurrentOption]);
- scrollTo(targetElement);
+ const scrollToLinkedElement = (index: number) => {
+ const targetLinkedElement = linkedElementRefs.current[index];
- const id = sourceCodes[index].id;
+ scrollTo(targetLinkedElement);
+ };
- if (!id) {
- console.error('id가 존재하지 않습니다.(useSelectList)');
+ const handleSelectOption = (index: number) => (e: React.MouseEvent) => {
+ e.preventDefault();
- return;
- }
+ scrollToLinkedElement(index);
- setCurrentFile(id);
+ setCurrentOption(index);
};
- return { currentFile, setCurrentFile, sourceCodeRefs, handleSelectOption };
+ return { currentOption, linkedElementRefs, handleSelectOption };
};
diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts
new file mode 100644
index 000000000..4654845af
--- /dev/null
+++ b/frontend/src/hooks/useToast.ts
@@ -0,0 +1,5 @@
+import { ToastContext } from '@/contexts';
+
+import { useCustomContext } from './useCustomContext';
+
+export const useToast = () => useCustomContext(ToastContext);
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx
index 214fca69f..d14780f80 100644
--- a/frontend/src/index.tsx
+++ b/frontend/src/index.tsx
@@ -7,6 +7,7 @@ import { RouterProvider } from 'react-router-dom';
import { AuthProvider, HeaderProvider, ToastProvider } from '@/contexts';
+import { ScreenReaderOnly } from './components/index';
import router from './routes/router';
import GlobalStyles from './style/GlobalStyles';
import { theme } from './style/theme';
@@ -49,6 +50,7 @@ enableMocking().then(() => {
+
diff --git a/frontend/src/pages/LoginPage/LoginPage.tsx b/frontend/src/pages/LoginPage/LoginPage.tsx
index 74858c580..846824cb3 100644
--- a/frontend/src/pages/LoginPage/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage/LoginPage.tsx
@@ -27,7 +27,14 @@ const LoginPage = () => {
>
아이디 (닉네임)
-
+
{errors.name}
@@ -35,12 +42,14 @@ const LoginPage = () => {
비밀번호
-
-
+
+
{errors.password}
diff --git a/frontend/src/pages/MyTemplatesPage/MyTemplatePage.tsx b/frontend/src/pages/MyTemplatesPage/MyTemplatePage.tsx
index 8062b0cb7..f067383fb 100644
--- a/frontend/src/pages/MyTemplatesPage/MyTemplatePage.tsx
+++ b/frontend/src/pages/MyTemplatesPage/MyTemplatePage.tsx
@@ -1,25 +1,11 @@
import { useState, useCallback } from 'react';
-import { useNavigate } from 'react-router-dom';
import { DEFAULT_SORTING_OPTION, SORTING_OPTIONS } from '@/api';
import { ArrowUpIcon, PlusIcon, SearchIcon } from '@/assets/images';
-import {
- Flex,
- Heading,
- Input,
- PagingButtons,
- Dropdown,
- Button,
- Modal,
- Text,
- LoadingBall,
- NoSearchResults,
-} from '@/components';
-import { useWindowWidth, useDebounce, useToggle, useDropdown, useInput } from '@/hooks';
+import { Flex, Heading, Input, PagingButtons, Dropdown, Button, Modal, Text, NoSearchResults } from '@/components';
+import { useWindowWidth, useDebounce, useToggle, useDropdown, useInput, useCustomNavigate } from '@/hooks';
import { useAuth } from '@/hooks/authentication';
-import { useCategoryListQuery } from '@/queries/categories';
-import { useTagListQuery } from '@/queries/tags';
-import { useTemplateDeleteMutation, useTemplateListQuery } from '@/queries/templates';
+import { useTemplateDeleteMutation, useTemplateCategoryTagQueries } from '@/queries/templates';
import { END_POINTS } from '@/routes';
import { theme } from '@/style/theme';
import { scroll } from '@/utils';
@@ -47,18 +33,17 @@ const MyTemplatePage = () => {
const [page, setPage] = useState(1);
- const { data: templateData, isPending } = useTemplateListQuery({
+ const [{ data: templateData }, { data: categoryData }, { data: tagData }] = useTemplateCategoryTagQueries({
keyword: debouncedKeyword,
categoryId: selectedCategoryId,
tagIds: selectedTagIds,
sort: sortingOption.key,
page,
});
- const { data: categoryData } = useCategoryListQuery();
- const { data: tagData } = useTagListQuery();
- const templates = templateData?.templates || [];
- const categories = categoryData?.categories || [];
- const tags = tagData?.tags || [];
+
+ const templateList = templateData?.templates || [];
+ const categoryList = categoryData?.categories || [];
+ const tagList = tagData?.tags || [];
const totalPages = templateData?.totalPages || 0;
const { mutateAsync: deleteTemplates } = useTemplateDeleteMutation(selectedList);
@@ -84,13 +69,13 @@ const MyTemplatePage = () => {
};
const handleAllSelected = () => {
- if (selectedList.length === templates.length) {
+ if (selectedList.length === templateList.length) {
setSelectedList([]);
return;
}
- setSelectedList(templates.map((template) => template.id));
+ setSelectedList(templateList.map((template) => template.id));
};
const handleDelete = () => {
@@ -100,11 +85,7 @@ const MyTemplatePage = () => {
};
const renderTemplateContent = () => {
- if (isPending) {
- return ;
- }
-
- if (templates.length === 0) {
+ if (templateList.length === 0) {
if (debouncedKeyword !== '') {
return ;
} else {
@@ -114,7 +95,7 @@ const MyTemplatePage = () => {
return (
{
-
+
@@ -139,7 +120,7 @@ const MyTemplatePage = () => {
돌아가기
- {selectedList.length === templates.length ? '전체 해제' : '전체 선택'}
+ {selectedList.length === templateList.length ? '전체 해제' : '전체 선택'}
{
getOptionLabel={(option) => option.value}
/>
- {tags.length !== 0 && (
-
+ {tagList.length !== 0 && (
+
)}
{renderTemplateContent()}
- {templates.length !== 0 && (
+ {templateList.length !== 0 && (
@@ -187,32 +168,15 @@ const MyTemplatePage = () => {
{isDeleteModalOpen && (
-
-
-
-
- 정말 삭제하시겠습니까?
-
- 삭제된 템플릿은 복구할 수 없습니다.
-
-
-
- 취소
-
- 삭제
-
-
-
+
)}
- {
- scroll.top('smooth');
- }}
- >
-
-
+
);
};
@@ -233,7 +197,7 @@ const TopBanner = ({ name }: TopBannerProps) => (
);
const NewTemplateButton = () => {
- const navigate = useNavigate();
+ const navigate = useCustomNavigate();
return (
navigate(END_POINTS.TEMPLATES_UPLOAD)}>
@@ -246,3 +210,38 @@ const NewTemplateButton = () => {
};
export default MyTemplatePage;
+
+interface ConfirmDeleteModalProps {
+ isDeleteModalOpen: boolean;
+ toggleDeleteModal: () => void;
+ handleDelete: () => void;
+}
+
+const ConfirmDeleteModal = ({ isDeleteModalOpen, toggleDeleteModal, handleDelete }: ConfirmDeleteModalProps) => (
+
+
+
+
+ 정말 삭제하시겠습니까?
+
+ 삭제된 템플릿은 복구할 수 없습니다.
+
+
+
+ 취소
+
+ 삭제
+
+
+
+);
+
+const ScrollTopButton = () => (
+ {
+ scroll.top('smooth');
+ }}
+ >
+
+
+);
diff --git a/frontend/src/pages/MyTemplatesPage/components/CategoryFilterMenu/CategoryFilterMenu.stories.tsx b/frontend/src/pages/MyTemplatesPage/components/CategoryFilterMenu/CategoryFilterMenu.stories.tsx
index d145e2a71..082290387 100644
--- a/frontend/src/pages/MyTemplatesPage/components/CategoryFilterMenu/CategoryFilterMenu.stories.tsx
+++ b/frontend/src/pages/MyTemplatesPage/components/CategoryFilterMenu/CategoryFilterMenu.stories.tsx
@@ -7,7 +7,7 @@ import CategoryFilterMenu from './CategoryFilterMenu';
const meta: Meta = {
title: 'CategoryFilterMenu',
component: CategoryFilterMenu,
- args: { categories },
+ args: { categoryList: categories },
};
export default meta;
diff --git a/frontend/src/pages/MyTemplatesPage/components/CategoryFilterMenu/CategoryFilterMenu.tsx b/frontend/src/pages/MyTemplatesPage/components/CategoryFilterMenu/CategoryFilterMenu.tsx
index 7347b5c00..4ed586c5b 100644
--- a/frontend/src/pages/MyTemplatesPage/components/CategoryFilterMenu/CategoryFilterMenu.tsx
+++ b/frontend/src/pages/MyTemplatesPage/components/CategoryFilterMenu/CategoryFilterMenu.tsx
@@ -10,11 +10,11 @@ import { CategoryEditModal } from '../';
import * as S from './CategoryFilterMenu.style';
interface CategoryMenuProps {
- categories: Category[];
+ categoryList: Category[];
onSelectCategory: (selectedCategoryId: number) => void;
}
-const CategoryFilterMenu = ({ categories, onSelectCategory }: CategoryMenuProps) => {
+const CategoryFilterMenu = ({ categoryList, onSelectCategory }: CategoryMenuProps) => {
const [selectedId, setSelectedId] = useState(0);
const [isEditModalOpen, toggleEditModal] = useToggle();
const [isMenuOpen, toggleMenu] = useToggle(false);
@@ -35,17 +35,17 @@ const CategoryFilterMenu = ({ categories, onSelectCategory }: CategoryMenuProps)
}
};
- const [defaultCategory, ...userCategories] = categories.length ? categories : [{ id: 0, name: '' }];
+ const [defaultCategory, ...userCategories] = categoryList.length ? categoryList : [{ id: 0, name: '' }];
const indexById: Record = useMemo(() => {
- const map: Record = { 0: 0, [defaultCategory.id]: categories.length };
+ const map: Record = { 0: 0, [defaultCategory.id]: categoryList.length };
userCategories.forEach(({ id }, index) => {
map[id] = index + 1;
});
return map;
- }, [categories.length, defaultCategory.id, userCategories]);
+ }, [categoryList.length, defaultCategory.id, userCategories]);
return (
<>
@@ -81,7 +81,7 @@ const CategoryFilterMenu = ({ categories, onSelectCategory }: CategoryMenuProps)
diff --git a/frontend/src/pages/MyTemplatesPage/components/TagFilterMenu/TagFilterMenu.stories.tsx b/frontend/src/pages/MyTemplatesPage/components/TagFilterMenu/TagFilterMenu.stories.tsx
index 5c8c9a9b3..f59e35326 100644
--- a/frontend/src/pages/MyTemplatesPage/components/TagFilterMenu/TagFilterMenu.stories.tsx
+++ b/frontend/src/pages/MyTemplatesPage/components/TagFilterMenu/TagFilterMenu.stories.tsx
@@ -1,12 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react';
import { tags } from '@/mocks/tagList.json';
+
import TagFilterMenu from './TagFilterMenu';
const meta: Meta = {
title: 'TagFilterMenu',
component: TagFilterMenu,
- args: { tags },
+ args: { tagList: tags },
};
export default meta;
diff --git a/frontend/src/pages/MyTemplatesPage/components/TagFilterMenu/TagFilterMenu.tsx b/frontend/src/pages/MyTemplatesPage/components/TagFilterMenu/TagFilterMenu.tsx
index 3e0c4102a..31bdbde22 100644
--- a/frontend/src/pages/MyTemplatesPage/components/TagFilterMenu/TagFilterMenu.tsx
+++ b/frontend/src/pages/MyTemplatesPage/components/TagFilterMenu/TagFilterMenu.tsx
@@ -11,40 +11,40 @@ import * as S from './TagFilterMenu.style';
const LINE_HEIGHT_REM = 1.875;
interface Props {
- tags: Tag[];
+ tagList: Tag[];
selectedTagIds: number[];
onSelectTags: (selectedTagIds: number[]) => void;
}
-const TagFilterMenu = ({ tags, selectedTagIds, onSelectTags }: Props) => {
+const TagFilterMenu = ({ tagList, selectedTagIds, onSelectTags }: Props) => {
const [deselectedTags, setDeselectedTags] = useState([]);
const [isTagBoxOpen, toggleTagBox] = useToggle(false);
- const [height, setHeight] = useState('auto');
+ const [height, setHeight] = useState(`${LINE_HEIGHT_REM}rem`);
const containerRef = useRef(null);
const [showMoreButton, setShowMoreButton] = useState(false);
const windowWidth = useWindowWidth();
- const updateTagContainerState = () => {
- if (containerRef.current) {
- const containerHeight = containerRef.current.scrollHeight;
+ useEffect(() => {
+ const updateTagContainerState = () => {
+ if (containerRef.current) {
+ const containerHeight = containerRef.current.scrollHeight;
- setHeight(isTagBoxOpen ? `${containerHeight}px` : `${LINE_HEIGHT_REM}rem`);
+ setHeight(isTagBoxOpen ? `${containerHeight}px` : `${LINE_HEIGHT_REM}rem`);
- if (containerHeight > remToPx(LINE_HEIGHT_REM)) {
- setShowMoreButton(true);
- } else {
- setShowMoreButton(false);
+ if (containerHeight > remToPx(LINE_HEIGHT_REM)) {
+ setShowMoreButton(true);
+ } else {
+ setShowMoreButton(false);
+ }
}
- }
- };
+ };
- useEffect(() => {
updateTagContainerState();
- }, [tags, selectedTagIds, isTagBoxOpen, windowWidth]);
+ }, [tagList, selectedTagIds, isTagBoxOpen, windowWidth]);
const handleButtonClick = (tagId: number) => {
if (selectedTagIds.includes(tagId)) {
- const deselectedTag = tags.find((tag) => tag.id === tagId);
+ const deselectedTag = tagList.find((tag) => tag.id === tagId);
if (deselectedTag) {
setDeselectedTags((prev) => [deselectedTag, ...prev.filter((tag) => tag.id !== tagId)]);
@@ -57,10 +57,10 @@ const TagFilterMenu = ({ tags, selectedTagIds, onSelectTags }: Props) => {
}
};
- const selectedTags = selectedTagIds.map((id) => tags.find((tag) => tag.id === id)!).filter(Boolean);
+ const selectedTags = selectedTagIds.map((id) => tagList.find((tag) => tag.id === id)!).filter(Boolean);
const unselectedTags = deselectedTags.concat(
- tags.filter(
+ tagList.filter(
(tag) => !selectedTagIds.includes(tag.id) && !deselectedTags.some((deselectedTag) => deselectedTag.id === tag.id),
),
);
diff --git a/frontend/src/pages/MyTemplatesPage/components/TemplateGrid/TemplateGrid.stories.tsx b/frontend/src/pages/MyTemplatesPage/components/TemplateGrid/TemplateGrid.stories.tsx
index 15ae04816..9872b89fb 100644
--- a/frontend/src/pages/MyTemplatesPage/components/TemplateGrid/TemplateGrid.stories.tsx
+++ b/frontend/src/pages/MyTemplatesPage/components/TemplateGrid/TemplateGrid.stories.tsx
@@ -1,13 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react';
import { templates } from '@/mocks/templateList.json';
+
import TemplateGrid from './TemplateGrid';
const meta: Meta = {
title: 'TemplateGrid',
component: TemplateGrid,
args: {
- templates,
+ templateList: templates,
},
};
diff --git a/frontend/src/pages/MyTemplatesPage/components/TemplateGrid/TemplateGrid.tsx b/frontend/src/pages/MyTemplatesPage/components/TemplateGrid/TemplateGrid.tsx
index 37cd1461b..82d68e332 100644
--- a/frontend/src/pages/MyTemplatesPage/components/TemplateGrid/TemplateGrid.tsx
+++ b/frontend/src/pages/MyTemplatesPage/components/TemplateGrid/TemplateGrid.tsx
@@ -3,17 +3,18 @@ import { Link } from 'react-router-dom';
import { TemplateCard } from '@/components';
import { TemplateListItem } from '@/types';
+
import * as S from './TemplateGrid.style';
interface Props {
- templates: TemplateListItem[];
+ templateList: TemplateListItem[];
isEditMode: boolean;
selectedList: number[];
setSelectedList: React.Dispatch>;
cols?: number;
}
-const TemplateGrid = ({ templates, isEditMode, selectedList, setSelectedList, cols = 2 }: Props) => {
+const TemplateGrid = ({ templateList, isEditMode, selectedList, setSelectedList, cols = 2 }: Props) => {
useEffect(() => {
const resetSelectedList = () => {
setSelectedList([]);
@@ -30,7 +31,7 @@ const TemplateGrid = ({ templates, isEditMode, selectedList, setSelectedList, co
return (
- {templates.map((template) =>
+ {templateList.map((template) =>
isEditMode ? (
{
- const navigate = useNavigate();
+ const navigate = useCustomNavigate();
return (
diff --git a/frontend/src/pages/SignupPage/hooks/useSignupForm.ts b/frontend/src/pages/SignupPage/hooks/useSignupForm.ts
index d3f931b62..9c92beee1 100644
--- a/frontend/src/pages/SignupPage/hooks/useSignupForm.ts
+++ b/frontend/src/pages/SignupPage/hooks/useSignupForm.ts
@@ -1,12 +1,11 @@
import { FormEvent, useEffect } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useInputWithValidate } from '@/hooks';
+import { useCustomNavigate, useInputWithValidate } from '@/hooks';
import { useCheckNameQuery, useSignupMutation } from '@/queries/authentication';
import { validateName, validatePassword, validateConfirmPassword } from '@/service';
export const useSignupForm = () => {
- const navigate = useNavigate();
+ const navigate = useCustomNavigate();
const { mutateAsync: postSignup } = useSignupMutation();
const {
diff --git a/frontend/src/pages/TemplateUploadPage/components/TemplateEdit/TemplateEdit.style.ts b/frontend/src/pages/TemplateEditPage/TemplateEditPage.style.ts
similarity index 71%
rename from frontend/src/pages/TemplateUploadPage/components/TemplateEdit/TemplateEdit.style.ts
rename to frontend/src/pages/TemplateEditPage/TemplateEditPage.style.ts
index fbc67ee7f..37b01424e 100644
--- a/frontend/src/pages/TemplateUploadPage/components/TemplateEdit/TemplateEdit.style.ts
+++ b/frontend/src/pages/TemplateEditPage/TemplateEditPage.style.ts
@@ -1,9 +1,15 @@
import styled from '@emotion/styled';
-import { Button } from '@/components';
import { theme } from '@/style/theme';
export const TemplateEditContainer = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+`;
+
+export const MainContainer = styled.div`
display: flex;
flex-direction: column;
gap: 1.5rem;
@@ -16,11 +22,13 @@ export const TemplateEditContainer = styled.div`
margin-top: 3rem;
`;
-export const DeleteButton = styled(Button)`
- position: absolute;
- top: 0.3rem;
- right: 0.4rem;
- height: 2.4rem;
+export const ButtonGroup = styled.div`
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+
+ width: 100%;
+ padding: 0.5rem 0 0 0;
`;
export const UnderlineInputWrapper = styled.div`
diff --git a/frontend/src/pages/TemplateEditPage/TemplateEditPage.tsx b/frontend/src/pages/TemplateEditPage/TemplateEditPage.tsx
index f057dc741..74d84c612 100644
--- a/frontend/src/pages/TemplateEditPage/TemplateEditPage.tsx
+++ b/frontend/src/pages/TemplateEditPage/TemplateEditPage.tsx
@@ -1,7 +1,14 @@
-import type { Template } from '@/types';
+import { PlusIcon } from '@/assets/images';
+import { Button, Input, SelectList, SourceCodeEditor, Text, CategoryDropdown, TagInput } from '@/components';
+import { useInput, useSelectList } from '@/hooks';
+import { useCategory } from '@/hooks/category';
+import { useTag, useSourceCode } from '@/hooks/template';
+import { useToast } from '@/hooks/useToast';
+import { useTemplateEditMutation } from '@/queries/templates';
+import { theme } from '@/style/theme';
+import type { Template, TemplateEditRequest } from '@/types';
-import { TemplateEdit } from './components';
-import { useTemplateEdit } from './hooks';
+import * as S from './TemplateEditPage.style';
interface Props {
template: Template;
@@ -9,9 +16,150 @@ interface Props {
}
const TemplateEditPage = ({ template, toggleEditButton }: Props) => {
- const props = useTemplateEdit({ template, toggleEditButton });
+ const categoryProps = useCategory(template.category);
- return ;
+ const [title, handleTitleChange] = useInput(template.title);
+ const [description, handleDescriptionChange] = useInput(template.description);
+
+ const {
+ sourceCodes,
+ deleteSourceCodeIds,
+ isValidContentChange,
+ handleFilenameChange,
+ handleContentChange,
+ addNewEmptySourceCode,
+ handleDeleteSourceCode,
+ } = useSourceCode(template.sourceCodes);
+
+ const initTags = template.tags.map((tag) => tag.name);
+ const tagProps = useTag(initTags);
+
+ const { currentOption: currentFile, linkedElementRefs: sourceCodeRefs, handleSelectOption } = useSelectList();
+
+ const { mutateAsync: updateTemplate, error } = useTemplateEditMutation(template.id);
+
+ const { failAlert } = useToast();
+
+ const validateTemplate = () => {
+ if (!title) {
+ return '제목을 입력해주세요';
+ }
+
+ if (sourceCodes.filter(({ filename }) => !filename || filename.trim() === '').length) {
+ return '파일명을 입력해주세요';
+ }
+
+ if (sourceCodes.filter(({ content }) => !content || content.trim() === '').length) {
+ return '소스코드 내용을 입력해주세요';
+ }
+
+ return '';
+ };
+
+ const handleCancelButton = () => {
+ toggleEditButton();
+ };
+
+ const handleSaveButtonClick = async () => {
+ if (validateTemplate()) {
+ failAlert(validateTemplate());
+
+ return;
+ }
+
+ const orderedSourceCodes = sourceCodes.map((sourceCode, index) => ({
+ ...sourceCode,
+ ordinal: index + 1,
+ }));
+ const createSourceCodes = orderedSourceCodes.filter((sourceCode) => !sourceCode.id);
+ const updateSourceCodes = orderedSourceCodes.filter((sourceCode) => sourceCode.id);
+
+ const templateUpdate: TemplateEditRequest = {
+ title,
+ description,
+ createSourceCodes,
+ updateSourceCodes,
+ deleteSourceCodeIds,
+ categoryId: categoryProps.currentValue.id,
+ tags: tagProps.tags,
+ };
+
+ try {
+ await updateTemplate({ id: template.id, template: templateUpdate });
+ toggleEditButton();
+ } catch (error) {
+ console.error('Failed to update template:', error);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {sourceCodes.map((sourceCode, index) => (
+ (sourceCodeRefs.current[index] = el)}
+ filename={sourceCode.filename}
+ content={sourceCode.content}
+ isValidContentChange={isValidContentChange}
+ onChangeContent={(newContent) => handleContentChange(newContent, index)}
+ onChangeFilename={(newFilename) => handleFilenameChange(newFilename, index)}
+ handleDeleteSourceCode={() => handleDeleteSourceCode(index)}
+ filenameAutoFocus={index !== 0}
+ />
+ ))}
+
+
+
+
+
+
+
+
+
+ 취소
+
+
+ 저장
+
+
+
+ {error && Error: {error.message}}
+
+
+
+
+ {sourceCodes.map((sourceCode, index) => (
+
+ {sourceCode.filename}
+
+ ))}
+
+
+
+ );
};
export default TemplateEditPage;
diff --git a/frontend/src/pages/TemplateEditPage/components/.gitkeep b/frontend/src/pages/TemplateEditPage/components/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/frontend/src/pages/TemplateEditPage/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/pages/TemplateEditPage/components/TemplateEdit/TemplateEdit.tsx
deleted file mode 100644
index 9039efa8e..000000000
--- a/frontend/src/pages/TemplateEditPage/components/TemplateEdit/TemplateEdit.tsx
+++ /dev/null
@@ -1,277 +0,0 @@
-import { ChangeEvent, Dispatch, KeyboardEvent, MouseEvent, MutableRefObject, SetStateAction, useEffect } from 'react';
-
-import { PlusIcon, TrashcanIcon } from '@/assets/images';
-import {
- Button,
- Dropdown,
- Flex,
- Input,
- SourceCodeEditor,
- TagInput,
- Text,
- Guide,
- LoadingBall,
- SelectList,
-} from '@/components';
-import { useInputWithValidate, useLoaderDelay } from '@/hooks';
-import { useSelectList } from '@/hooks/useSelectList';
-import { useCategoryUploadMutation } from '@/queries/categories';
-import { validateCategoryName } from '@/service/validates';
-import { theme } from '@/style/theme';
-import type { Category, SourceCodes } from '@/types';
-
-import * as S from './TemplateEdit.style';
-
-interface Props {
- title: string;
- description: string;
- sourceCodes: SourceCodes[];
- categoryProps: {
- options: Category[];
- isOpen: boolean;
- toggleDropdown: () => void;
- currentValue: Category;
- handleCurrentValue: (newValue: Category) => void;
- getOptionLabel: (category: Category) => string;
- dropdownRef: MutableRefObject;
- };
- tagProps: {
- tags: string[];
- setTags: Dispatch>;
- value: string;
- handleValue: (e: ChangeEvent) => void;
- resetValue: () => void;
- };
- handleTitleChange: (e: ChangeEvent) => void;
- handleDescriptionChange: (e: ChangeEvent) => void;
- handleAddButtonClick: () => void;
- handleCancelButton: () => void;
- handleCodeChange: (newContent: string, idx: number) => void;
- handleFileNameChange: (newFileName: string, idx: number) => void;
- handleDeleteSourceCode: (index: number) => void;
- handleSaveButtonClick: () => Promise;
- error: Error | null;
-}
-
-const TemplateEdit = ({
- title,
- description,
- sourceCodes,
- tagProps,
- categoryProps,
- handleTitleChange,
- handleDescriptionChange,
- handleAddButtonClick,
- handleCancelButton,
- handleCodeChange,
- handleFileNameChange,
- handleDeleteSourceCode: handleDeleteSourceCode,
- handleSaveButtonClick,
- error,
-}: Props) => {
- const { mutateAsync: postCategory, isPending } = useCategoryUploadMutation(categoryProps.handleCurrentValue);
- const { currentFile, setCurrentFile, sourceCodeRefs, handleSelectOption } = useSelectList(sourceCodes);
-
- const {
- value: categoryInputValue,
- errorMessage: categoryErrorMessage,
- handleChange: handleCategoryChange,
- } = useInputWithValidate('', validateCategoryName);
-
- const getExistingCategory = (value: string) =>
- categoryProps.options.find((category) => categoryProps.getOptionLabel(category) === value);
-
- const createNewCategory = async (e: KeyboardEvent) => {
- if (!(e.target instanceof HTMLInputElement) || e.key !== 'Enter' || e.nativeEvent.isComposing === true) {
- return;
- }
-
- const inputValue = e.target.value;
-
- if (inputValue === '') {
- return;
- }
-
- const existingCategory = getExistingCategory(inputValue);
-
- if (existingCategory) {
- categoryProps.handleCurrentValue(existingCategory);
-
- return;
- }
-
- if (categoryErrorMessage !== '') {
- return;
- }
-
- const newCategory = { name: inputValue };
-
- await postCategory(newCategory);
-
- e.target.value = '';
- };
-
- const handleSelectList = (index: number) => (e: MouseEvent) => {
- e.preventDefault();
-
- handleSelectOption(index)(e);
- setCurrentFile(index);
- };
-
- useEffect(() => {
- if (sourceCodes.length === 1) {
- setCurrentFile(0);
- }
- }, [sourceCodes, setCurrentFile]);
-
- return (
-
-
-
-
-
- }
- />
-
-
-
-
-
-
-
-
-
-
-
- {sourceCodes.map((sourceCode, index) => (
-
- (sourceCodeRefs.current[index] = el)} css={{ width: '100%' }}>
- handleCodeChange(newContent, index)}
- onChangeFileName={(newFileName) => handleFileNameChange(newFileName, index)}
- />
- {
- handleDeleteSourceCode(index);
- }}
- >
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
- 취소
-
-
- 저장
-
-
-
-
- {error && Error: {error.message}}
-
-
-
-
- {sourceCodes.map((sourceCode, index) => (
-
- {sourceCode.filename}
-
- ))}
-
-
-
- );
-};
-
-export default TemplateEdit;
-
-interface NewCategoryInputProps {
- categoryInputValue: string;
- createNewCategory: (e: KeyboardEvent) => void;
- handleChange: (e: ChangeEvent, compareValue?: string) => void;
- isPending: boolean;
-}
-
-const NewCategoryInput = ({
- categoryInputValue,
- createNewCategory,
- handleChange,
- isPending,
-}: NewCategoryInputProps) => {
- const showLoader = useLoaderDelay(isPending, 700);
-
- return (
-
- {showLoader ? (
-
- ) : (
-
- )}
-
- );
-};
-
-interface CategoryGuideProps {
- isOpen: boolean;
- categoryErrorMessage: string;
-}
-
-const CategoryGuide = ({ isOpen, categoryErrorMessage }: CategoryGuideProps) => {
- const isError = categoryErrorMessage !== '';
-
- return (
-
- {isError ? (
- {categoryErrorMessage}
- ) : (
- 엔터로 카테고리를 등록해요
- )}
-
- );
-};
diff --git a/frontend/src/pages/TemplateEditPage/components/index.ts b/frontend/src/pages/TemplateEditPage/components/index.ts
deleted file mode 100644
index a1690d1e5..000000000
--- a/frontend/src/pages/TemplateEditPage/components/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as TemplateEdit } from './TemplateEdit/TemplateEdit';
diff --git a/frontend/src/pages/TemplateEditPage/hooks/.gitkeep b/frontend/src/pages/TemplateEditPage/hooks/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/frontend/src/pages/TemplateEditPage/hooks/index.ts b/frontend/src/pages/TemplateEditPage/hooks/index.ts
deleted file mode 100644
index c7146c899..000000000
--- a/frontend/src/pages/TemplateEditPage/hooks/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useTemplateEdit } from './useTemplateEdit';
diff --git a/frontend/src/pages/TemplateEditPage/hooks/useTemplateEdit.ts b/frontend/src/pages/TemplateEditPage/hooks/useTemplateEdit.ts
deleted file mode 100644
index 31ae75ef0..000000000
--- a/frontend/src/pages/TemplateEditPage/hooks/useTemplateEdit.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import { useCallback, useState } from 'react';
-
-import { ToastContext } from '@/contexts';
-import { useCustomContext, useInput } from '@/hooks';
-import { useCategory } from '@/hooks/category';
-import { useTemplateEditMutation } from '@/queries/templates';
-import type { Template, TemplateEditRequest } from '@/types';
-
-interface Props {
- template: Template;
- toggleEditButton: () => void;
-}
-
-export const useTemplateEdit = ({ template, toggleEditButton }: Props) => {
- const { failAlert } = useCustomContext(ToastContext);
-
- const [title, handleTitleChange] = useInput(template.title);
- const [description, handleDescriptionChange] = useInput(template.description);
-
- const [sourceCodes, setSourceCodes] = useState([...template.sourceCodes]);
- const [deleteSourceCodeIds, setDeleteSourceCodeIds] = useState([]);
-
- const categoryProps = useCategory(template.category);
-
- const initTags = template.tags.map((tag) => tag.name);
- const [tags, setTags] = useState(initTags);
- const [value, handleValue, resetValue] = useInput('');
-
- const { mutateAsync, error } = useTemplateEditMutation(template.id);
-
- const handleAddButtonClick = () => {
- setSourceCodes((prevSourceCode) => [
- ...prevSourceCode,
- {
- filename: '',
- content: '',
- ordinal: prevSourceCode.length + 1,
- },
- ]);
- };
-
- const handleCancelButton = () => {
- toggleEditButton();
- };
-
- const handleCodeChange = useCallback((newContent: string, idx: number) => {
- setSourceCodes((prevSourceCodes) =>
- prevSourceCodes.map((sourceCodes, index) =>
- index === idx ? { ...sourceCodes, content: newContent } : sourceCodes,
- ),
- );
- }, []);
-
- const handleFileNameChange = useCallback((newFileName: string, idx: number) => {
- setSourceCodes((prevSourceCodes) =>
- prevSourceCodes.map((sourceCodes, index) =>
- index === idx ? { ...sourceCodes, filename: newFileName } : sourceCodes,
- ),
- );
- }, []);
-
- const handleDeleteSourceCode = (index: number) => {
- const deletedSourceCodeId = sourceCodes[index].id;
-
- if (!sourceCodes[index]) {
- console.error('존재하지 않는 소스코드 인덱스입니다.');
- }
-
- if (deletedSourceCodeId) {
- setDeleteSourceCodeIds((prevSourceCodeId) => [...prevSourceCodeId, deletedSourceCodeId]);
- }
-
- setSourceCodes((prevSourceCodes) => prevSourceCodes.filter((_, idx) => index !== idx));
- };
-
- const validateTemplate = () => {
- if (!title) {
- return '제목을 입력해주세요';
- }
-
- if (sourceCodes.filter((sourceCode) => !sourceCode.filename).length) {
- return '파일명을 입력해주세요';
- }
-
- if (sourceCodes.filter((sourceCode) => !sourceCode.content).length) {
- return '소스코드 내용을 입력해주세요';
- }
-
- return '';
- };
-
- const handleSaveButtonClick = async () => {
- if (validateTemplate()) {
- failAlert(validateTemplate());
-
- return;
- }
-
- const orderedSourceCodes = sourceCodes.map((sourceCode, index) => ({
- ...sourceCode,
- ordinal: index + 1,
- }));
- const createSourceCodes = orderedSourceCodes.filter((sourceCode) => !sourceCode.id);
- const updateSourceCodes = orderedSourceCodes.filter((sourceCode) => sourceCode.id);
-
- const templateUpdate: TemplateEditRequest = {
- title,
- description,
- createSourceCodes,
- updateSourceCodes,
- deleteSourceCodeIds,
- categoryId: categoryProps.currentValue.id,
- tags,
- };
-
- try {
- await mutateAsync({ id: template.id, template: templateUpdate });
- toggleEditButton();
- } catch (error) {
- console.error('Failed to update template:', error);
- }
- };
-
- return {
- title,
- description,
- sourceCodes,
- categoryProps,
- tagProps: {
- tags,
- setTags,
- value,
- handleValue,
- resetValue,
- },
- handleTitleChange,
- handleDescriptionChange,
- handleAddButtonClick,
- handleCancelButton,
- handleCodeChange,
- handleFileNameChange,
- handleDeleteSourceCode,
- handleSaveButtonClick,
- error,
- };
-};
diff --git a/frontend/src/pages/TemplatePage/TemplatePage.tsx b/frontend/src/pages/TemplatePage/TemplatePage.tsx
index d95039fa9..d05fde5f3 100644
--- a/frontend/src/pages/TemplatePage/TemplatePage.tsx
+++ b/frontend/src/pages/TemplatePage/TemplatePage.tsx
@@ -1,20 +1,26 @@
import { useTheme } from '@emotion/react';
-import { type LanguageName, loadLanguage } from '@uiw/codemirror-extensions-langs';
-import { quietlight } from '@uiw/codemirror-theme-quietlight';
-import CodeMirror, { EditorView } from '@uiw/react-codemirror';
import { useParams } from 'react-router-dom';
-import { ChevronIcon, ClockIcon, PersonIcon } from '@/assets/images';
-import { Button, Flex, Heading, LikeButton, Modal, SelectList, TagButton, Text, NonmemberAlerter } from '@/components';
-import { ToastContext } from '@/contexts';
-import { useCustomContext, useToggle } from '@/hooks';
+import { ClockIcon, PersonIcon } from '@/assets/images';
+import {
+ Button,
+ Flex,
+ Heading,
+ LikeButton,
+ Modal,
+ SelectList,
+ SourceCodeViewer,
+ TagButton,
+ Text,
+ NonmemberAlerter,
+ LoadingFallback,
+} from '@/components';
+import { useToggle } from '@/hooks';
import { useAuth } from '@/hooks/authentication';
import { TemplateEditPage } from '@/pages';
-import type { SourceCodes } from '@/types';
-import { formatRelativeTime, getLanguageByFilename } from '@/utils';
+import { formatRelativeTime } from '@/utils';
-import { useTemplate } from './hooks';
-import { useLike } from './hooks/useLike';
+import { useTemplate, useLike } from './hooks';
import * as S from './TemplatePage.style';
const TemplatePage = () => {
@@ -27,14 +33,8 @@ const TemplatePage = () => {
memberInfo: { name },
} = useAuth();
- const { infoAlert } = useCustomContext(ToastContext);
const [isOpen, toggleModal] = useToggle();
- const copyCode = (sourceCode: SourceCodes) => () => {
- navigator.clipboard.writeText(sourceCode.content);
- infoAlert('코드가 복사되었습니다!');
- };
-
const {
currentFile,
template,
@@ -44,8 +44,6 @@ const TemplatePage = () => {
handleEditButtonClick,
handleSelectOption,
handleDelete,
- isOpenList,
- handleIsOpenList,
} = useTemplate(Number(id));
const { likesCount, isLiked, toggleLike } = useLike({
@@ -65,7 +63,7 @@ const TemplatePage = () => {
};
if (!template) {
- return 템플릿을 불러오는 중...
;
+ return ;
}
return (
@@ -184,89 +182,13 @@ const TemplatePage = () => {
)}
{template.sourceCodes.map((sourceCode, index) => (
- (sourceCodeRefs.current[index] = el)}
- css={{ width: '100%' }}
- >
-
-
-
-
-
- {sourceCode.filename}
-
-
-
-
-
- {'복사'}
-
-
-
-
- {isOpenList[index] && (
-
- )}
-
-
+ mode='detailView'
+ filename={sourceCode.filename}
+ content={sourceCode.content}
+ sourceCodeRef={(el) => (sourceCodeRefs.current[index] = el)}
+ />
))}
@@ -276,7 +198,7 @@ const TemplatePage = () => {
{sourceCode.filename}
diff --git a/frontend/src/pages/TemplatePage/hooks/index.ts b/frontend/src/pages/TemplatePage/hooks/index.ts
index fb51a54a5..a8a7dfecc 100644
--- a/frontend/src/pages/TemplatePage/hooks/index.ts
+++ b/frontend/src/pages/TemplatePage/hooks/index.ts
@@ -1 +1,2 @@
export { useTemplate } from './useTemplate';
+export { useLike } from './useLike';
diff --git a/frontend/src/pages/TemplatePage/hooks/useTemplate.ts b/frontend/src/pages/TemplatePage/hooks/useTemplate.ts
index 1dee038b4..2e1ba203d 100644
--- a/frontend/src/pages/TemplatePage/hooks/useTemplate.ts
+++ b/frontend/src/pages/TemplatePage/hooks/useTemplate.ts
@@ -1,18 +1,15 @@
import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useSelectList } from '@/hooks';
+import { useCustomNavigate, useSelectList } from '@/hooks';
import { useTemplateDeleteMutation, useTemplateQuery } from '@/queries/templates';
import { END_POINTS } from '@/routes';
export const useTemplate = (id: number) => {
- const navigate = useNavigate();
+ const navigate = useCustomNavigate();
const { data: template } = useTemplateQuery(Number(id));
const { mutateAsync: deleteTemplate } = useTemplateDeleteMutation([Number(id)]);
- const { currentFile, setCurrentFile, sourceCodeRefs, handleSelectOption } = useSelectList(
- template?.sourceCodes || [],
- );
+ const { currentOption: currentFile, linkedElementRefs: sourceCodeRefs, handleSelectOption } = useSelectList();
const [isEdit, setIsEdit] = useState(false);
@@ -20,10 +17,9 @@ export const useTemplate = (id: number) => {
useEffect(() => {
if (template && template?.sourceCodes.length > 0) {
- setCurrentFile(template?.sourceCodes[0].id as number);
setIsOpenList(template?.sourceCodes.map(() => true));
}
- }, [template, setCurrentFile]);
+ }, [template]);
const toggleEditButton = () => {
setIsEdit((prev) => !prev);
diff --git a/frontend/src/pages/TemplateEditPage/components/TemplateEdit/TemplateEdit.style.ts b/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.style.ts
similarity index 71%
rename from frontend/src/pages/TemplateEditPage/components/TemplateEdit/TemplateEdit.style.ts
rename to frontend/src/pages/TemplateUploadPage/TemplateUploadPage.style.ts
index fbc67ee7f..37b01424e 100644
--- a/frontend/src/pages/TemplateEditPage/components/TemplateEdit/TemplateEdit.style.ts
+++ b/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.style.ts
@@ -1,9 +1,15 @@
import styled from '@emotion/styled';
-import { Button } from '@/components';
import { theme } from '@/style/theme';
export const TemplateEditContainer = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+`;
+
+export const MainContainer = styled.div`
display: flex;
flex-direction: column;
gap: 1.5rem;
@@ -16,11 +22,13 @@ export const TemplateEditContainer = styled.div`
margin-top: 3rem;
`;
-export const DeleteButton = styled(Button)`
- position: absolute;
- top: 0.3rem;
- right: 0.4rem;
- height: 2.4rem;
+export const ButtonGroup = styled.div`
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+
+ width: 100%;
+ padding: 0.5rem 0 0 0;
`;
export const UnderlineInputWrapper = styled.div`
diff --git a/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.tsx b/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.tsx
index 242070547..0ef7c03dc 100644
--- a/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.tsx
+++ b/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.tsx
@@ -1,10 +1,165 @@
-import { TemplateEdit } from './components';
-import { useTemplateUpload } from './hooks';
+import { PlusIcon } from '@/assets/images';
+import { Button, CategoryDropdown, Input, SelectList, SourceCodeEditor, TagInput, Text } from '@/components';
+import { useCustomNavigate, useInput, useSelectList } from '@/hooks';
+import { useCategory } from '@/hooks/category';
+import { useSourceCode, useTag } from '@/hooks/template';
+import { useToast } from '@/hooks/useToast';
+import { useTemplateUploadMutation } from '@/queries/templates';
+import { END_POINTS } from '@/routes';
+import { theme } from '@/style/theme';
+import { TemplateUploadRequest } from '@/types';
+
+import * as S from './TemplateUploadPage.style';
const TemplateUploadPage = () => {
- const props = useTemplateUpload();
+ const navigate = useCustomNavigate();
+ const { failAlert } = useToast();
+
+ const categoryProps = useCategory();
+
+ const [title, handleTitleChange] = useInput('');
+ const [description, handleDescriptionChange] = useInput('');
+
+ const {
+ sourceCodes,
+ isValidContentChange,
+ handleFilenameChange,
+ handleContentChange,
+ addNewEmptySourceCode,
+ handleDeleteSourceCode,
+ } = useSourceCode([
+ {
+ filename: '',
+ content: '',
+ ordinal: 1,
+ },
+ ]);
+
+ const tagProps = useTag([]);
+
+ const { currentOption: currentFile, linkedElementRefs: sourceCodeRefs, handleSelectOption } = useSelectList();
+
+ const { mutateAsync: uploadTemplate, error } = useTemplateUploadMutation();
+
+ const validateTemplate = () => {
+ if (!title) {
+ return '제목을 입력해주세요';
+ }
+
+ if (sourceCodes.filter(({ filename }) => !filename || filename.trim() === '').length) {
+ return '파일명을 입력해주세요';
+ }
+
+ if (sourceCodes.filter(({ content }) => !content || content.trim() === '').length) {
+ return '소스코드 내용을 입력해주세요';
+ }
+
+ return '';
+ };
+
+ const handleCancelButton = () => {
+ navigate(-1);
+ };
+
+ const handleSaveButtonClick = async () => {
+ const errorMessage = validateTemplate();
+
+ if (errorMessage) {
+ failAlert(errorMessage);
+
+ return;
+ }
+
+ const newTemplate: TemplateUploadRequest = {
+ title,
+ description,
+ sourceCodes,
+ thumbnailOrdinal: 1,
+ categoryId: categoryProps.currentValue.id,
+ tags: tagProps.tags,
+ };
+
+ await uploadTemplate(newTemplate, {
+ onSuccess: (res) => {
+ if (res?.status === 400 || res?.status === 404) {
+ failAlert('템플릿 생성에 실패했습니다. 다시 한 번 시도해주세요');
+
+ return;
+ }
+
+ navigate(END_POINTS.MY_TEMPLATES);
+ },
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {sourceCodes.map((sourceCode, index) => (
+ (sourceCodeRefs.current[index] = el)}
+ filename={sourceCode.filename}
+ content={sourceCode.content}
+ isValidContentChange={isValidContentChange}
+ onChangeContent={(newContent) => handleContentChange(newContent, index)}
+ onChangeFilename={(newFilename) => handleFilenameChange(newFilename, index)}
+ handleDeleteSourceCode={() => handleDeleteSourceCode(index)}
+ filenameAutoFocus={index !== 0}
+ />
+ ))}
+
+
+
+
+
+
+
+
+
+ 취소
+
+
+ 저장
+
+
+
+ {error && Error: {error.message}}
+
- return ;
+
+
+ {sourceCodes.map((sourceCode, index) => (
+
+ {sourceCode.filename}
+
+ ))}
+
+
+
+ );
};
export default TemplateUploadPage;
diff --git a/frontend/src/pages/TemplateUploadPage/components/.gitkeep b/frontend/src/pages/TemplateUploadPage/components/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/frontend/src/pages/TemplateUploadPage/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/pages/TemplateUploadPage/components/TemplateEdit/TemplateEdit.tsx
deleted file mode 100644
index 9039efa8e..000000000
--- a/frontend/src/pages/TemplateUploadPage/components/TemplateEdit/TemplateEdit.tsx
+++ /dev/null
@@ -1,277 +0,0 @@
-import { ChangeEvent, Dispatch, KeyboardEvent, MouseEvent, MutableRefObject, SetStateAction, useEffect } from 'react';
-
-import { PlusIcon, TrashcanIcon } from '@/assets/images';
-import {
- Button,
- Dropdown,
- Flex,
- Input,
- SourceCodeEditor,
- TagInput,
- Text,
- Guide,
- LoadingBall,
- SelectList,
-} from '@/components';
-import { useInputWithValidate, useLoaderDelay } from '@/hooks';
-import { useSelectList } from '@/hooks/useSelectList';
-import { useCategoryUploadMutation } from '@/queries/categories';
-import { validateCategoryName } from '@/service/validates';
-import { theme } from '@/style/theme';
-import type { Category, SourceCodes } from '@/types';
-
-import * as S from './TemplateEdit.style';
-
-interface Props {
- title: string;
- description: string;
- sourceCodes: SourceCodes[];
- categoryProps: {
- options: Category[];
- isOpen: boolean;
- toggleDropdown: () => void;
- currentValue: Category;
- handleCurrentValue: (newValue: Category) => void;
- getOptionLabel: (category: Category) => string;
- dropdownRef: MutableRefObject;
- };
- tagProps: {
- tags: string[];
- setTags: Dispatch>;
- value: string;
- handleValue: (e: ChangeEvent) => void;
- resetValue: () => void;
- };
- handleTitleChange: (e: ChangeEvent) => void;
- handleDescriptionChange: (e: ChangeEvent) => void;
- handleAddButtonClick: () => void;
- handleCancelButton: () => void;
- handleCodeChange: (newContent: string, idx: number) => void;
- handleFileNameChange: (newFileName: string, idx: number) => void;
- handleDeleteSourceCode: (index: number) => void;
- handleSaveButtonClick: () => Promise;
- error: Error | null;
-}
-
-const TemplateEdit = ({
- title,
- description,
- sourceCodes,
- tagProps,
- categoryProps,
- handleTitleChange,
- handleDescriptionChange,
- handleAddButtonClick,
- handleCancelButton,
- handleCodeChange,
- handleFileNameChange,
- handleDeleteSourceCode: handleDeleteSourceCode,
- handleSaveButtonClick,
- error,
-}: Props) => {
- const { mutateAsync: postCategory, isPending } = useCategoryUploadMutation(categoryProps.handleCurrentValue);
- const { currentFile, setCurrentFile, sourceCodeRefs, handleSelectOption } = useSelectList(sourceCodes);
-
- const {
- value: categoryInputValue,
- errorMessage: categoryErrorMessage,
- handleChange: handleCategoryChange,
- } = useInputWithValidate('', validateCategoryName);
-
- const getExistingCategory = (value: string) =>
- categoryProps.options.find((category) => categoryProps.getOptionLabel(category) === value);
-
- const createNewCategory = async (e: KeyboardEvent) => {
- if (!(e.target instanceof HTMLInputElement) || e.key !== 'Enter' || e.nativeEvent.isComposing === true) {
- return;
- }
-
- const inputValue = e.target.value;
-
- if (inputValue === '') {
- return;
- }
-
- const existingCategory = getExistingCategory(inputValue);
-
- if (existingCategory) {
- categoryProps.handleCurrentValue(existingCategory);
-
- return;
- }
-
- if (categoryErrorMessage !== '') {
- return;
- }
-
- const newCategory = { name: inputValue };
-
- await postCategory(newCategory);
-
- e.target.value = '';
- };
-
- const handleSelectList = (index: number) => (e: MouseEvent) => {
- e.preventDefault();
-
- handleSelectOption(index)(e);
- setCurrentFile(index);
- };
-
- useEffect(() => {
- if (sourceCodes.length === 1) {
- setCurrentFile(0);
- }
- }, [sourceCodes, setCurrentFile]);
-
- return (
-
-
-
-
-
- }
- />
-
-
-
-
-
-
-
-
-
-
-
- {sourceCodes.map((sourceCode, index) => (
-
- (sourceCodeRefs.current[index] = el)} css={{ width: '100%' }}>
- handleCodeChange(newContent, index)}
- onChangeFileName={(newFileName) => handleFileNameChange(newFileName, index)}
- />
- {
- handleDeleteSourceCode(index);
- }}
- >
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
- 취소
-
-
- 저장
-
-
-
-
- {error && Error: {error.message}}
-
-
-
-
- {sourceCodes.map((sourceCode, index) => (
-
- {sourceCode.filename}
-
- ))}
-
-
-
- );
-};
-
-export default TemplateEdit;
-
-interface NewCategoryInputProps {
- categoryInputValue: string;
- createNewCategory: (e: KeyboardEvent) => void;
- handleChange: (e: ChangeEvent, compareValue?: string) => void;
- isPending: boolean;
-}
-
-const NewCategoryInput = ({
- categoryInputValue,
- createNewCategory,
- handleChange,
- isPending,
-}: NewCategoryInputProps) => {
- const showLoader = useLoaderDelay(isPending, 700);
-
- return (
-
- {showLoader ? (
-
- ) : (
-
- )}
-
- );
-};
-
-interface CategoryGuideProps {
- isOpen: boolean;
- categoryErrorMessage: string;
-}
-
-const CategoryGuide = ({ isOpen, categoryErrorMessage }: CategoryGuideProps) => {
- const isError = categoryErrorMessage !== '';
-
- return (
-
- {isError ? (
- {categoryErrorMessage}
- ) : (
- 엔터로 카테고리를 등록해요
- )}
-
- );
-};
diff --git a/frontend/src/pages/TemplateUploadPage/components/index.ts b/frontend/src/pages/TemplateUploadPage/components/index.ts
deleted file mode 100644
index a1690d1e5..000000000
--- a/frontend/src/pages/TemplateUploadPage/components/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as TemplateEdit } from './TemplateEdit/TemplateEdit';
diff --git a/frontend/src/pages/TemplateUploadPage/hooks/.gitkeep b/frontend/src/pages/TemplateUploadPage/hooks/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/frontend/src/pages/TemplateUploadPage/hooks/index.ts b/frontend/src/pages/TemplateUploadPage/hooks/index.ts
deleted file mode 100644
index c8df8f70d..000000000
--- a/frontend/src/pages/TemplateUploadPage/hooks/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useTemplateUpload } from './useTemplateUpload';
diff --git a/frontend/src/pages/TemplateUploadPage/hooks/useTemplateUpload.ts b/frontend/src/pages/TemplateUploadPage/hooks/useTemplateUpload.ts
deleted file mode 100644
index e36f2eaa3..000000000
--- a/frontend/src/pages/TemplateUploadPage/hooks/useTemplateUpload.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import { useCallback, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-
-import { ToastContext } from '@/contexts';
-import { useCustomContext, useInput, useInputWithValidate } from '@/hooks';
-import { useCategory } from '@/hooks/category';
-import { useTemplateUploadMutation } from '@/queries/templates';
-import { END_POINTS } from '@/routes';
-import type { SourceCodes, TemplateUploadRequest } from '@/types';
-
-export const useTemplateUpload = () => {
- const navigate = useNavigate();
- const { failAlert } = useCustomContext(ToastContext);
-
- const categoryProps = useCategory();
-
- const [title, handleTitleChange] = useInput('');
- const [description, handleDescriptionChange] = useInput('');
-
- const [sourceCodes, setSourceCodes] = useState([
- {
- filename: '',
- content: '',
- ordinal: 1,
- },
- ]);
-
- const { value, handleChange: handleValue, resetValue } = useInputWithValidate('');
-
- const [tags, setTags] = useState([]);
-
- const { mutateAsync: uploadTemplate, error } = useTemplateUploadMutation();
-
- const handleCodeChange = useCallback((newContent: string, idx: number) => {
- setSourceCodes((prevSourceCodes) =>
- prevSourceCodes.map((sourceCode, index) => (index === idx ? { ...sourceCode, content: newContent } : sourceCode)),
- );
- }, []);
-
- const handleFileNameChange = useCallback((newFileName: string, idx: number) => {
- setSourceCodes((prevSourceCodes) =>
- prevSourceCodes.map((sourceCode, index) =>
- index === idx ? { ...sourceCode, filename: newFileName } : sourceCode,
- ),
- );
- }, []);
-
- const handleAddButtonClick = () => {
- setSourceCodes((prevSourceCodes) => [
- ...prevSourceCodes,
- {
- filename: '',
- content: '',
- ordinal: prevSourceCodes.length + 1,
- },
- ]);
- };
-
- const handleDeleteSourceCode = (index: number) => {
- if (!sourceCodes[index]) {
- console.error('존재하지 않는 소스코드 인덱스입니다.');
- }
-
- setSourceCodes((prevSourceCodes) => prevSourceCodes.filter((_, idx) => index !== idx));
- };
-
- const handleCancelButton = () => {
- navigate(-1);
- };
-
- const validateTemplate = () => {
- if (!title) {
- return '제목을 입력해주세요';
- }
-
- if (sourceCodes.filter((sourceCode) => !sourceCode.filename).length) {
- return '파일명을 입력해주세요';
- }
-
- if (sourceCodes.filter((sourceCode) => !sourceCode.content).length) {
- return '소스코드 내용을 입력해주세요';
- }
-
- return '';
- };
-
- const handleSaveButtonClick = async () => {
- const errorMessage = validateTemplate();
-
- if (errorMessage) {
- failAlert(errorMessage);
-
- return;
- }
-
- const newTemplate: TemplateUploadRequest = {
- title,
- description,
- sourceCodes,
- thumbnailOrdinal: 1,
- categoryId: categoryProps.currentValue.id,
- tags,
- };
-
- await uploadTemplate(newTemplate, {
- onSuccess: (res) => {
- if (res?.status === 400 || res?.status === 404) {
- failAlert('템플릿 생성에 실패했습니다. 다시 한 번 시도해주세요');
-
- return;
- }
-
- navigate(END_POINTS.MY_TEMPLATES);
- },
- });
- };
-
- return {
- title,
- description,
- sourceCodes,
- error,
- handleDescriptionChange,
- handleTitleChange,
- handleCodeChange,
- handleFileNameChange,
- handleAddButtonClick,
- handleDeleteSourceCode,
- handleCancelButton,
- handleSaveButtonClick,
-
- categoryProps,
- tagProps: {
- value,
- handleValue,
- resetValue,
- tags,
- setTags,
- },
- };
-};
diff --git a/frontend/src/queries/authentication/useLoginMutation.ts b/frontend/src/queries/authentication/useLoginMutation.ts
index e2c131266..e3c428188 100644
--- a/frontend/src/queries/authentication/useLoginMutation.ts
+++ b/frontend/src/queries/authentication/useLoginMutation.ts
@@ -1,9 +1,8 @@
import { useMutation } from '@tanstack/react-query';
-import { useNavigate } from 'react-router-dom';
import { postLogin } from '@/api/authentication';
import { ToastContext } from '@/contexts';
-import { useCustomContext } from '@/hooks';
+import { useCustomContext, useCustomNavigate } from '@/hooks';
import { useAuth } from '@/hooks/authentication/useAuth';
import { END_POINTS } from '@/routes';
import { LoginRequest } from '@/types';
@@ -11,7 +10,7 @@ import { LoginRequest } from '@/types';
export const useLoginMutation = () => {
const { handleLoginState, handleMemberInfo } = useAuth();
const { failAlert, successAlert } = useCustomContext(ToastContext);
- const navigate = useNavigate();
+ const navigate = useCustomNavigate();
return useMutation({
mutationFn: (loginInfo: LoginRequest) => postLogin(loginInfo),
diff --git a/frontend/src/queries/authentication/useSignupMutation.ts b/frontend/src/queries/authentication/useSignupMutation.ts
index 3741694ec..a4e15c4c2 100644
--- a/frontend/src/queries/authentication/useSignupMutation.ts
+++ b/frontend/src/queries/authentication/useSignupMutation.ts
@@ -1,15 +1,14 @@
import { useMutation } from '@tanstack/react-query';
-import { useNavigate } from 'react-router-dom';
import { postSignup } from '@/api/authentication';
import { ToastContext } from '@/contexts';
-import { useCustomContext } from '@/hooks';
+import { useCustomContext, useCustomNavigate } from '@/hooks';
import { END_POINTS } from '@/routes';
import { SignupRequest } from '@/types';
export const useSignupMutation = () => {
const { failAlert, successAlert } = useCustomContext(ToastContext);
- const navigate = useNavigate();
+ const navigate = useCustomNavigate();
return useMutation({
mutationFn: (signupInfo: SignupRequest) => postSignup(signupInfo),
diff --git a/frontend/src/queries/templates/index.ts b/frontend/src/queries/templates/index.ts
index f3e5707d6..9a21c0c1a 100644
--- a/frontend/src/queries/templates/index.ts
+++ b/frontend/src/queries/templates/index.ts
@@ -4,3 +4,4 @@ export { useTemplateQuery } from './useTemplateQuery';
export { useTemplateUploadMutation } from './useTemplateUploadMutation';
export { useTemplateEditMutation } from './useTemplateEditMutation';
export { useTemplateDeleteMutation } from './useTemplateDeleteMutation';
+export { useTemplateCategoryTagQueries } from './useTemplateCategoryTagQueries';
diff --git a/frontend/src/queries/templates/useTemplateCategoryTagQueries.ts b/frontend/src/queries/templates/useTemplateCategoryTagQueries.ts
new file mode 100644
index 000000000..4b047567d
--- /dev/null
+++ b/frontend/src/queries/templates/useTemplateCategoryTagQueries.ts
@@ -0,0 +1,44 @@
+import { useSuspenseQueries } from '@tanstack/react-query';
+
+import { QUERY_KEY, getTemplateList, getTagList, getCategoryList, DEFAULT_SORTING_OPTION, PAGE_SIZE } from '@/api';
+import { useAuth } from '@/hooks/authentication/useAuth';
+import type { SortingKey } from '@/types';
+
+interface Props {
+ keyword?: string;
+ categoryId?: number;
+ tagIds?: number[];
+ sort?: SortingKey;
+ page?: number;
+ size?: number;
+}
+
+export const useTemplateCategoryTagQueries = ({
+ keyword,
+ categoryId,
+ tagIds,
+ sort = DEFAULT_SORTING_OPTION.key,
+ page = 1,
+ size = PAGE_SIZE,
+}: Props) => {
+ const {
+ memberInfo: { memberId },
+ } = useAuth();
+
+ return useSuspenseQueries({
+ queries: [
+ {
+ queryKey: [QUERY_KEY.TEMPLATE_LIST, keyword, categoryId, tagIds, sort, page, size, memberId],
+ queryFn: () => getTemplateList({ keyword, categoryId, tagIds, sort, page, size, memberId }),
+ },
+ {
+ queryKey: [QUERY_KEY.CATEGORY_LIST],
+ queryFn: () => getCategoryList({ memberId }),
+ },
+ {
+ queryKey: [QUERY_KEY.TAG_LIST],
+ queryFn: () => getTagList({ memberId }),
+ },
+ ],
+ });
+};
diff --git a/frontend/src/routes/RouteGuard.tsx b/frontend/src/routes/RouteGuard.tsx
index f198daa6e..1de634701 100644
--- a/frontend/src/routes/RouteGuard.tsx
+++ b/frontend/src/routes/RouteGuard.tsx
@@ -1,8 +1,7 @@
import { ReactElement, useEffect } from 'react';
-import { useNavigate } from 'react-router-dom';
import { ToastContext } from '@/contexts';
-import { useCustomContext } from '@/hooks';
+import { useCustomContext, useCustomNavigate } from '@/hooks';
import { useAuth } from '@/hooks/authentication';
type RouteGuardProps = {
@@ -14,7 +13,7 @@ type RouteGuardProps = {
const RouteGuard = ({ children, isLoginRequired, redirectTo }: RouteGuardProps) => {
const { isLogin, isChecking } = useAuth();
const { infoAlert } = useCustomContext(ToastContext);
- const navigate = useNavigate();
+ const navigate = useCustomNavigate();
useEffect(() => {
if (isLoginRequired && !isChecking && !isLogin) {
diff --git a/frontend/src/routes/router.tsx b/frontend/src/routes/router.tsx
index 1d695ed7b..ca7de82dc 100644
--- a/frontend/src/routes/router.tsx
+++ b/frontend/src/routes/router.tsx
@@ -1,50 +1,81 @@
+import { ErrorBoundary } from '@sentry/react';
+import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router-dom';
-import { Layout } from '@/components';
-import {
- TemplatePage,
- TemplateUploadPage,
- SignupPage,
- LoginPage,
- LandingPage,
- NotFoundPage,
- TemplateExplorePage,
- MyTemplatePage,
-} from '@/pages';
+import { Layout, LoadingFallback } from '@/components';
import RouteGuard from './RouteGuard';
import { END_POINTS } from './endPoints';
+const LandingPage = lazy(() => import('@/pages/LandingPage/LandingPage'));
+const TemplatePage = lazy(() => import('@/pages/TemplatePage/TemplatePage'));
+const TemplateUploadPage = lazy(() => import('@/pages/TemplateUploadPage/TemplateUploadPage'));
+const SignupPage = lazy(() => import('@/pages/SignupPage/SignupPage'));
+const LoginPage = lazy(() => import('@/pages/LoginPage/LoginPage'));
+const NotFoundPage = lazy(() => import('@/pages/NotFoundPage/NotFoundPage'));
+const TemplateExplorePage = lazy(() => import('@/pages/TemplateExplorePage/TemplateExplorePage'));
+const MyTemplatePage = lazy(() => import('@/pages/MyTemplatesPage/MyTemplatePage'));
+
+const CustomSuspense = ({ children }: { children: JSX.Element }) => (
+ Loading...}>{children}
+);
+
const router = createBrowserRouter([
{
- errorElement: ,
- element: ,
+ errorElement: (
+
+
+
+ ),
+ element: (
+
+
+
+ ),
children: [
{
path: END_POINTS.HOME,
- element: ,
+ element: (
+
+
+
+ ),
},
{
path: END_POINTS.MY_TEMPLATES,
element: (
-
+ }>
+ }>
+
+
+
),
},
{
path: END_POINTS.TEMPLATES_EXPLORE,
- element: ,
+ element: (
+
+
+
+ ),
},
{
path: END_POINTS.TEMPLATE,
- element: ,
+ element: (
+
+
+
+ ),
},
{
path: END_POINTS.TEMPLATES_UPLOAD,
element: (
-
+
+
+
),
},
@@ -52,7 +83,9 @@ const router = createBrowserRouter([
path: END_POINTS.SIGNUP,
element: (
-
+
+
+
),
},
@@ -60,13 +93,19 @@ const router = createBrowserRouter([
path: END_POINTS.LOGIN,
element: (
-
+
+
+
),
},
{
path: '*',
- element: ,
+ element: (
+
+
+
+ ),
},
],
},
diff --git a/frontend/src/service/index.ts b/frontend/src/service/index.ts
index 927da8040..81faf1ecc 100644
--- a/frontend/src/service/index.ts
+++ b/frontend/src/service/index.ts
@@ -2,6 +2,6 @@ export {
validateName,
validatePassword,
validateConfirmPassword,
- validateFileName,
+ validateFilename,
validateSourceCode,
} from './validates';
diff --git a/frontend/src/service/validates.ts b/frontend/src/service/validates.ts
index 53f9f46b2..53d075378 100644
--- a/frontend/src/service/validates.ts
+++ b/frontend/src/service/validates.ts
@@ -26,15 +26,15 @@ export const validatePassword = (password: string) => {
export const validateConfirmPassword = (password: string, confirmPassword: string) =>
password === confirmPassword ? '' : '비밀번호가 일치하지 않습니다.';
-export const validateFileName = (fileName: string) => {
+export const validateFilename = (filename: string) => {
const MAX_LENGTH = 255;
const invalidChars = /[<>:"/\\|?*]/;
- if (fileName.length > MAX_LENGTH) {
+ if (filename.length > MAX_LENGTH) {
return `파일명의 길이는 ${MAX_LENGTH}자 이내로 입력해주세요!`;
}
- if (invalidChars.test(fileName)) {
+ if (invalidChars.test(filename)) {
return '특수 문자 (<, >, :, ", /, , |, ?, *)는 사용할 수 없습니다!';
}
@@ -46,7 +46,7 @@ export const validateSourceCode = (sourceCode: string) => {
const currentByteSize = getByteSize(sourceCode);
if (currentByteSize > MAX_CONTENT_SIZE) {
- return `소스코드는 최대 ${MAX_CONTENT_SIZE} 바이트까지 입력할 수 있습니다!.`;
+ return `소스코드는 최대 ${MAX_CONTENT_SIZE} 바이트까지 입력할 수 있습니다!`;
}
return '';
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts
index 79afbbf00..d50713e0f 100644
--- a/frontend/src/utils/index.ts
+++ b/frontend/src/utils/index.ts
@@ -2,6 +2,5 @@ export { formatRelativeTime } from './formatRelativeTime';
export { formatWithK } from './formatWithK';
export { getByteSize } from './getByteSize';
export { getLanguageByFilename } from './getLanguageByFileName';
-export { removeAllWhitespace } from './removeAllWhitespace';
export { remToPx } from './remToPx';
export { scroll } from './scroll';
diff --git a/frontend/src/utils/removeAllWhitespace.ts b/frontend/src/utils/removeAllWhitespace.ts
deleted file mode 100644
index 376c8dbbb..000000000
--- a/frontend/src/utils/removeAllWhitespace.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const removeAllWhitespace = (str: string) => str.replace(/\s/g, '');
diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js
index 01923dd9f..6f4429637 100644
--- a/frontend/webpack.common.js
+++ b/frontend/webpack.common.js
@@ -84,5 +84,4 @@ module.exports = {
},
extensions: ['.tsx', '.ts', '.js'],
},
- devtool: 'source-map',
};
diff --git a/frontend/webpack.prod.js b/frontend/webpack.prod.js
index a62146e97..f56735d20 100644
--- a/frontend/webpack.prod.js
+++ b/frontend/webpack.prod.js
@@ -1,6 +1,5 @@
const { merge } = require('webpack-merge');
const Dotenv = require('dotenv-webpack');
-
const common = require('./webpack.common.js');
module.exports = () => {
@@ -15,5 +14,10 @@ module.exports = () => {
ignoreStub: true,
}),
],
+ optimization: {
+ splitChunks: {
+ chunks: 'all',
+ },
+ },
});
};