From 4abb07567bdf99aa4b0c2f1070e38f61b08ed525 Mon Sep 17 00:00:00 2001 From: Q Kim Date: Sat, 5 Aug 2023 18:46:45 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EB=B3=84=EC=A0=90=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/Icons/FilledStar.tsx | 20 +++ src/components/Star/index.tsx | 13 ++ src/components/StarRating/StarRating.css.ts | 32 +++++ .../StarRating/StarRating.stories.tsx | 28 ++++ src/components/StarRating/index.tsx | 122 ++++++++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 src/assets/Icons/FilledStar.tsx create mode 100644 src/components/Star/index.tsx create mode 100644 src/components/StarRating/StarRating.css.ts create mode 100644 src/components/StarRating/StarRating.stories.tsx create mode 100644 src/components/StarRating/index.tsx diff --git a/src/assets/Icons/FilledStar.tsx b/src/assets/Icons/FilledStar.tsx new file mode 100644 index 0000000..71f62af --- /dev/null +++ b/src/assets/Icons/FilledStar.tsx @@ -0,0 +1,20 @@ +const MaterialSymbolsStar = (props: React.SVGProps) => { + const { color } = props; + + return ( + + + + ); +}; + +export default MaterialSymbolsStar; diff --git a/src/components/Star/index.tsx b/src/components/Star/index.tsx new file mode 100644 index 0000000..7fab21a --- /dev/null +++ b/src/components/Star/index.tsx @@ -0,0 +1,13 @@ +import FilledStar from '../../assets/Icons/FilledStar'; + +interface StarProps extends React.SVGProps { + filled?: boolean; +} + +const Star = (props: StarProps) => { + const { filled = false, ...rest } = props; + + return ; +}; + +export default Star; diff --git a/src/components/StarRating/StarRating.css.ts b/src/components/StarRating/StarRating.css.ts new file mode 100644 index 0000000..2dd58e8 --- /dev/null +++ b/src/components/StarRating/StarRating.css.ts @@ -0,0 +1,32 @@ +import { style } from '@vanilla-extract/css'; + +export const wrapper = style({ + display: 'inline-flex', + border: 'none', + padding: 0, + margin: 0, +}); + +export const hiddenRadio = style({ + position: 'absolute', + visibility: 'hidden', + height: 0, + width: 0, +}); + +export const hidden = style({ + border: 'none', + clip: 'rect(0 0 0 0)', + height: '1px', + margin: '-1px', + overflow: 'hidden', + padding: 0, + position: 'absolute', + width: '1px', +}); + +export const clickable = style({ + cursor: 'pointer', + width: '32px', + height: '32px', +}); diff --git a/src/components/StarRating/StarRating.stories.tsx b/src/components/StarRating/StarRating.stories.tsx new file mode 100644 index 0000000..8f912e0 --- /dev/null +++ b/src/components/StarRating/StarRating.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import StarRating from '.'; +import { useState } from 'react'; + +const meta: Meta = { + component: StarRating, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + argTypes: { + rating: { + control: { type: 'range', min: 1, max: 5, step: 1 }, + }, + }, +}; + +const Wrapper = () => { + const [rating, setRating] = useState(0); + return ; +}; + +export const Interactive: Story = { + render: () => , +}; diff --git a/src/components/StarRating/index.tsx b/src/components/StarRating/index.tsx new file mode 100644 index 0000000..fb76b4d --- /dev/null +++ b/src/components/StarRating/index.tsx @@ -0,0 +1,122 @@ +import { useId } from 'react'; +import Star from '../Star'; +import { wrapper, hiddenRadio, hidden, clickable } from './StarRating.css.js'; + +interface StarRatingProps { + rating: number; + setRating: React.Dispatch>; +} + +const StarRating = (props: StarRatingProps) => { + const { rating, setRating } = props; + + const star1Id = useId(); + const star2Id = useId(); + const star3Id = useId(); + const star4Id = useId(); + const star5Id = useId(); + + return ( +
+ 별점 5점 중 {rating}점 +
+ + { + setRating(1); + }} + className={hiddenRadio} + /> + + + { + setRating(2); + }} + className={hiddenRadio} + /> + + + { + setRating(3); + }} + className={hiddenRadio} + /> + + + { + setRating(4); + }} + className={hiddenRadio} + /> + + + { + setRating(5); + }} + className={hiddenRadio} + /> +
+
+ ); +}; + +export default StarRating; From 1660680b5b72dede0cd6dc9406a71d9b746cd75e Mon Sep 17 00:00:00 2001 From: Q Kim Date: Sat, 5 Aug 2023 18:47:03 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EB=93=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/memory.ts | 20 ++++++++++++++++++++ src/types/tag.ts | 5 +++++ 2 files changed, 25 insertions(+) create mode 100644 src/types/memory.ts create mode 100644 src/types/tag.ts diff --git a/src/types/memory.ts b/src/types/memory.ts new file mode 100644 index 0000000..986d930 --- /dev/null +++ b/src/types/memory.ts @@ -0,0 +1,20 @@ +import type { Tag } from './tag'; + +export interface Memory { + title: string; + category: string; + tags: Tag[]; + visitedAt: string; + star: number; + images: string[]; + location: { + latitude: number; + longitude: number; + }; +} + +export interface NewMemory + extends Omit { + tags: Array; + images: File[]; +} diff --git a/src/types/tag.ts b/src/types/tag.ts new file mode 100644 index 0000000..b031bca --- /dev/null +++ b/src/types/tag.ts @@ -0,0 +1,5 @@ +export interface Tag { + id: number; + name: string; + color: string; +} From 3d67b67df9d4a50cb9c5eefd2dd142ead047764f Mon Sep 17 00:00:00 2001 From: Q Kim Date: Sat, 5 Aug 2023 19:17:43 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20api=20=ED=86=B5=EC=8B=A0=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/category.ts | 7 +++++++ src/apis/memory.ts | 10 ++++++++++ src/hooks/queries/useCategories.ts | 17 +++++++++++++++++ src/hooks/queries/useNewMemory.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 src/apis/category.ts create mode 100644 src/apis/memory.ts create mode 100644 src/hooks/queries/useCategories.ts create mode 100644 src/hooks/queries/useNewMemory.ts diff --git a/src/apis/category.ts b/src/apis/category.ts new file mode 100644 index 0000000..5feb70a --- /dev/null +++ b/src/apis/category.ts @@ -0,0 +1,7 @@ +export const getCategories = async () => + await fetch('/categories', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); diff --git a/src/apis/memory.ts b/src/apis/memory.ts new file mode 100644 index 0000000..9ccea5f --- /dev/null +++ b/src/apis/memory.ts @@ -0,0 +1,10 @@ +import type { NewMemory } from '../types/memory'; + +export const postMemory = async (form: NewMemory) => + await fetch('/memories', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(form), + }); diff --git a/src/hooks/queries/useCategories.ts b/src/hooks/queries/useCategories.ts new file mode 100644 index 0000000..f4168c2 --- /dev/null +++ b/src/hooks/queries/useCategories.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import type { Category, CategoryResponse } from '../../types/category'; +import { getCategories } from '../../apis/category'; + +const useCategories = () => + useQuery>({ + queryKey: ['category'], + queryFn: async () => { + const response = await getCategories(); + const data = await response.json(); + return data; + }, + select: ({ categories }) => categories.map(({ name }) => name), + initialData: { categories: [] }, + }); + +export default useCategories; diff --git a/src/hooks/queries/useNewMemory.ts b/src/hooks/queries/useNewMemory.ts new file mode 100644 index 0000000..886936c --- /dev/null +++ b/src/hooks/queries/useNewMemory.ts @@ -0,0 +1,28 @@ +import { useMutation } from '@tanstack/react-query'; +import { postMemory } from '../../apis/memory'; +import type { NewMemory } from '../../types/memory'; +import useModal from '../useModal'; + +const useNewMemory = () => { + const { hide } = useModal(); + + return useMutation({ + mutationFn: async (form) => { + const response = await postMemory(form); + if (response.status !== 201) throw new Error('추억 등록 실패'); + }, + + onSuccess: () => { + alert('추억 등록에 성공했어요'); + hide(); + }, + + onError: (e) => { + alert('추억 등록에 실패했어요'); + }, + + retry: 1, + }); +}; + +export default useNewMemory; From 6f22b760b0efa8b83af0c510f6de495fa5777181 Mon Sep 17 00:00:00 2001 From: Q Kim Date: Sat, 5 Aug 2023 19:17:55 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/category.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/types/category.ts diff --git a/src/types/category.ts b/src/types/category.ts new file mode 100644 index 0000000..3e48db4 --- /dev/null +++ b/src/types/category.ts @@ -0,0 +1,7 @@ +export interface Category { + name: string; +} + +export interface CategoryResponse { + categories: Category[]; +} From c542e560299bc9a9e2d7dac953c8ada9ecd38a7f Mon Sep 17 00:00:00 2001 From: Q Kim Date: Sat, 5 Aug 2023 19:19:38 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=EC=B6=94=EC=96=B5=20=EC=83=88?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ImageUploader/ImageUploader.css.ts | 42 ++++++ src/components/ImageUploader/index.tsx | 40 +++++ src/components/NewMemoryForm/index.tsx | 138 ++++++++++++++++++ .../NewMemoryForm/newMemoryForm.css.ts | 79 ++++++++++ src/hooks/useImageInput.ts | 27 ++++ src/hooks/usePastDateTimeInput.ts | 21 +++ src/hooks/useSelectInput.ts | 17 +++ src/hooks/useTextInput.ts | 18 +++ src/types/memory.ts | 3 +- src/utils/getToday.ts | 10 ++ 10 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 src/components/ImageUploader/ImageUploader.css.ts create mode 100644 src/components/ImageUploader/index.tsx create mode 100644 src/components/NewMemoryForm/index.tsx create mode 100644 src/components/NewMemoryForm/newMemoryForm.css.ts create mode 100644 src/hooks/useImageInput.ts create mode 100644 src/hooks/usePastDateTimeInput.ts create mode 100644 src/hooks/useSelectInput.ts create mode 100644 src/hooks/useTextInput.ts create mode 100644 src/utils/getToday.ts diff --git a/src/components/ImageUploader/ImageUploader.css.ts b/src/components/ImageUploader/ImageUploader.css.ts new file mode 100644 index 0000000..8eaadb0 --- /dev/null +++ b/src/components/ImageUploader/ImageUploader.css.ts @@ -0,0 +1,42 @@ +import { style } from '@vanilla-extract/css'; + +export const hidden = style({ + border: 'none', + clip: 'rect(0 0 0 0)', + height: '1px', + margin: '-1px', + overflow: 'hidden', + padding: 0, + position: 'absolute', + width: '1px', +}); + +export const uploadButton = style({ + cursor: 'pointer', + + border: '1px solid #DDDDDD', + backgroundColor: '#ffffff', + fontSize: '14px', + + transition: 'all 0.15s linear', + + ':focus': { + outline: 'none', + border: '1px solid #735BF2', + }, + + ':hover': { + backgroundColor: '#CAC2F2', + }, +}); + +export const thumbnail = style({ + objectFit: 'cover', + + width: '100%', + height: '200px', + padding: 0, + margin: 0, + + borderRadius: '5px', +}); diff --git a/src/components/ImageUploader/index.tsx b/src/components/ImageUploader/index.tsx new file mode 100644 index 0000000..fb8f462 --- /dev/null +++ b/src/components/ImageUploader/index.tsx @@ -0,0 +1,40 @@ +import { useRef } from 'react'; +import { hidden, thumbnail, uploadButton } from './ImageUploader.css'; + +interface ImageUploaderProps { + images: string[]; + setImages: (fileList: FileList) => void; +} + +const ImageUploader = (props: ImageUploaderProps) => { + const { images, setImages } = props; + + const inputRef = useRef(null); + + const accessImageInput = () => { + inputRef.current?.click(); + }; + + return ( + <> + + { + setImages(files); + }} + className={hidden} + /> + + ); +}; + +export default ImageUploader; diff --git a/src/components/NewMemoryForm/index.tsx b/src/components/NewMemoryForm/index.tsx new file mode 100644 index 0000000..7632a18 --- /dev/null +++ b/src/components/NewMemoryForm/index.tsx @@ -0,0 +1,138 @@ +import type { Category } from '../../types/category'; +import { useState } from 'react'; +import StarRating from '../StarRating'; +import { + baseInput, + contentInput, + inlineRowFlex, + normalFont, + primaryButton, + rounded, + title, + titleInput, + wrapper, +} from './newMemoryForm.css'; +import useTextInput from '../../hooks/useTextInput'; +import usePastDateTimeInput from '../../hooks/usePastDateTimeInput'; +import getNowInDateTimeFormat from '../../utils/getToday'; +import useSelectInput from '../../hooks/useSelectInput'; +import ImageUploader from '../ImageUploader'; +import useImageInput from '../../hooks/useImageInput'; +import type { NewMemory } from '../../types/memory'; +import useNewMemory from '../../hooks/queries/useNewMemory'; +import useCategories from '../../hooks/queries/useCategories'; + +const NewMemoryForm = () => { + const { data: categories } = useCategories(); + const { mutate } = useNewMemory(); + + const { value: images, files, setValue: setImages } = useImageInput(); + const { value: memoryTitle, setValue: setMemoryTitle } = useTextInput('', 50); + const { value: content, setValue: setContent } = useTextInput('', 1000); + const { value: dateTime, setValue: setDateTime } = usePastDateTimeInput(); + const { value: category, setValue: setCategory } = useSelectInput( + [...categories, ''], + '' + ); + const [rating, setRating] = useState(0); + + const submit = () => { + if (images.length === 0) { + alert('사진을 추가해 주세요!'); + return; + } + if (memoryTitle.length === 0) { + alert('제목을 입력해 주세요!'); + return; + } + if (category === '') { + alert('카테고리를 입력해 주세요!'); + return; + } + if (rating === 0) { + alert('별점을 입력해 주세요!'); + return; + } + + const formData: NewMemory = { + title, + category, + images: Array.from(files), + tags: [], + star: rating, + visitedAt: dateTime, + }; + + mutate(formData); + }; + + return ( +
+

추억을 새겨보자

+ + + + { + setMemoryTitle(value); + }} + className={`${rounded} ${titleInput} ${baseInput}`} + /> + +