diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 0bf11a65e..f954f9d02 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -4,6 +4,8 @@ import { MemoryRouter } from 'react-router-dom'; import GlobalStyles from '../src/style/GlobalStyles'; import { AuthProvider, ToastProvider } from '../src/contexts'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ThemeProvider } from '@emotion/react'; +import { theme } from '../src/style/theme'; const queryClient = new QueryClient(); @@ -20,14 +22,16 @@ const preview: Preview = { decorators: [ (Story) => ( - - - - - - - - + + + + + + + + + + ), ], diff --git a/frontend/src/api/authentication.ts b/frontend/src/api/authentication.ts index 89998ce5d..fccd25713 100644 --- a/frontend/src/api/authentication.ts +++ b/frontend/src/api/authentication.ts @@ -12,10 +12,10 @@ export const postLogin = async (loginInfo: LoginRequest) => { export const postLogout = async () => await apiClient.post(END_POINTS.LOGOUT, {}); -export const getLoginState = async () => apiClient.get(END_POINTS.LOGIN_CHECK); +export const getLoginState = async () => await apiClient.get(END_POINTS.LOGIN_CHECK); export const checkName = async (name: string) => { const params = new URLSearchParams({ name }); - await apiClient.get(`${END_POINTS.CHECK_NAME}?${params.toString()}`); + return await apiClient.get(`${END_POINTS.CHECK_NAME}?${params.toString()}`); }; diff --git a/frontend/src/components/Radio/Radio.stories.tsx b/frontend/src/components/Radio/Radio.stories.tsx new file mode 100644 index 000000000..e9f7e0cae --- /dev/null +++ b/frontend/src/components/Radio/Radio.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import Radio from './Radio'; + +const meta: Meta = { + title: 'Radio', + component: Radio, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const options = { 코: '코', 드: '드', 잽: '잽' }; + const [currentValue, setCurrentValue] = useState('코'); + + return ; + }, +}; diff --git a/frontend/src/components/Radio/Radio.style.ts b/frontend/src/components/Radio/Radio.style.ts new file mode 100644 index 000000000..b38fb3a7d --- /dev/null +++ b/frontend/src/components/Radio/Radio.style.ts @@ -0,0 +1,25 @@ +import styled from '@emotion/styled'; + +export const RadioContainer = styled.div` + display: flex; + gap: 2rem; +`; + +export const RadioOption = styled.button` + cursor: pointer; + + display: flex; + gap: 1rem; + align-items: center; + justify-content: center; +`; + +export const RadioCircle = styled.div<{ isSelected: boolean }>` + width: 1rem; + height: 1rem; + + background-color: ${({ theme, isSelected }) => + isSelected ? theme.color.light.primary_500 : theme.color.light.secondary_300}; + border: ${({ theme }) => `0.18rem solid ${theme.color.light.secondary_300}`}; + border-radius: 100%; +`; diff --git a/frontend/src/components/Radio/Radio.tsx b/frontend/src/components/Radio/Radio.tsx new file mode 100644 index 000000000..8b6bb8c77 --- /dev/null +++ b/frontend/src/components/Radio/Radio.tsx @@ -0,0 +1,23 @@ +import { Text } from '@/components'; +import { theme } from '@/style/theme'; + +import * as S from './Radio.style'; + +interface Props { + options: Record; + currentValue: T; + handleCurrentValue: (value: T) => void; +} + +const Radio = ({ options, currentValue, handleCurrentValue }: Props) => ( + + {Object.keys(options).map((optionKey) => ( + handleCurrentValue(optionKey as T)}> + + {options[optionKey as T]} + + ))} + +); + +export default Radio; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 2b2b859f3..ac3fc2af4 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -30,6 +30,7 @@ export { default as ContactUs } from './ContactUs/ContactUs'; export { default as Toggle } from './Toggle/Toggle'; export { default as ScrollTopButton } from './ScrollTopButton/ScrollTopButton'; export { default as AuthorInfo } from './AuthorInfo/AuthorInfo'; +export { default as Radio } from './Radio/Radio'; // Skeleton UI export { default as LoadingBall } from './LoadingBall/LoadingBall'; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index ee9d3cbe0..3740125b3 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -14,3 +14,4 @@ export { useSelectList } from './useSelectList'; export { useCustomNavigate } from './useCustomNavigate'; export { useQueryParams } from './useQueryParams'; export { useToast } from './useToast'; +export { usePreventDuplicateMutation } from './usePreventDuplicateMutation'; diff --git a/frontend/src/hooks/usePreventDuplicateMutation.ts b/frontend/src/hooks/usePreventDuplicateMutation.ts new file mode 100644 index 000000000..f5b5fa8d5 --- /dev/null +++ b/frontend/src/hooks/usePreventDuplicateMutation.ts @@ -0,0 +1,38 @@ +import { useMutation, UseMutationOptions, UseMutationResult, MutationFunction } from '@tanstack/react-query'; +import { useRef } from 'react'; + +type RequiredMutationFn = UseMutationOptions< + TData, + TError, + TVariables, + TContext +> & { + mutationFn: MutationFunction; +}; + +export const usePreventDuplicateMutation = ( + options: RequiredMutationFn, +): UseMutationResult => { + const isMutatingRef = useRef(false); + + const originalMutationFn = options.mutationFn; + + const preventDuplicateMutationFn: MutationFunction = async (variables: TVariables) => { + if (isMutatingRef.current) { + return undefined as TData; + } + + isMutatingRef.current = true; + + try { + return await originalMutationFn(variables); + } finally { + isMutatingRef.current = false; + } + }; + + return useMutation({ + ...options, + mutationFn: preventDuplicateMutationFn, + }); +}; diff --git a/frontend/src/pages/TemplateEditPage/TemplateEditPage.style.ts b/frontend/src/pages/TemplateEditPage/TemplateEditPage.style.ts index e35b328e8..ae844b117 100644 --- a/frontend/src/pages/TemplateEditPage/TemplateEditPage.style.ts +++ b/frontend/src/pages/TemplateEditPage/TemplateEditPage.style.ts @@ -23,12 +23,6 @@ export const MainContainer = styled.div` margin-top: 3rem; `; -export const CategoryAndVisibilityContainer = styled.div` - display: flex; - align-items: flex-end; - justify-content: space-between; -`; - export const CancelButton = styled(Button)` background-color: white; `; diff --git a/frontend/src/pages/TemplateEditPage/TemplateEditPage.tsx b/frontend/src/pages/TemplateEditPage/TemplateEditPage.tsx index 83b0ad0e4..a927155dd 100644 --- a/frontend/src/pages/TemplateEditPage/TemplateEditPage.tsx +++ b/frontend/src/pages/TemplateEditPage/TemplateEditPage.tsx @@ -1,14 +1,27 @@ import { useState } from 'react'; -import { PlusIcon, PrivateIcon, PublicIcon } from '@/assets/images'; -import { Button, Input, SelectList, SourceCodeEditor, Text, CategoryDropdown, TagInput, Toggle } from '@/components'; +import { PlusIcon } from '@/assets/images'; +import { + Button, + Input, + SelectList, + SourceCodeEditor, + Text, + CategoryDropdown, + TagInput, + LoadingBall, + Textarea, + Radio, +} from '@/components'; import { useInput, useSelectList, useToast } from '@/hooks'; import { useAuth } from '@/hooks/authentication'; import { useCategory } from '@/hooks/category'; import { useTag, useSourceCode } from '@/hooks/template'; import { useTemplateEditMutation } from '@/queries/templates'; import { useTrackPageViewed } from '@/service/amplitude'; -import { TEMPLATE_VISIBILITY } from '@/service/constants'; +import { VISIBILITY_OPTIONS } from '@/service/constants'; +import { generateUniqueFilename, isFilenameEmpty } from '@/service/generateUniqueFilename'; +import { validateTemplate } from '@/service/validates'; import { ICON_SIZE } from '@/style/styleConstants'; import { theme } from '@/style/theme'; import type { Template, TemplateEditRequest } from '@/types'; @@ -49,44 +62,20 @@ const TemplateEditPage = ({ template, toggleEditButton }: Props) => { const { currentOption: currentFile, linkedElementRefs: sourceCodeRefs, handleSelectOption } = useSelectList(); - const { mutateAsync: updateTemplate, error } = useTemplateEditMutation(template.id); + const { mutateAsync: updateTemplate, isPending, 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()); - + if (!canSaveTemplate()) { 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 { createSourceCodes, updateSourceCodes } = generateProcessedSourceCodes(); const templateUpdate: TemplateEditRequest = { id: template.id, @@ -100,31 +89,43 @@ const TemplateEditPage = ({ template, toggleEditButton }: Props) => { visibility, }; - try { - await updateTemplate(templateUpdate); - toggleEditButton(); - } catch (error) { - console.error('Failed to update template:', error); + await updateTemplate(templateUpdate); + toggleEditButton(); + }; + + const canSaveTemplate = () => { + const errorMessage = validateTemplate(title, sourceCodes); + + if (errorMessage) { + failAlert(errorMessage); + + return false; } + + return true; + }; + + const generateProcessedSourceCodes = () => { + const processSourceCodes = sourceCodes.map((sourceCode, index) => { + const { filename } = sourceCode; + + return { + ...sourceCode, + ordinal: index + 1, + filename: isFilenameEmpty(filename) ? generateUniqueFilename() : filename, + }; + }); + + const createSourceCodes = processSourceCodes.filter((sourceCode) => !sourceCode.id); + const updateSourceCodes = processSourceCodes.filter((sourceCode) => sourceCode.id); + + return { createSourceCodes, updateSourceCodes }; }; return ( - - - , - , - ]} - optionSliderColor={[undefined, theme.color.light.triadic_primary_800]} - selectedOption={visibility} - switchOption={setVisibility} - /> - + @@ -132,13 +133,15 @@ const TemplateEditPage = ({ template, toggleEditButton }: Props) => { - - + - + {sourceCodes.map((sourceCode, index) => ( { - - - 취소 - - - + + + {isPending ? ( + + ) : ( + + + 취소 + + + + )} {error && Error: {error.message}} diff --git a/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.style.ts b/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.style.ts index e35b328e8..ae844b117 100644 --- a/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.style.ts +++ b/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.style.ts @@ -23,12 +23,6 @@ export const MainContainer = styled.div` margin-top: 3rem; `; -export const CategoryAndVisibilityContainer = styled.div` - display: flex; - align-items: flex-end; - justify-content: space-between; -`; - export const CancelButton = styled(Button)` background-color: white; `; diff --git a/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.tsx b/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.tsx index 2e8f33d79..2923ff8b9 100644 --- a/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.tsx +++ b/frontend/src/pages/TemplateUploadPage/TemplateUploadPage.tsx @@ -1,14 +1,27 @@ import { useState } from 'react'; -import { PlusIcon, PrivateIcon, PublicIcon } from '@/assets/images'; -import { Button, CategoryDropdown, Input, SelectList, SourceCodeEditor, TagInput, Text, Toggle } from '@/components'; +import { PlusIcon } from '@/assets/images'; +import { + Button, + CategoryDropdown, + Input, + LoadingBall, + Radio, + SelectList, + SourceCodeEditor, + TagInput, + Text, + Textarea, +} from '@/components'; import { useCustomNavigate, useInput, useSelectList, useToast } from '@/hooks'; import { useAuth } from '@/hooks/authentication'; import { useCategory } from '@/hooks/category'; import { useSourceCode, useTag } from '@/hooks/template'; import { useTemplateUploadMutation } from '@/queries/templates'; import { trackClickTemplateSave, useTrackPageViewed } from '@/service/amplitude'; -import { DEFAULT_TEMPLATE_VISIBILITY, TEMPLATE_VISIBILITY } from '@/service/constants'; +import { DEFAULT_TEMPLATE_VISIBILITY, VISIBILITY_OPTIONS } from '@/service/constants'; +import { generateUniqueFilename, isFilenameEmpty } from '@/service/generateUniqueFilename'; +import { validateTemplate } from '@/service/validates'; import { ICON_SIZE } from '@/style/styleConstants'; import { theme } from '@/style/theme'; import { TemplateUploadRequest } from '@/types'; @@ -53,54 +66,23 @@ const TemplateUploadPage = () => { 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 { mutateAsync: uploadTemplate, isPending, error } = useTemplateUploadMutation(); const handleCancelButton = () => { navigate(-1); }; const handleSaveButtonClick = async () => { - if (categoryProps.isCategoryQueryFetching) { - failAlert('카테고리 목록을 불러오는 중입니다. 잠시 후 다시 시도해주세요.'); - - return; - } - - const errorMessage = validateTemplate(); - - if (errorMessage) { - failAlert(errorMessage); - + if (!canSaveTemplate()) { return; } - const orderedSourceCodes = sourceCodes.map( - (sourceCode, index): SourceCodes => ({ - ...sourceCode, - ordinal: index + 1, - }), - ); + const processedSourceCodes = generateProcessedSourceCodes(); const newTemplate: TemplateUploadRequest = { title, description, - sourceCodes: orderedSourceCodes, + sourceCodes: processedSourceCodes, thumbnailOrdinal: 1, categoryId: categoryProps.currentValue.id, tags: tagProps.tags, @@ -110,31 +92,51 @@ const TemplateUploadPage = () => { const response = await uploadTemplate(newTemplate); if (response.ok) { - trackClickTemplateSave({ - templateTitle: title, - sourceCodeCount: sourceCodes.length, - visibility, - }); + trackTemplateSaveSuccess(); } }; + const canSaveTemplate = (): boolean => { + if (categoryProps.isCategoryQueryFetching) { + failAlert('카테고리 목록을 불러오는 중입니다. 잠시 후 다시 시도해주세요.'); + + return false; + } + + const errorMessage = validateTemplate(title, sourceCodes); + + if (errorMessage) { + failAlert(errorMessage); + + return false; + } + + return true; + }; + + const generateProcessedSourceCodes = (): SourceCodes[] => + sourceCodes.map((sourceCode, index): SourceCodes => { + const { filename } = sourceCode; + + return { + ...sourceCode, + ordinal: index + 1, + filename: isFilenameEmpty(filename) ? generateUniqueFilename() : filename, + }; + }); + + const trackTemplateSaveSuccess = () => { + trackClickTemplateSave({ + templateTitle: title, + sourceCodeCount: sourceCodes.length, + visibility, + }); + }; + return ( - - - , - , - ]} - optionSliderColor={[undefined, theme.color.light.triadic_primary_800]} - selectedOption={visibility} - switchOption={setVisibility} - /> - + @@ -142,13 +144,15 @@ const TemplateUploadPage = () => { - - + - + {sourceCodes.map((sourceCode, index) => ( { - - - 취소 - - - + + + {isPending ? ( + + ) : ( + + + 취소 + + + + )} {error && Error: {error.message}} diff --git a/frontend/src/queries/templates/useTemplateEditMutation.ts b/frontend/src/queries/templates/useTemplateEditMutation.ts index bee247573..bdd154013 100644 --- a/frontend/src/queries/templates/useTemplateEditMutation.ts +++ b/frontend/src/queries/templates/useTemplateEditMutation.ts @@ -1,11 +1,12 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { QUERY_KEY, editTemplate } from '@/api'; +import { usePreventDuplicateMutation } from '@/hooks'; export const useTemplateEditMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation({ + return usePreventDuplicateMutation({ mutationFn: editTemplate, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEY.TEMPLATE, id] }); diff --git a/frontend/src/queries/templates/useTemplateUploadMutation.ts b/frontend/src/queries/templates/useTemplateUploadMutation.ts index 07f883342..fc9b4ba23 100644 --- a/frontend/src/queries/templates/useTemplateUploadMutation.ts +++ b/frontend/src/queries/templates/useTemplateUploadMutation.ts @@ -1,16 +1,16 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { QUERY_KEY, postTemplate } from '@/api'; import { ApiError, HTTP_STATUS } from '@/api/Error'; -import { useToast } from '@/hooks'; +import { usePreventDuplicateMutation, useToast } from '@/hooks'; export const useTemplateUploadMutation = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); const { failAlert } = useToast(); - return useMutation({ + return usePreventDuplicateMutation({ mutationFn: postTemplate, onSuccess: (res) => { const location = res.headers.get('location'); diff --git a/frontend/src/service/constants.ts b/frontend/src/service/constants.ts index faf8513a8..4b8e83671 100644 --- a/frontend/src/service/constants.ts +++ b/frontend/src/service/constants.ts @@ -1,4 +1,8 @@ export const VISIBILITY_PUBLIC = 'PUBLIC'; export const VISIBILITY_PRIVATE = 'PRIVATE'; export const DEFAULT_TEMPLATE_VISIBILITY = VISIBILITY_PUBLIC; -export const TEMPLATE_VISIBILITY = [VISIBILITY_PRIVATE, VISIBILITY_PUBLIC] as const; + +export const VISIBILITY_OPTIONS = { + PUBLIC: '전체 공개', + PRIVATE: '비공개', +} as const; diff --git a/frontend/src/service/generateUniqueFilename.ts b/frontend/src/service/generateUniqueFilename.ts new file mode 100644 index 000000000..56aa1188f --- /dev/null +++ b/frontend/src/service/generateUniqueFilename.ts @@ -0,0 +1,3 @@ +export const isFilenameEmpty = (filename: string) => !filename.trim(); + +export const generateUniqueFilename = () => `file_${Math.random().toString(36).substring(2, 10)}.txt`; diff --git a/frontend/src/service/validates.ts b/frontend/src/service/validates.ts index 29377781f..a8150e7de 100644 --- a/frontend/src/service/validates.ts +++ b/frontend/src/service/validates.ts @@ -1,3 +1,4 @@ +import { SourceCodes } from '@/types'; import { getByteSize } from '@/utils'; export const validateName = (name: string) => { @@ -91,3 +92,15 @@ export const validateEmail = (email: string) => { return ''; }; + +export const validateTemplate = (title: string, sourceCodes: SourceCodes[]) => { + if (!title) { + return '제목을 입력해주세요'; + } + + if (sourceCodes.filter(({ content }) => !content || content.trim() === '').length) { + return '소스코드 내용을 입력해주세요'; + } + + return ''; +}; diff --git a/frontend/src/types/template.ts b/frontend/src/types/template.ts index 7e82cc3fe..8fa14bdb6 100644 --- a/frontend/src/types/template.ts +++ b/frontend/src/types/template.ts @@ -1,4 +1,4 @@ -import { TEMPLATE_VISIBILITY } from '@/service/constants'; +import { VISIBILITY_OPTIONS } from '@/service/constants'; export interface SourceCodes { id?: number; @@ -57,4 +57,4 @@ export interface TemplateListItem { visibility: TemplateVisibility; } -export type TemplateVisibility = (typeof TEMPLATE_VISIBILITY)[number]; +export type TemplateVisibility = keyof typeof VISIBILITY_OPTIONS;