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;