diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/dicegame/App.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/dicegame/App.js" new file mode 100644 index 00000000..a1a20646 --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/dicegame/App.js" @@ -0,0 +1,12 @@ +import Dice from './Dice'; + +function App() { + return ( +
+ +
+ ); + +} + +export default App; diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/dicegame/Dice.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/dicegame/Dice.js" new file mode 100644 index 00000000..e69de29b diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/dicegame/index.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/dicegame/index.js" new file mode 100644 index 00000000..e3ca7986 --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/dicegame/index.js" @@ -0,0 +1,10 @@ + +import ReactDOM from 'react-dom/client'; +import App from './App'; + + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + , document.getElementById('root') +); + diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/App.css" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/App.css" new file mode 100644 index 00000000..74b5e053 --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/App.css" @@ -0,0 +1,38 @@ +.App { + text-align: center; +} + +.App-logo { + height: 40vmin; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .App-logo { + animation: App-logo-spin infinite 20s linear; + } +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.App-link { + color: #61dafb; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/App.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/App.js" new file mode 100644 index 00000000..19acd9a8 --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/App.js" @@ -0,0 +1,26 @@ +import logo from './logo.svg'; +import './App.css'; + +function App() { + return ( +
+
+

안녕 리액트!

+ logo +

+ Edit src/App.js and save to reload. +

+ + Learn React + +
+
+ ); +} + +export default App; diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/index.css" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/index.css" new file mode 100644 index 00000000..ec2585e8 --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/index.css" @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/index.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/index.js" new file mode 100644 index 00000000..d563c0fb --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/hello_react/index.js" @@ -0,0 +1,17 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; +import reportWebVitals from './reportWebVitals'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/App.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/App.js" new file mode 100644 index 00000000..146d01b5 --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/App.js" @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import Button from './Button'; +import HandButton from './HandButton'; +import HandIcon from './HandIcon'; +import { compareHand, generateRandomHand } from './utils'; + +const INITIAL_VALUE = 'rock'; + +function getResult(me, other) { + const comparison = compareHand(me, other); + if (comparison > 0) return '승리'; + if (comparison < 0) return '패배'; + return '무승부'; +} + +function App() { + const [hand, setHand] = useState(INITIAL_VALUE); + const [otherHand, setOtherHand] = useState(INITIAL_VALUE); + const [gameHistory, setGameHistory] = useState([]); + const [score, setScore] = useState(0); + const [otherScore, setOtherScore] = useState(0); + const [bet, setBet] = useState(1); + + const handleButtonClick = (nextHand) => { + const nextOtherHand = generateRandomHand(); + const nextHistoryItem = getResult(nextHand, nextOtherHand); + const comparison = compareHand(nextHand, nextOtherHand); + setHand(nextHand); + setOtherHand(nextOtherHand); + setGameHistory([...gameHistory, nextHistoryItem]); + if (comparison > 0) setScore(score + bet); + if (comparison < 0) setOtherScore(otherScore + bet); + }; + + const handleClearClick = () => { + setHand(INITIAL_VALUE); + setOtherHand(INITIAL_VALUE); + setGameHistory([]); + setScore(0); + setOtherScore(0); + setBet(1); + }; + + const handleBetChange = (e) => { + let num = Number(e.target.value); + if (num > 9) num %= 10; + if (num < 1) num = 1; + num = Math.floor(num); + setBet(num); + }; + + return ( +
+ +
+ {score} : {otherScore} +
+
+ + VS + +
+
+ +
+

승부 기록: {gameHistory.join(', ')}

+
+ + + +
+
+ ); +} + +export default App; diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/Button.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/Button.js" new file mode 100644 index 00000000..a2796459 --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/Button.js" @@ -0,0 +1,8 @@ + + +function Button({ children, onClick }) { + + return ; +} + +export default Button; diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/HandButton.css" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/HandButton.css" new file mode 100644 index 00000000..809a0b1c --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/HandButton.css" @@ -0,0 +1,27 @@ +.HandButton { + width: 166px; + height: 166px; + border: none; + outline: none; + text-align: center; + cursor: pointer; + background-color: transparent; + background-image: url('./assets/purple.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: contain; +} + +.HandButton .HandButton-icon { + opacity: 0.45; + width: 55px; + height: 55px; +} + +.HandButton:hover { + background-image: url('./assets/yellow.svg'); +} + +.HandButton:hover .HandButton-icon { + opacity: 0.87; +} diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/HandButton.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/HandButton.js" new file mode 100644 index 00000000..b522c935 --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/HandButton.js" @@ -0,0 +1,14 @@ +import HandIcon from './HandIcon'; +import './HandButton.css'; + +// CSS 파일로 스타일을 적용해 주세요 +function HandButton({ value, onClick }) { + const handleClick = () => onClick(value); + return ( + + ); +} + +export default HandButton; diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/HandIcon.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/HandIcon.js" new file mode 100644 index 00000000..cba3552d --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/HandIcon.js" @@ -0,0 +1,17 @@ +import rockImg from './assets/rock.svg'; +import scissorImg from './assets/scissor.svg'; +import paperImg from './assets/paper.svg'; + +const IMAGES = { + rock: rockImg, + scissor: scissorImg, + paper: paperImg, +}; + +// className prop을 추가하고, img 태그에 적용해주세요 +function HandIcon({ className, value }) { + const src = IMAGES[value]; + return {value}; +} + +export default HandIcon; diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/index.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/index.js" new file mode 100644 index 00000000..606d7ac4 --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/index.js" @@ -0,0 +1,4 @@ +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render(, document.getElementById('root')); diff --git "a/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/utils.js" "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/utils.js" new file mode 100644 index 00000000..ee002c31 --- /dev/null +++ "b/05ovo2e/Round2/React \354\233\271 \352\260\234\353\260\234 \354\213\234\354\236\221\355\225\230\352\270\260/rock-scissor-paper/utils.js" @@ -0,0 +1,22 @@ +const HANDS = ['rock', 'scissor', 'paper']; + +const WINS = { + rock: 'scissor', + scissor: 'paper', + paper: 'rock', +}; + +export function compareHand(a, b) { + if (WINS[a] === b) return 1; + if (WINS[b] === a) return -1; + return 0; +} + +function random(n) { + return Math.floor(Math.random() * n); +} + +export function generateRandomHand() { + const idx = random(HANDS.length); + return HANDS[idx]; +} diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/App.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/App.js" new file mode 100644 index 00000000..5b166839 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/App.js" @@ -0,0 +1,117 @@ +import { useEffect, useState } from "react"; +import { createFood, updateFood, getFoods, deleteFood } from "../api"; +import FoodList from "./FoodList"; +import FoodForm from "./FoodForm"; +import { LocaleProvider } from "../contexts/LocaleContext"; +import LocaleSelect from "./LocaleSelect"; + +function App() { + const [order, setOrder] = useState("createdAt"); + const [cursor, setCursor] = useState(null); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [loadingError, setLoadingError] = useState(null); + const [search, setSearch] = useState(""); + + const handleNewestClick = () => setOrder("createdAt"); + + const handleCalorieClick = () => setOrder("calorie"); + + const handleDelete = async (id) => { + const result = await deleteFood(id); + if (!result) return; + + const nextItems = items.filter((item) => item.id !== id); + setItems(nextItems); + }; + + const handleLoad = async (options) => { + let result; + try { + setLoadingError(null); + setIsLoading(true); + result = await getFoods(options); + } catch (error) { + setLoadingError(error); + return; + } finally { + setIsLoading(false); + } + const { + foods, + paging: { nextCursor }, + } = result; + if (!options.cursor) { + setItems(foods); + } else { + setItems((prevItems) => [...prevItems, ...foods]); + } + setCursor(nextCursor); + }; + + const handleLoadMore = () => { + handleLoad({ + order, + cursor, + search, + }); + }; + + const handleSearchSubmit = (e) => { + e.preventDefault(); + setSearch(e.target["search"].value); + }; + + const handleCreateSuccess = (newItem) => { + setItems((prevItems) => [newItem, ...prevItems]); + }; + + const handleUpdateSuccess = (newItem) => { + setItems((prevItems) => { + const splitIdx = prevItems.findIndex((item) => item.id === newItem.id); + return [ + ...prevItems.slice(0, splitIdx), + newItem, + ...prevItems.slice(splitIdx + 1), + ]; + }); + }; + + const sortedItems = items.sort((a, b) => b[order] - a[order]); + + useEffect(() => { + handleLoad({ + order, + search, + }); + }, [order, search]); + + return ( + +
+ + + + +
+ + +
+ + {cursor && ( + + )} + {loadingError &&

{loadingError.message}

} +
+
+ ); +} + +export default App; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/FileInput.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/FileInput.js" new file mode 100644 index 00000000..57f4c18b --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/FileInput.js" @@ -0,0 +1,46 @@ +import { useEffect, useRef, useState } from "react"; + +function FileInput({ name, value, onChange }) { + const [preview, setPreview] = useState(); + const inputRef = useRef(); + + const handleChange = (e) => { + const nextValue = e.target.files[0]; + onChange(name, nextValue); + }; + + const handleClearClick = () => { + const inputNode = inputRef.current; + if (!inputNode) return; + + inputNode.value = ""; + onChange(name, null); + }; + + useEffect(() => { + if (!value) return; + // ObjectURL 만들기 + const nextPreview = URL.createObjectURL(value); + setPreview(nextPreview); + + return () => { + setPreview(); + // ObjectURL 해제하기 + URL.revokeObjectURL(nextPreview); + }; + }, [value]); + + return ( +
+ 이미지 미리보기 + + {value && } +
+ ); +} +export default FileInput; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/FoodForm.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/FoodForm.js" new file mode 100644 index 00000000..1ade9cb5 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/FoodForm.js" @@ -0,0 +1,100 @@ +import { useState } from "react"; +import FileInput from "./FileInput"; + +function sanitize(type, value) { + switch (type) { + case "number": + return Number(value) || 0; + + default: + return value; + } +} + +const INITIAL_VALUES = { + imgFile: null, + title: "", + calorie: 0, + content: "", +}; + +function FoodForm({ + initialValues = INITIAL_VALUES, + initialPreview, + onSubmit, + onSubmitSuccess, + onCancel, +}) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [submittingError, setSubmittingError] = useState(null); + const [values, setValues] = useState(initialValues); + + const handleSubmit = async (e) => { + e.preventDefault(); + const formData = new FormData(); + formData.append("imgFile", values.imgFile); + formData.append("title", values.title); + formData.append("calorie", values.calorie); + formData.append("content", values.content); + let result; + try { + setSubmittingError(null); + setIsSubmitting(true); + result = await onSubmit(formData); + } catch (error) { + setSubmittingError(error); + return; + } finally { + setIsSubmitting(false); + } + const { food } = result; + setValues(initialValues); + onSubmitSuccess(food); + }; + + const handleChange = (name, value) => { + setValues((prevValues) => ({ + ...prevValues, + [name]: value, + })); + }; + + const handleInputChange = (e) => { + const { name, value, type } = e.target; + handleChange(name, sanitize(type, value)); + }; + + return ( +
+ + + + + {onCancel && ( + + )} + + {submittingError &&

{submittingError.message}

} + + ); +} + +export default FoodForm; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/FoodList.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/FoodList.js" new file mode 100644 index 00000000..ca33cf94 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/FoodList.js" @@ -0,0 +1,82 @@ +import { useState } from "react"; +import FoodForm from "./FoodForm"; +import useTranslate from "../hooks/useTranslate"; + +function formatDate(value) { + const date = new Date(value); + return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}`; +} + +function FoodListItem({ item, onEdit, onDelete }) { + const t = useTranslate(); + const { imgUrl, title, calorie, content, createdAt } = item; + + const handleEditClick = () => { + onEdit(item.id); + }; + + const handleDeleteClick = () => { + onDelete(item.id); + }; + + return ( +
+ {title} +
{title}
+
{calorie}
+
{content}
+
{formatDate(createdAt)}
+ + +
+ ); +} + +function FoodList({ items, onUpdate, onUpdateSuccess, onDelete }) { + const [editingId, setEditingId] = useState(null); + + const handleCancel = () => { + setEditingId(null); + }; + + return ( +
    + {items.map((item) => { + if (item.id === editingId) { + const { id, imgUrl, title, calorie, content } = item; + const initialValues = { title, calorie, content, imgFile: null }; + + const handleSubmit = (formData) => onUpdate(id, formData); + + const handleSubmitSuccess = (newItem) => { + onUpdateSuccess(newItem); + setEditingId(null); + }; + + return ( +
  • + +
  • + ); + } + return ( +
  • + +
  • + ); + })} +
+ ); +} + +export default FoodList; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/LocaleContext.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/LocaleContext.js" new file mode 100644 index 00000000..d8f5a347 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/LocaleContext.js" @@ -0,0 +1,37 @@ +import { createContext, useContext, useState } from "react"; + +const LocaleContext = createContext(); + +export function LocaleProvider({ defaultValue = "ko", children }) { + const [locale, setLocale] = useState(defaultValue); + + return ( + + {children} + + ); +} + +export function useLocale() { + const context = useContext(LocaleContext); + + if (!context) { + throw new Error("반드시 LocaleProvider 안에서 사용해야 합니다"); + } + + const { locale } = context; + + return locale; +} + +export function useSetLocale() { + const context = useContext(LocaleContext); + + if (!context) { + throw new Error("반드시 LocaleProvider 안에서 사용해야 합니다"); + } + + const { setLocale } = context; + + return setLocale; +} diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/LocaleSelect.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/LocaleSelect.js" new file mode 100644 index 00000000..07fcfd3a --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/LocaleSelect.js" @@ -0,0 +1,17 @@ +import { useLocale, useSetLocale } from "../contexts/LocaleContext"; + +function LocaleSelect() { + const locale = useLocale(); + const setLocale = useSetLocale(); + const handleChange = (e) => setLocale(e.target.value); + + return ( + + ); +} + +export default LocaleSelect; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/api.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/api.js" new file mode 100644 index 00000000..bafbe2e4 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/api.js" @@ -0,0 +1,51 @@ +const BASE_URL = "https://learn.codeit.kr/api"; + +export async function getFoods({ + order = "", + cursor = "", + limit = 10, + search = "", +}) { + const query = `order=${order}&cursor=${cursor}&limit=${limit}&search=${search}`; + const response = await fetch(`${BASE_URL}/foods?${query}`); + if (!response.ok) { + throw new Error("데이터를 불러오는데 실패했습니다"); + } + const body = await response.json(); + return body; +} + +export async function createFood(formData) { + const response = await fetch(`${BASE_URL}/foods`, { + method: "POST", + body: formData, + }); + if (!response.ok) { + throw new Error("데이터를 생성하는데 실패했습니다"); + } + const body = await response.json(); + return body; +} + +export async function updateFood(id, formData) { + const response = await fetch(`${BASE_URL}/foods/${id}`, { + method: "PUT", + body: formData, + }); + if (!response.ok) { + throw new Error("데이터를 수정하는데 실패했습니다"); + } + const body = await response.json(); + return body; +} + +export async function deleteFood(id) { + const response = await fetch(`${BASE_URL}/foods/${id}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error("데이터를 삭제하는데 실패했습니다"); + } + const body = await response.json(); + return body; +} diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/index.html" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/index.html" new file mode 100644 index 00000000..aa069f27 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/index.html" @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/index.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/index.js" new file mode 100644 index 00000000..463687cd --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/index.js" @@ -0,0 +1,4 @@ +import ReactDOM from 'react-dom'; +import App from './components/App'; + +ReactDOM.render(, document.getElementById('root')); diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/useTranslate.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/useTranslate.js" new file mode 100644 index 00000000..40840860 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/foodit/useTranslate.js" @@ -0,0 +1,24 @@ +import { useLocale } from "../contexts/LocaleContext"; + +const dict = { + ko: { + "edit button": "수정", + "delete button": "삭제", + }, + en: { + "edit button": "Edit", + "delete button": "Delete", + }, + fr: { + "edit button": "Modifier", + "delete button": "Supprimer", + }, +}; + +function useTranslate() { + const locale = useLocale(); + const translate = (key) => dict[locale][key] || ""; + return translate; +} + +export default useTranslate; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/App.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/App.js" new file mode 100644 index 00000000..81e56fec --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/App.js" @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useState } from "react"; +import ReviewList from "./ReviewList"; +import ReviewForm from "./ReviewForm"; +import { createReview, deleteReview, getReviews, updateReview } from "../api"; +import useAsync from "../hooks/useAsync"; +import { LocaleProvider } from "../contexts/LocaleContext"; +import LocaleSelect from "./LocaleSelect"; + +const LIMIT = 6; + +function App() { + const [order, setOrder] = useState("createdAt"); + const [offset, setOffset] = useState(0); + const [hasNext, setHasNext] = useState(false); + const [isLoading, loadingError, getReviewsAsync] = useAsync(getReviews); + const [items, setItems] = useState([]); + const sortedItems = items.sort((a, b) => b[order] - a[order]); + + const handleNewestClick = () => setOrder("createdAt"); + + const handleBestClick = () => setOrder("rating"); + + const handleDelete = async (id) => { + const result = await deleteReview(id); + if (!result) return; + + setItems((prevItems) => prevItems.filter((item) => item.id !== id)); + }; + + const handleLoad = useCallback( + async (options) => { + const result = await getReviewsAsync(options); + if (!result) return; + + const { paging, reviews } = result; + if (options.offset === 0) { + setItems(reviews); + } else { + setItems((prevItems) => [...prevItems, ...reviews]); + } + setOffset(options.offset + options.limit); + setHasNext(paging.hasNext); + }, + [getReviewsAsync] + ); + + const handleLoadMore = async () => { + await handleLoad({ order, offset, limit: LIMIT }); + }; + + const handleCreateSuccess = (review) => { + setItems((prevItems) => [review, ...prevItems]); + }; + + const handleUpdateSuccess = (review) => { + setItems((prevItems) => { + const splitIdx = prevItems.findIndex((item) => item.id === review.id); + return [ + ...prevItems.slice(0, splitIdx), + review, + ...prevItems.slice(splitIdx + 1), + ]; + }); + }; + + useEffect(() => { + handleLoad({ order, offset: 0, limit: LIMIT }); + }, [order, handleLoad]); + + return ( + +
+ +
+ + +
+ + + {hasNext && ( + + )} + {loadingError?.message && {loadingError.message}} +
+
+ ); +} + +export default App; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/FileInput.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/FileInput.js" new file mode 100644 index 00000000..d2ebfd98 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/FileInput.js" @@ -0,0 +1,45 @@ +import { useEffect, useRef, useState } from "react"; + +function FileInput({ name, value, initalPreview, onChange }) { + const [preview, setPreview] = useState(initalPreview); + const inputRef = useRef(); + + const handleChange = (e) => { + const nextValue = e.target.files[0]; + onChange(name, nextValue); + }; + + const handleClearClick = () => { + const inputNode = inputRef.current; + if (!inputNode) return; + + inputNode.value = ""; + onChange(name, null); + }; + + useEffect(() => { + if (!value) return; + const nextPreview = URL.createObjectURL(value); + setPreview(nextPreview); + + return () => { + setPreview(initalPreview); + URL.revokeObjectURL(nextPreview); + }; + }, [value, initalPreview]); + + return ( +
+ 이미지 미리보기 + + {value && } +
+ ); +} + +export default FileInput; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/LocaleContext.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/LocaleContext.js" new file mode 100644 index 00000000..d8f5a347 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/LocaleContext.js" @@ -0,0 +1,37 @@ +import { createContext, useContext, useState } from "react"; + +const LocaleContext = createContext(); + +export function LocaleProvider({ defaultValue = "ko", children }) { + const [locale, setLocale] = useState(defaultValue); + + return ( + + {children} + + ); +} + +export function useLocale() { + const context = useContext(LocaleContext); + + if (!context) { + throw new Error("반드시 LocaleProvider 안에서 사용해야 합니다"); + } + + const { locale } = context; + + return locale; +} + +export function useSetLocale() { + const context = useContext(LocaleContext); + + if (!context) { + throw new Error("반드시 LocaleProvider 안에서 사용해야 합니다"); + } + + const { setLocale } = context; + + return setLocale; +} diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/LocaleSelect.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/LocaleSelect.js" new file mode 100644 index 00000000..07fcfd3a --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/LocaleSelect.js" @@ -0,0 +1,17 @@ +import { useLocale, useSetLocale } from "../contexts/LocaleContext"; + +function LocaleSelect() { + const locale = useLocale(); + const setLocale = useSetLocale(); + const handleChange = (e) => setLocale(e.target.value); + + return ( + + ); +} + +export default LocaleSelect; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/Rating.css" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/Rating.css" new file mode 100644 index 00000000..0b2523fc --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/Rating.css" @@ -0,0 +1,7 @@ +.Rating-star { + color: slategray; +} + +.Rating-star.selected { + color: yellowgreen; +} diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/Rating.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/Rating.js" new file mode 100644 index 00000000..af1e1f56 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/Rating.js" @@ -0,0 +1,39 @@ +import "./Rating.css"; + +const RATINGS = [1, 2, 3, 4, 5]; + +function Star({ selected = false, rating = 0, onSelect, onHover }) { + const className = `Rating-star ${selected ? "selected" : ""}`; + + const handleClick = onSelect ? () => onSelect(rating) : undefined; + + const handleMouesOver = onHover ? () => onHover(rating) : undefined; + + return ( + + ★ + + ); +} + +function Rating({ className, value = 0, onSelect, onHover, onMouseOut }) { + return ( +
+ {RATINGS.map((rating) => ( + = rating} + rating={rating} + onSelect={onSelect} + onHover={onHover} + /> + ))} +
+ ); +} + +export default Rating; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/RatingInput.css" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/RatingInput.css" new file mode 100644 index 00000000..abb151a4 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/RatingInput.css" @@ -0,0 +1,3 @@ +.RatingInput { + cursor: pointer; +} diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/RatingInput.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/RatingInput.js" new file mode 100644 index 00000000..710bcdf8 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/RatingInput.js" @@ -0,0 +1,23 @@ +import { useState } from "react"; +import Rating from "./Rating"; +import "./RatingInput.css"; + +function RatingInput({ name, value, onChange }) { + const [rating, setRating] = useState(value); + + const handleSelect = (nextValue) => onChange(name, nextValue); + + const handleMouseOut = () => setRating(value); + + return ( + + ); +} + +export default RatingInput; diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/ReviewForm.css" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/ReviewForm.css" new file mode 100644 index 00000000..b6bf3951 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/ReviewForm.css" @@ -0,0 +1,9 @@ +.ReviewForm { + display: flex; + flex-direction: column; + padding: 10px 0; +} + +.ReviewForm > :not(:last-child) { + margin-bottom: 20px; +} diff --git "a/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/ReviewForm.js" "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/ReviewForm.js" new file mode 100644 index 00000000..f19da591 --- /dev/null +++ "b/05ovo2e/Round2/React\353\241\234 \353\215\260\354\235\264\355\204\260 \353\213\244\353\243\250\352\270\260/moviepedia/ReviewForm.js" @@ -0,0 +1,82 @@ +import { useState } from "react"; +import useAsync from "../hooks/useAsync"; +import FileInput from "./FileInput"; +import RatingInput from "./RatingInput"; +import "./ReviewForm.css"; +import useTranslate from "../hooks/useTranslate"; + +const INITIAL_VALUES = { + title: "", + rating: 0, + content: "", + imgFile: null, +}; + +function ReviewForm({ + initialValues = INITIAL_VALUES, + initialPreview, + onCancel, + onSubmit, + onSubmitSuccess, +}) { + const t = useTranslate(); + const [values, setValues] = useState(initialValues); + const [isSubmitting, submittingError, onSubmitAsync] = useAsync(onSubmit); + + const handleChange = (name, value) => { + setValues((prevValues) => ({ + ...prevValues, + [name]: value, + })); + }; + + const handleInputChange = (e) => { + const { name, value } = e.target; + handleChange(name, value); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + const formData = new FormData(); + formData.append("title", values.title); + formData.append("rating", values.rating); + formData.append("content", values.content); + formData.append("imgFile", values.imgFile); + + const result = await onSubmitAsync(formData); + if (!result) return; + + const { review } = result; + setValues(INITIAL_VALUES); + onSubmitSuccess(review); + }; + + return ( +
+ + + +