diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 926756e..ff5efdd 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -3,6 +3,7 @@ module.exports = { browser: true, es2021: true, }, + root: true, extends: [ "standard-with-typescript", "plugin:react/recommended", @@ -13,7 +14,8 @@ module.exports = { parserOptions: { ecmaVersion: "latest", sourceType: "module", - project: "./tsconfig.json", + project: ["./tsconfig.json"], + createDefaultProgram: true, }, plugins: ["react", "@typescript-eslint"], rules: { @@ -21,6 +23,8 @@ module.exports = { "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/triple-slash-reference": "off", "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/strict-boolean-expressions": "off", + "@typescript-eslint/restrict-template-expressions": "off", }, ignorePatterns: [".eslintrc.cjs", "vite.config.ts"], }; diff --git a/index.html b/index.html index 96e1ec4..5392190 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@
+ diff --git a/src/App.css b/src/App.css index b9d355d..10bc114 100644 --- a/src/App.css +++ b/src/App.css @@ -1,42 +1,9 @@ #root { + display: flex; + flex-direction: column; + align-items: center; + max-width: 1280px; margin: 0 auto; - padding: 2rem; text-align: center; } - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/App.tsx b/src/App.tsx index 7263a5e..b11a617 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,17 @@ +import './reset.css'; import './App.css'; -import { - useQuery, - useMutation, - useQueryClient, - QueryClient, - QueryClientProvider, -} from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import NavBar from './components/NavBar'; const queryClient = new QueryClient(); -function App() { +const App = () => { return ( - hello, world! +

hello, world!

+
); -} +}; export default App; 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/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/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/Modal/index.ts b/src/components/Modal/index.ts new file mode 100644 index 0000000..49efa1f --- /dev/null +++ b/src/components/Modal/index.ts @@ -0,0 +1,7 @@ +import ModalProvider from '../../contexts/modalContext'; +import Content from './ingredients/Content'; +import Trigger from './ingredients/Trigger'; + +const Modal = Object.assign(ModalProvider, { Content, Trigger }); + +export default Modal; diff --git a/src/components/Modal/ingredients/Content.css.ts b/src/components/Modal/ingredients/Content.css.ts new file mode 100644 index 0000000..7370b8a --- /dev/null +++ b/src/components/Modal/ingredients/Content.css.ts @@ -0,0 +1,43 @@ +import { keyframes, style } from '@vanilla-extract/css'; + +const showModal = keyframes({ + '0%': { + bottom: '-100%', + }, + + '100%': { + bottom: '0', + }, +}); + +export const backdrop = style({ + position: 'fixed', + top: '0', + left: '0', + width: '100%', + height: '100%', + backgroundColor: '#33333311', +}); + +export const content = style({ + position: 'fixed', + bottom: '0', + left: '0', + + overflowX: 'hidden', + overflowY: 'auto', + + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + + width: '100%', + minHeight: '10%', + maxHeight: '90%', + + borderRadius: '10px 10px 0 0', + backgroundColor: '#ffffff', + + animation: `${showModal} 0.4s ease-out`, +}); diff --git a/src/components/Modal/ingredients/Content.tsx b/src/components/Modal/ingredients/Content.tsx new file mode 100644 index 0000000..5d73a33 --- /dev/null +++ b/src/components/Modal/ingredients/Content.tsx @@ -0,0 +1,23 @@ +import { createPortal } from 'react-dom'; +import useModal from '../../../hooks/useModal'; +import { backdrop, content } from './Content.css'; + +const Content = (props: React.PropsWithChildren) => { + const { children } = props; + const { isOpen, hide } = useModal(); + + const modalRoot = document.getElementById('modal-root'); + + return ( + isOpen && + createPortal( + <> +
+
{children}
+ , + modalRoot + ) + ); +}; + +export default Content; diff --git a/src/components/Modal/ingredients/Trigger.tsx b/src/components/Modal/ingredients/Trigger.tsx new file mode 100644 index 0000000..80dec1b --- /dev/null +++ b/src/components/Modal/ingredients/Trigger.tsx @@ -0,0 +1,31 @@ +import useModal from '../../../hooks/useModal'; + +export interface TriggerProps { + type: 'open' | 'close' | 'toggle'; + className?: string; + ariaLabel?: string; +} + +const Trigger = (props: React.PropsWithChildren) => { + const { type, children, className, ariaLabel } = props; + const { show, hide, toggle } = useModal(); + + const triggerModal = () => { + if (type === 'open') show(); + if (type === 'close') hide(); + if (type === 'toggle') toggle(); + }; + + return ( + + ); +}; + +export default Trigger; diff --git a/src/components/NavBar/NavBar.css.ts b/src/components/NavBar/NavBar.css.ts new file mode 100644 index 0000000..6f7f4c4 --- /dev/null +++ b/src/components/NavBar/NavBar.css.ts @@ -0,0 +1,19 @@ +import { style } from '@vanilla-extract/css'; + +export const bottomNav = style({ + display: 'block', + + position: 'fixed', + bottom: '0', + left: '0', + + minWidth: '350px', + width: '100%', + height: '50px', + + margin: 'auto auto 0 auto', + + borderRadius: '5px', + border: '1px solid pink', + backgroundColor: '#ffffff', +}); diff --git a/src/components/NavBar/index.tsx b/src/components/NavBar/index.tsx new file mode 100644 index 0000000..a0119c3 --- /dev/null +++ b/src/components/NavBar/index.tsx @@ -0,0 +1,12 @@ +import NewMemoryModalButton from '../NewMemoryModalButton'; +import { bottomNav } from './NavBar.css'; + +const NavBar = () => { + return ( + + ); +}; + +export default NavBar; 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}`} + /> + +