diff --git a/src/app/[lng]/(main)/community/write/components/ImageSection.tsx b/src/app/[lng]/(main)/community/write/components/ImageSection.tsx new file mode 100644 index 000000000..5408c18ed --- /dev/null +++ b/src/app/[lng]/(main)/community/write/components/ImageSection.tsx @@ -0,0 +1,94 @@ +import Image from 'next/image'; +import { memo, useCallback } from 'react'; +import { Control, useController } from 'react-hook-form'; + +import { WriteFormType } from '../type'; +import { Icon } from '@/components/Icon'; +import { Flex } from '@/components/Layout'; +import { Loading } from '@/components/Loading'; +import { useFileUpload } from '@/hooks/useFileUpload'; + +interface ImageSectionProps { + control: Control; +} + +export default function ImageSection({ control }: ImageSectionProps) { + const { + field: { value, onChange }, + } = useController({ + name: 'images', + control, + }); + + const { handleFileUploadClick, isLoading } = useFileUpload((files) => { + onChange([...value, ...files]); + }); + + const handleDeleteClick = useCallback( + (imageUrl: string) => onChange(value.filter((v) => v !== imageUrl)), + [onChange, value] + ); + + return ( +
+ + {value.map((imageUrl, index) => ( + + ))} + {isLoading && ( + + + + )} + {value.length < 3 && !(isLoading && value.length === 2) && ( + + )} + +
+ ); +} + +interface AddImageSectionProps { + imageCount: number; + onClick: () => void; +} + +function AddImageButton({ imageCount, onClick }: AddImageSectionProps) { + return ( + + +

{imageCount}/3

+
+ ); +} + +interface ImageThumbnailProps { + imageUrl: string; + onClick: (imageUrl: string) => void; +} + +const ImageThumbnail = memo(({ imageUrl, onClick }: ImageThumbnailProps) => { + return ( +
+ select-img + onClick(imageUrl)} + /> +
+ ); +}); diff --git a/src/app/[lng]/(main)/community/write/components/InputSection.tsx b/src/app/[lng]/(main)/community/write/components/InputSection.tsx new file mode 100644 index 000000000..2ca9c63c5 --- /dev/null +++ b/src/app/[lng]/(main)/community/write/components/InputSection.tsx @@ -0,0 +1,93 @@ +'use client'; +import { SubmitHandler, useForm } from 'react-hook-form'; + +import WriteModal from '../components/WriteModal'; +import { WriteFormType } from '../type'; +import { useTranslation } from '@/app/i18n/client'; +import { Button, ButtonGroup } from '@/components/Button'; +import MultiImageUploader from '@/components/Image/MultiImageUploader'; +import ListBoxController from '@/components/ListBox/ListBoxController'; +import { Spacing } from '@/components/Spacing'; +import { TextFieldController } from '@/components/TextField'; +import { useModal } from '@/hooks/useModal'; + +export default function InputSection() { + const { open, exit } = useModal(); + const { t } = useTranslation('community'); + const hookForm = useForm({ + mode: 'onChange', + defaultValues: { + category: 'NONE', + title: '', + content: '', + images: [], + }, + }); + + const { register, handleSubmit, formState, control } = hookForm; + + const onSubmit: SubmitHandler = (formData) => { + console.log(formData); + }; + + const options = [t('create.category.kpop'), t('create.category.qna'), t('create.category.lang')]; + + return ( +
+
+ + value !== 'NONE', + })} + /> + + + +
+ +
+ + +
+ + control={control} name={'images'} /> + + + + + +
+ ); +} diff --git a/src/app/[lng]/(main)/community/write/components/WriteHeader.tsx b/src/app/[lng]/(main)/community/write/components/WriteHeader.tsx new file mode 100644 index 000000000..3a56b444e --- /dev/null +++ b/src/app/[lng]/(main)/community/write/components/WriteHeader.tsx @@ -0,0 +1,40 @@ +'use client'; + +import WriteModal from '../components/WriteModal'; +import { useTranslation } from '@/app/i18n/client'; +import { IconButton } from '@/components/Button'; +import { Header } from '@/components/Header'; +import { Icon } from '@/components/Icon'; +import useAppRouter from '@/hooks/useAppRouter'; +import { useModal } from '@/hooks/useModal'; + +export default function WriteHeader() { + const { t } = useTranslation('community'); + const { back } = useAppRouter(); + const { open, exit } = useModal(); + + return ( +
+ + + open(() => ( + { + exit(); + back(); + }} + /> + )) + } + > + + +

{t('create.headerTitle')}

+
+
+ ); +} diff --git a/src/app/[lng]/(main)/community/write/components/WriteModal.tsx b/src/app/[lng]/(main)/community/write/components/WriteModal.tsx new file mode 100644 index 000000000..4de3176f7 --- /dev/null +++ b/src/app/[lng]/(main)/community/write/components/WriteModal.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from '@/app/i18n/client'; +import { Icon } from '@/components/Icon'; +import { Modal } from '@/components/Modal'; +import { Spacing } from '@/components/Spacing'; + +import type { ComponentProps } from 'react'; + +type ModalStyleType = { + [key in WriteModalProps['type']]: { + variant: NonNullable['variant']>; + iconId: string; + content: string; + }; +}; + +interface WriteModalProps { + type: 'write' | 'cancel'; + onOkClick: () => void; + onCancelClick: () => void; +} + +export default function WriteModal({ type, onOkClick, onCancelClick }: WriteModalProps) { + const { t } = useTranslation('community'); + const modalStyle: ModalStyleType = { + write: { + variant: 'success', + iconId: '48-check', + content: t('create.submit.content'), + }, + cancel: { + variant: 'warning', + iconId: '48-warning', + content: t('create.cancel.content'), + }, + }; + const { variant, iconId, content } = modalStyle[type]; + + return ( + + + + +

{content}

+ +
+ ); +} diff --git a/src/app/[lng]/(main)/community/write/page.tsx b/src/app/[lng]/(main)/community/write/page.tsx index d91d1f5df..de75ade07 100644 --- a/src/app/[lng]/(main)/community/write/page.tsx +++ b/src/app/[lng]/(main)/community/write/page.tsx @@ -1,7 +1,11 @@ +import InputSection from '@/app/[lng]/(main)/community/write/components/InputSection'; +import WriteHeader from '@/app/[lng]/(main)/community/write/components/WriteHeader'; + export default function CommunityWritePage() { return ( -
-

커뮤니티 글 작성 페이지

-
+ <> + + + ); } diff --git a/src/app/[lng]/(main)/community/write/type.ts b/src/app/[lng]/(main)/community/write/type.ts new file mode 100644 index 000000000..de832b4dc --- /dev/null +++ b/src/app/[lng]/(main)/community/write/type.ts @@ -0,0 +1,6 @@ +export interface WriteFormType { + title: string; + content: string; + category: string; + images: string[]; +} diff --git a/src/app/i18n/locales/ko/community.json b/src/app/i18n/locales/ko/community.json index 52bb85c08..88a491fad 100644 --- a/src/app/i18n/locales/ko/community.json +++ b/src/app/i18n/locales/ko/community.json @@ -2,5 +2,29 @@ "all": "전체", "daily": "일상톡톡", "question": "궁금해요", - "language": "언어교환" + "language": "언어교환", + + "create": { + "headerTitle": "게시글 작성", + "category": { + "name": "카테고리", + + "kpop": "K-POP", + "qna": "궁금해요", + "lang": "언어교환" + }, + "title": { + "placeholder": "게시글 제목" + }, + "content": { + "placeholder": "최소 20글자 이상의 게시글을 작성해보세요." + }, + "submit": { + "label": "글쓰기", + "content": "게시글을 등록하시겠습니까?" + }, + "cancel": { + "content": "게시글 작성을 취소하시겠습니" + } + } } diff --git a/src/components/Image/MultiImageUploader.tsx b/src/components/Image/MultiImageUploader.tsx new file mode 100644 index 000000000..55b1f4d5b --- /dev/null +++ b/src/components/Image/MultiImageUploader.tsx @@ -0,0 +1,97 @@ +import Image from 'next/image'; +import { memo, useCallback } from 'react'; +import { Control, FieldValues, Path, useController } from 'react-hook-form'; + +import { Icon } from '@/components/Icon'; +import { Flex } from '@/components/Layout'; +import { Loading } from '@/components/Loading'; +import { useFileUpload } from '@/hooks/useFileUpload'; + +interface MultiImageUploaderProps { + control: Control; + name: Path; +} + +export default function MultiImageUploader({ + name, + control, +}: MultiImageUploaderProps) { + const { + field: { value, onChange }, + } = useController({ + name, + control, + }); + + const { handleFileUploadClick, isLoading } = useFileUpload((files) => { + onChange([...value, ...files]); + }); + + const handleDeleteClick = useCallback( + (imageUrl: string) => onChange(value.filter((v: string) => v !== imageUrl)), + [onChange, value] + ); + + return ( +
+ + {value.map((imageUrl: string, index: number) => ( + + ))} + {isLoading && ( + + + + )} + {value.length < 3 && !(isLoading && value.length === 2) && ( + + )} + +
+ ); +} + +interface AddImageSectionProps { + imageCount: number; + onClick: () => void; +} + +function AddImageButton({ imageCount, onClick }: AddImageSectionProps) { + return ( + + +

{imageCount}/3

+
+ ); +} + +interface ImageThumbnailProps { + imageUrl: string; + onClick: (imageUrl: string) => void; +} + +const ImageThumbnail = memo(({ imageUrl, onClick }: ImageThumbnailProps) => { + return ( +
+ select-img + onClick(imageUrl)} + /> +
+ ); +}); diff --git a/src/components/ListBox/ListBox.tsx b/src/components/ListBox/ListBox.tsx new file mode 100644 index 000000000..35bc7405a --- /dev/null +++ b/src/components/ListBox/ListBox.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { Icon } from '@/components/Icon'; +import { useListBoxContext } from '@/components/ListBox/ListBoxController'; +import cn from '@/utils/cn'; + +import type { StrictPropsWithChildren } from '@/types'; + +interface ListBoxProps { + name: string; +} + +export default function ListBox({ name, children }: StrictPropsWithChildren) { + const { open, setOpen } = useListBoxContext(); + + return ( + <> +
setOpen(!open)} + > +
{name}
+ +
+ {open &&
{children}
} + + ); +} diff --git a/src/components/ListBox/ListBoxController.tsx b/src/components/ListBox/ListBoxController.tsx new file mode 100644 index 000000000..1623da313 --- /dev/null +++ b/src/components/ListBox/ListBoxController.tsx @@ -0,0 +1,47 @@ +import React, { ReactNode, createContext, useContext, useState } from 'react'; +import { UseFormRegisterReturn } from 'react-hook-form'; + +import ListBox from '@/components/ListBox/ListBox'; +import ListBoxOptions from '@/components/ListBox/ListBoxOptions'; + +interface ListBoxControllerProps { + name: string; + options: string[]; + register: UseFormRegisterReturn; +} + +interface ListBoxProviderProps { + children: ReactNode; +} + +const ListBoxContext = createContext({ + open: false, + setOpen: (open: boolean) => {}, +}); + +export const useListBoxContext = () => useContext(ListBoxContext); + +const ListBoxProvider = ({ children }: ListBoxProviderProps) => { + const [open, setOpen] = useState(false); + + return {children}; +}; + +export default function ListBoxController({ name, options, register }: ListBoxControllerProps) { + const [selectedValue, setSelectedValue] = useState(name); + + const handleSelect = (value: string) => { + setSelectedValue(value); + register.onChange({ + target: { value, name: register.name }, + } as React.ChangeEvent); + }; + + return ( + + + + + + ); +} diff --git a/src/components/ListBox/ListBoxOptions.tsx b/src/components/ListBox/ListBoxOptions.tsx new file mode 100644 index 000000000..63489c672 --- /dev/null +++ b/src/components/ListBox/ListBoxOptions.tsx @@ -0,0 +1,29 @@ +import React, { Dispatch, SetStateAction } from 'react'; + +import { useListBoxContext } from '@/components/ListBox/ListBoxController'; + +interface ListBoxOptionsProps { + options: string[]; + onSelect: (value: string) => void; +} + +export default function ListBoxOptions({ options, onSelect }: ListBoxOptionsProps) { + const { setOpen } = useListBoxContext(); + + return ( +
+ {options.map((option) => ( +
{ + onSelect(option); + setOpen(false); + }} + className={'p-16'} + > + {option} +
+ ))} +
+ ); +} diff --git a/src/components/ListBox/index.ts b/src/components/ListBox/index.ts new file mode 100644 index 000000000..43944d25d --- /dev/null +++ b/src/components/ListBox/index.ts @@ -0,0 +1 @@ +export { default as InitMap } from './ListBox';