diff --git a/frontend/package.json b/frontend/package.json index 154fc67c..010df8d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "nanoid": "^5.0.8", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.53.2", "react-router-dom": "^6.27.0", "socket.io-client": "^4.8.1", "styled-components": "^6.1.13", diff --git a/frontend/src/apis/queries/host/useUpdateHost.ts b/frontend/src/apis/queries/host/useUpdateHost.ts index 8a1f52b9..794bf297 100644 --- a/frontend/src/apis/queries/host/useUpdateHost.ts +++ b/frontend/src/apis/queries/host/useUpdateHost.ts @@ -1,5 +1,6 @@ -import { HostInfo, updateHost } from '@apis/updateHost'; +import { updateHost } from '@apis/updateHost'; import { useMutation, UseMutationResult } from '@tanstack/react-query'; +import { HostInfo } from '@type/hostInfo'; type Params = { onSuccess?: (data: HostInfo) => void; diff --git a/frontend/src/apis/updateHost.ts b/frontend/src/apis/updateHost.ts index 65cfcd40..4bb4c350 100644 --- a/frontend/src/apis/updateHost.ts +++ b/frontend/src/apis/updateHost.ts @@ -1,13 +1,6 @@ import { AxiosResponse } from 'axios'; import { fetchInstance } from '.'; - -export interface HostInfo { - userId: string; - liveTitle: string; - defaultThumbnailImageUrl: string; - category: string; - tags: string[]; -} +import { HostInfo } from '@type/hostInfo'; export const updateHost = async (hostInfo: HostInfo): Promise => { const response: AxiosResponse = await fetchInstance().post('/host/update', hostInfo); diff --git a/frontend/src/components/host/Form/CategoryField.tsx b/frontend/src/components/host/Form/CategoryField.tsx new file mode 100644 index 00000000..19b543a4 --- /dev/null +++ b/frontend/src/components/host/Form/CategoryField.tsx @@ -0,0 +1,18 @@ +import { Controller, useFormContext } from 'react-hook-form'; +import { FormCell, Input, Label } from './style'; +import { FormValues } from '@type/hostInfo'; + +export default function CategoryField() { + const { control } = useFormContext(); + + return ( + + + } + /> + + ); +} diff --git a/frontend/src/components/host/Form/HostNameField.tsx b/frontend/src/components/host/Form/HostNameField.tsx new file mode 100644 index 00000000..fbc73542 --- /dev/null +++ b/frontend/src/components/host/Form/HostNameField.tsx @@ -0,0 +1,22 @@ +import { Controller, useFormContext } from 'react-hook-form'; +import { FormCell, Input, Label, Required } from './style'; +import { FormValues } from '@type/hostInfo'; + +export default function HostNameField() { + const { control } = useFormContext(); + + return ( + + + ( + + )} + /> + + ); +} diff --git a/frontend/src/components/host/Form/ImageField.tsx b/frontend/src/components/host/Form/ImageField.tsx new file mode 100644 index 00000000..75034d57 --- /dev/null +++ b/frontend/src/components/host/Form/ImageField.tsx @@ -0,0 +1,84 @@ +import { useCallback, useRef } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; +import { FormValues } from '@type/hostInfo'; +import { convertToBase64 } from '@utils/convertToBase64'; +import { + FileInput, + FileInputLabel, + FormCell, + ImageActionButtons, + ImageButton, + ImageContainer, + ImageUpload, + Label, + PlaceholderImage, + PreviewImage, + UploadIcon, + UploadText +} from './style'; + +export default function ImageField() { + const fileInputRef = useRef(null); + const { control, setValue } = useFormContext(); + const previewImage = useWatch({ + control, + name: 'previewImage', + defaultValue: null + }); + + const handleImageChange = useCallback( + async (e: React.ChangeEvent): Promise => { + const files = e.target.files; + if (!files) return; + + const base64 = await convertToBase64(files[0]); + setValue('previewImage', base64); + }, + [setValue] + ); + + const handleImageDelete = useCallback(() => { + setValue('previewImage', null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, [setValue]); + + return ( + + + + {previewImage ? ( + + + + + 수정 + + + 삭제 + + + + ) : ( + + + + + + + 업로드 (1280x720) + + + )} + + + + ); +} diff --git a/frontend/src/components/host/Form/LiveTitleField.tsx b/frontend/src/components/host/Form/LiveTitleField.tsx new file mode 100644 index 00000000..240b6a75 --- /dev/null +++ b/frontend/src/components/host/Form/LiveTitleField.tsx @@ -0,0 +1,27 @@ +import { Controller, useFormContext } from 'react-hook-form'; +import { Bold, CharCount, FormCell, Input, Label, Required } from './style'; +import { FormValues } from '@type/hostInfo'; + +export default function LiveTitleField() { + const { control } = useFormContext(); + + return ( + + + ( + <> + + + {field.value?.length || 0}/100 + + + )} + /> + + ); +} diff --git a/frontend/src/components/host/Form/NoticeField.tsx b/frontend/src/components/host/Form/NoticeField.tsx new file mode 100644 index 00000000..9d86bb49 --- /dev/null +++ b/frontend/src/components/host/Form/NoticeField.tsx @@ -0,0 +1,18 @@ +import { Controller, useFormContext } from 'react-hook-form'; +import { FormValues } from '@type/hostInfo'; +import { FormCell, Input, Label } from './style'; + +export default function NoticeField() { + const { control } = useFormContext(); + + return ( + + + } + /> + + ); +} diff --git a/frontend/src/components/host/Form/TagField.tsx b/frontend/src/components/host/Form/TagField.tsx new file mode 100644 index 00000000..d4799f0b --- /dev/null +++ b/frontend/src/components/host/Form/TagField.tsx @@ -0,0 +1,81 @@ +import { Controller, useFormContext } from 'react-hook-form'; +import { FormValues } from '@type/hostInfo'; +import { + Button, + Flex, + FormCell, + Input, + Label, + RemoveButton, + TagChipContainer, + TagContainer, + UploadText +} from './style'; +import { KeyboardEvent } from 'react'; + +export default function TagField() { + const { control, setValue, getValues } = useFormContext(); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.preventDefault(); + onAddTag(); + } + }; + + const onAddTag = () => { + const currentTag = getValues('tag').trim(); + if (currentTag) { + const currentTags = getValues('tags'); + setValue('tags', [...currentTags, currentTag]); + setValue('tag', ''); + } + }; + + const onRemoveTag = (indexToRemove: number) => { + const currentTags = getValues('tags'); + setValue( + 'tags', + currentTags.filter((_: string, index: number) => index !== indexToRemove) + ); + }; + + return ( + + + + } + /> + + + ( + <>{field.value.length > 0 && } + )} + /> + 공백 및 특수 문자 없이 15자까지 입력할 수 있습니다. + + ); +} + +const TagChip = ({ tag, onRemove }: { tag: string; onRemove: () => void }) => ( + + {tag} + × + +); + +const TagList = ({ tags, onRemoveTag }: { tags: string[]; onRemoveTag: (index: number) => void }) => ( + + {tags.map((tag, index) => ( + onRemoveTag(index)} /> + ))} + +); diff --git a/frontend/src/components/host/Form/index.ts b/frontend/src/components/host/Form/index.ts new file mode 100644 index 00000000..2fa4a02a --- /dev/null +++ b/frontend/src/components/host/Form/index.ts @@ -0,0 +1,6 @@ +export { default as LiveTitleField } from './LiveTitleField'; +export { default as CategoryField } from './CategoryField'; +export { default as TagField } from './TagField'; +export { default as NoticeField } from './NoticeField'; +export { default as ImageField } from './ImageField'; +export { default as HostNameField } from './HostNameField'; diff --git a/frontend/src/components/host/Form/style.tsx b/frontend/src/components/host/Form/style.tsx new file mode 100644 index 00000000..0362992c --- /dev/null +++ b/frontend/src/components/host/Form/style.tsx @@ -0,0 +1,177 @@ +import styled from 'styled-components'; + +export const Flex = styled.div` + display: flex; + gap: 20px; + + input { + flex: 1; + } +`; + +export const FormCell = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +export const Label = styled.label` + font-size: 14px; + color: #525662; + margin-bottom: 8px; +`; + +export const Required = styled.em` + color: #e63a3e; + margin-left: 4px; +`; + +export const CharCount = styled.span` + font-size: 12px; + color: #9da5b6; + margin-top: 4px; + align-self: flex-end; +`; + +export const Bold = styled.span` + font-weight: bold; + color: #000; +`; + +export const Input = styled.input` + padding: 10px; + border: 1px solid #dddddd; + border-radius: 4px; + font-size: 14px; + outline: none; + &:focus { + border-color: #4e41db; + } +`; + +export const ImageUpload = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + width: 300px; +`; + +export const FileInputLabel = styled.label` + cursor: pointer; + width: 100%; +`; + +export const FileInput = styled.input` + display: none; +`; + +export const PreviewImage = styled.img` + width: 100%; + max-width: 300px; + border-radius: 4px; + border: 1px solid #dddddd; +`; + +export const PlaceholderImage = styled.div` + width: 100%; + height: 180px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px dashed #dddddd; + border-radius: 4px; + background-color: #f5f6f8; +`; + +export const UploadIcon = styled.svg` + width: 24px; + height: 24px; + margin-bottom: 8px; +`; + +export const UploadText = styled.span` + font-family: 'NanumGothic', sans-serif; + font-size: 12px; + color: #9da5b6; +`; + +export const Button = styled.button` + padding: 12px; + background-color: rgba(78, 65, 219, 0.1); + color: rgba(78, 65, 219, 0.8); + border: none; + border-radius: 7px; + font-size: 16px; + cursor: pointer; + &:hover { + background-color: rgba(78, 65, 219, 0.2); + } +`; + +export const TagContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +export const TagChipContainer = styled.span` + display: inline-flex; + align-items: center; + padding: 4px 12px; + background-color: #f3f4f6; + border-radius: 9999px; + font-size: 14px; + transition: background-color 0.2s ease; + + &:hover { + background-color: #e5e7eb; + } +`; + +export const RemoveButton = styled.button` + margin-left: 8px; + color: #6b7280; + font-size: 16px; + cursor: pointer; + transition: color 0.2s ease; + background: none; + border: none; + padding: 0 4px; + line-height: 1; + + &:hover { + color: #374151; + } + + &:focus { + outline: none; + } +`; + +export const ImageContainer = styled.div` + position: relative; +`; + +export const ImageActionButtons = styled.div` + display: flex; + gap: 8px; + margin-top: 8px; +`; + +export const ImageButton = styled.button` + flex: 1; + padding: 8px; + background-color: #f3f4f6; + border: none; + border-radius: 4px; + color: #4e41db; + font-size: 14px; + cursor: pointer; + text-align: center; + transition: background-color 0.2s ease; + + &:hover { + background-color: #e5e7eb; + } +`; diff --git a/frontend/src/components/host/SettingForm.tsx b/frontend/src/components/host/SettingForm.tsx index 30612835..24b4eba2 100644 --- a/frontend/src/components/host/SettingForm.tsx +++ b/frontend/src/components/host/SettingForm.tsx @@ -1,147 +1,50 @@ +import { useForm, FormProvider } from 'react-hook-form'; import styled from 'styled-components'; -import { ChangeEvent, useRef, useState } from 'react'; import useUpdateHost from '@apis/queries/host/useUpdateHost'; import { getStoredId } from '@utils/id'; -import { convertToBase64 } from '@utils/convertToBase64'; +import { CategoryField, HostNameField, ImageField, LiveTitleField, NoticeField, TagField } from './Form'; +import { Button } from './Form/style'; +import { FormValues } from '@type/hostInfo'; export default function SettingForm() { - const fileInputRef = useRef(null); const { mutate: updateHost } = useUpdateHost(); + const methods = useForm({ + defaultValues: { + liveTitle: '', + category: '', + tag: '', + tags: [], + notice: '', + previewImage: null + } + }); - const [liveTitle, setLiveTitle] = useState(''); - const [category, setCategory] = useState(''); - const [tag, setTag] = useState(''); - const [tags, setTags] = useState([]); - const [notice, setNotice] = useState(''); - const [previewImage, setPreviewImage] = useState(null); - - const onRemoveTag = (indexToRemove: number) => { - setTags((prev) => prev.filter((_, index) => index !== indexToRemove)); - }; - - const handleImageChange = async (e: ChangeEvent): Promise => { - const files = e.target.files; - if (!files) return; - - const base64 = await convertToBase64(files[0]); - setPreviewImage(base64); - }; - - const handleUpdate = () => { + const onSubmit = ({ liveTitle, category, previewImage, tags, hostName, notice }: FormValues) => { updateHost({ userId: getStoredId(), liveTitle, category, + hostName, + notice, defaultThumbnailImageUrl: previewImage || '', tags }); }; - const handleImageDelete = () => { - setPreviewImage(null); - - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - return ( - - - - - setLiveTitle(e.target.value)} - placeholder="방송 제목을 입력해주세요." - maxLength={100} - /> - - {liveTitle.length} - /100 - - - - - setCategory(e.target.value)} - placeholder="카테고리 검색" - /> - - - - - setTag(e.target.value)} placeholder="태그 추가하기" /> - - - {tags.length > 0 && ( - - {tags.map((tag, index) => ( - - {tag} - onRemoveTag(index)}>× - - ))} - - )} - 공백 및 특수 문자 없이 15자까지 입력할 수 있습니다. - - - - setNotice(e.target.value)} placeholder="공지 추가하기" /> - - - - - - {previewImage ? ( - - - - - 수정 - - - 삭제 - - - - ) : ( - - - - - - - 업로드 (1280x720) - - - )} - - - - - - - + + + + + + + + + + + + + ); } @@ -152,184 +55,8 @@ const Container = styled.div` box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); `; -const Flex = styled.div` - display: flex; - gap: 20px; - - input { - flex: 1; - } -`; - -const FormArea = styled.div` +const FormArea = styled.form` display: flex; flex-direction: column; gap: 20px; `; - -const FormCell = styled.div` - display: flex; - flex-direction: column; - gap: 10px; -`; - -const Label = styled.label` - font-size: 14px; - color: #525662; - margin-bottom: 8px; -`; - -const Required = styled.em` - color: #e63a3e; - margin-left: 4px; -`; - -const Input = styled.input` - padding: 10px; - border: 1px solid #dddddd; - border-radius: 4px; - font-size: 14px; - outline: none; - &:focus { - border-color: #4e41db; - } -`; - -const CharCount = styled.span` - font-size: 12px; - color: #9da5b6; - margin-top: 4px; - align-self: flex-end; -`; - -const Bold = styled.span` - font-weight: bold; - color: #000; -`; - -const ImageUpload = styled.div` - display: flex; - flex-direction: column; - gap: 10px; - width: 300px; -`; - -const FileInputLabel = styled.label` - cursor: pointer; - width: 100%; -`; - -const FileInput = styled.input` - display: none; -`; - -const PreviewImage = styled.img` - width: 100%; - max-width: 300px; - border-radius: 4px; - border: 1px solid #dddddd; -`; - -const PlaceholderImage = styled.div` - width: 100%; - height: 180px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - border: 1px dashed #dddddd; - border-radius: 4px; - background-color: #f5f6f8; -`; - -const UploadIcon = styled.svg` - width: 24px; - height: 24px; - margin-bottom: 8px; -`; - -const UploadText = styled.span` - font-family: 'NanumGothic', sans-serif; - font-size: 12px; - color: #9da5b6; -`; - -const Button = styled.button` - padding: 12px; - background-color: rgba(78, 65, 219, 0.1); - color: rgba(78, 65, 219, 0.8); - border: none; - border-radius: 7px; - font-size: 16px; - cursor: pointer; - &:hover { - background-color: rgba(78, 65, 219, 0.2); - } -`; - -const TagContainer = styled.div` - display: flex; - flex-wrap: wrap; - gap: 8px; -`; - -const TagChip = styled.span` - display: inline-flex; - align-items: center; - padding: 4px 12px; - background-color: #f3f4f6; - border-radius: 9999px; - font-size: 14px; - transition: background-color 0.2s ease; - - &:hover { - background-color: #e5e7eb; - } -`; - -const RemoveButton = styled.button` - margin-left: 8px; - color: #6b7280; - font-size: 16px; - cursor: pointer; - transition: color 0.2s ease; - background: none; - border: none; - padding: 0 4px; - line-height: 1; - - &:hover { - color: #374151; - } - - &:focus { - outline: none; - } -`; - -const ImageContainer = styled.div` - position: relative; -`; - -const ImageActionButtons = styled.div` - display: flex; - gap: 8px; - margin-top: 8px; -`; - -const ImageButton = styled.button` - flex: 1; - padding: 8px; - background-color: #f3f4f6; - border: none; - border-radius: 4px; - color: #4e41db; - font-size: 14px; - cursor: pointer; - text-align: center; - transition: background-color 0.2s ease; - - &:hover { - background-color: #e5e7eb; - } -`; diff --git a/frontend/src/type/hostInfo.ts b/frontend/src/type/hostInfo.ts new file mode 100644 index 00000000..6581042f --- /dev/null +++ b/frontend/src/type/hostInfo.ts @@ -0,0 +1,19 @@ +export interface HostInfo { + userId: string; + liveTitle: string; + hostName: string; + notice: string; + defaultThumbnailImageUrl: string; + category: string; + tags: string[]; +} + +export interface FormValues { + liveTitle: string; + category: string; + tag: string; + tags: string[]; + notice: string; + hostName: string; + previewImage: string | null; +} diff --git a/yarn.lock b/yarn.lock index f1704702..10a5bdff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6665,6 +6665,7 @@ __metadata: nanoid: "npm:^5.0.8" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" + react-hook-form: "npm:^7.53.2" react-router-dom: "npm:^6.27.0" socket.io-client: "npm:^4.8.1" styled-components: "npm:^6.1.13" @@ -9612,6 +9613,15 @@ __metadata: languageName: node linkType: hard +"react-hook-form@npm:^7.53.2": + version: 7.53.2 + resolution: "react-hook-form@npm:7.53.2" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + checksum: 10c0/18336d8e8798a70dcd0af703a0becca2d5dbf82a7b7a3ca334ae0e1f26410490bc3ef2ea51adcf790bb1e7006ed7a763fd00d664e398f71225b23529a7ccf0bf + languageName: node + linkType: hard + "react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1"