diff --git a/public/locales/en/editor.json b/public/locales/en/editor.json new file mode 100644 index 0000000..a1f366d --- /dev/null +++ b/public/locales/en/editor.json @@ -0,0 +1,15 @@ +{ + "title": "Create Epigram", + "content": "Content", + "content_placeholder": "Please enter within 500 characters.", + "author": "Author", + "enter_directly": "Enter directly", + "unknown": "Unknown", + "self": "Self", + "author_placeholder": "Enter author name", + "source": "Source", + "source_placeholder": "Enter source title", + "tag": "Tag", + "tag_placeholder": "Enter a tag (up to 10 characters)", + "submit": "Completed" +} diff --git a/public/locales/ko/editor.json b/public/locales/ko/editor.json new file mode 100644 index 0000000..00e2147 --- /dev/null +++ b/public/locales/ko/editor.json @@ -0,0 +1,15 @@ +{ + "title": "에피그램 만들기", + "content": "내용", + "content_placeholder": "500자 이내로 입력해주세요.", + "author": "저자", + "enter_directly": "직접 입력", + "unknown": "알 수 없음", + "self": "본인", + "author_placeholder": "저자 이름 입력", + "source": "출처", + "source_placeholder": "출처 제목 입력", + "tag": "태그", + "tag_placeholder": "입력하여 태그 작성 (최대 10자)", + "submit": "작성 완료" +} diff --git a/src/components/Radio/index.tsx b/src/components/Radio/index.tsx new file mode 100644 index 0000000..266cfcf --- /dev/null +++ b/src/components/Radio/index.tsx @@ -0,0 +1,21 @@ +type RadioProps = React.InputHTMLAttributes; + +const Radio = (props: RadioProps) => { + return ( + + ); +}; + +export default Radio; diff --git a/src/components/RadioGroup/index.tsx b/src/components/RadioGroup/index.tsx new file mode 100644 index 0000000..f5a7758 --- /dev/null +++ b/src/components/RadioGroup/index.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import Radio from '../Radio'; + +type Option = { label: string; value: string; disabled?: boolean }; + +type RadioGroupProps = { + name: string; + options: Option[]; + value: string; + onChange: (value: string) => void; + className?: string; +}; + +const RadioGroup = ({ name, options, value, onChange, className = '' }: RadioGroupProps) => { + return ( +
+
+ {options.map((opt) => ( + + ))} +
+
+ ); +}; + +export default RadioGroup; diff --git a/src/components/TagInput/index.tsx b/src/components/TagInput/index.tsx new file mode 100644 index 0000000..89f549f --- /dev/null +++ b/src/components/TagInput/index.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useState } from 'react'; +import Input from '../Input'; +import { useTranslation } from 'next-i18next'; + +type TagInputProps = { + onChangeTags?: (tags: string[]) => void; + initialTags?: string[]; +}; + +const TagInput = ({ onChangeTags, initialTags }: TagInputProps) => { + const { t } = useTranslation('editor'); + const [tags, setTags] = useState([]); + const [draft, setDraft] = useState(''); + const [isComposing, setIsComposing] = useState(false); + + useEffect(() => { + if (initialTags) setTags(initialTags); + }, [initialTags]); + + useEffect(() => { + onChangeTags?.(tags); + }, [tags, onChangeTags]); + + const add = (raw: string) => { + if (tags.length >= 3) return; + const t = raw.trim(); + if (!t) return; + if (t.length > 10) return; + if (tags.includes(t)) return; + setTags((prev) => [...prev, t]); + setDraft(''); + }; + + const removeAt = (i: number) => { + setTags((prev) => prev.filter((_, idx) => idx !== i)); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (isComposing) return; + + if (tags.length >= 3) { + e.preventDefault(); + return; + } + + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + add(draft); + return; + } + + if (e.key === ',') { + e.preventDefault(); + } + }; + + const onChangeInput = (e: React.ChangeEvent) => { + const val = e.target.value; + + if (isComposing) { + setDraft(val); + return; + } + + const chunks = val.split(/[\n,]+/).map((s) => s.trim()); + if (chunks.length === 1) { + setDraft(chunks[0]); + return; + } + + const last = chunks.pop() ?? ''; + chunks.filter(Boolean).forEach(add); + setDraft(last); + }; + + const disabled = tags.length >= 3; + + return ( +
+ setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + onKeyDown={onKeyDown} + disabled={disabled} + maxLength={10} + placeholder={t('tag_placeholder')} + /> + {tags.map((t, i) => ( + + #{t} + + + ))} +
+ ); +}; + +export default TagInput; diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx new file mode 100644 index 0000000..286c864 --- /dev/null +++ b/src/pages/editor.tsx @@ -0,0 +1,251 @@ +import Button from '@/components/Button'; +import Input from '@/components/Input'; +import RadioGroup from '@/components/RadioGroup'; + +import { GetStaticProps } from 'next'; +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import nextI18NextConfig from '../../next-i18next.config'; +import { useTranslation } from 'next-i18next'; +import { EpigramForm, EpigramRequestApi } from '@/type/feed'; +import { Controller, useForm } from 'react-hook-form'; +import axiosInstance from '@/api/axiosInstance'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import TagInput from '@/components/TagInput'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import { toTagNames } from '@/utils/tags'; + +export const getStaticProps: GetStaticProps = async ({ locale }) => ({ + props: { + ...(await serverSideTranslations(locale ?? 'ko', ['editor'], nextI18NextConfig)), + }, +}); + +const FeedForm = () => { + const { + register, + watch, + control, + handleSubmit, + formState: { errors, isValid }, + setValue, + reset, + } = useForm({ + mode: 'onChange', + reValidateMode: 'onChange', + shouldUnregister: true, + defaultValues: { + content: '', + author: '', + referenceTitle: '', + referenceUrl: '', + }, + }); + const router = useRouter(); + const epigramId = useMemo(() => { + const q = router.query.id; + return Array.isArray(q) ? q[0] : q; + }, [router.query.id]); + + const onChangeTags = useCallback( + (arr: string[]) => { + setValue('tags', arr.join(','), { shouldDirty: true, shouldValidate: true }); + }, + [setValue], + ); + + const [initialTags, setInitialTags] = useState(); + + const getEpigram = async (id: string) => { + const { data } = await axiosInstance.get(`/epigrams/${id}`); + return data; + }; + + const { data: detail, isLoading: isLoadingDetail } = useQuery({ + queryKey: ['epigram', epigramId], + queryFn: () => getEpigram(epigramId as string), + enabled: !!epigramId, + }); + + useEffect(() => { + if (!detail) return; + const author = (detail.author ?? '').trim(); + const authorMode = author === '알 수 없음' || author === '본인' ? author : '직접 입력'; + const authorValue = authorMode === '직접 입력' ? author : ''; + + const names = toTagNames(detail.tags); + setInitialTags(names); + reset({ + content: detail.content ?? '', + authorMode, + author: authorValue, + referenceTitle: detail.referenceTitle ?? '', + referenceUrl: detail.referenceUrl ?? '', + tags: names.join(','), + }); + }, [detail, reset]); + + const authorMode = watch('authorMode'); + + const createEpigram = useMutation({ + mutationFn: async (data: EpigramRequestApi) => { + const { data: result } = await axiosInstance.post('/epigrams', data); + return result; + }, + }); + + const updateEpigram = useMutation({ + mutationFn: async (params: { id: string; body: EpigramRequestApi }) => { + const { data: result } = await axiosInstance.patch(`/epigrams/${params.id}`, params.body); + return result; + }, + }); + + const toTagArray = (s?: string) => + (s ?? '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + + const onSubmit = async (data: EpigramForm) => { + const safeAuthor: string = + data.authorMode === '직접 입력' ? data.author?.trim() || '없음' : data.authorMode; + + const tagsArr = toTagArray(data.tags); + + const payload = { + content: data.content, + author: safeAuthor, + referenceTitle: data.referenceTitle || undefined, + referenceUrl: data.referenceUrl || undefined, + tags: tagsArr, + }; + + try { + if (epigramId) { + const updated = await updateEpigram.mutateAsync({ id: epigramId, body: payload }); + const id = epigramId ?? updated?.id ?? updated?.epigramId; + alert('정상적으로 수정되었습니다.'); + router.push(`/epigrams/${id}`); + } else { + const created = await createEpigram.mutateAsync(payload); + const id = created?.id ?? created?.epigramId; + alert('정상적으로 등록되었습니다.'); + if (id) router.push(`/epigrams/${id}`); + else router.push('/epigrams'); + } + } catch { + alert('오류가 발생했습니다.'); + } + }; + + const { t } = useTranslation('editor'); + + return ( +
+
+

{t('title')}

+ +
+
+

+ {t('content')} * +

+