diff --git a/package-lock.json b/package-lock.json index d3bc1c272..4a90cf87b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,13 @@ "name": "12-sprint-mission", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^3.10.0", + "dayjs": "^1.11.13", "next": "15.1.4", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", + "zod": "^3.24.1" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -160,6 +164,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1648,6 +1661,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -3950,6 +3969,22 @@ "react": "^19.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -4880,6 +4915,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index e60f08d27..8bb6f1050 100644 --- a/package.json +++ b/package.json @@ -9,17 +9,21 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^3.10.0", + "dayjs": "^1.11.13", + "next": "15.1.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "next": "15.1.4" + "react-hook-form": "^7.54.2", + "zod": "^3.24.1" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.1.4", - "@eslint/eslintrc": "^3" + "typescript": "^5" } } diff --git a/src/components/ArticleItemCard.tsx b/src/components/ArticleItemCard.tsx index 20d562961..4843bdbae 100644 --- a/src/components/ArticleItemCard.tsx +++ b/src/components/ArticleItemCard.tsx @@ -5,9 +5,18 @@ import heartIcon from "@/assets/icons/heart.svg"; import Image from "next/image"; import { formatDate } from "@/utils/formattedDate"; import Link from "next/link"; +import { useMemo } from "react"; -export function ArticleItemCard({ id, title, content, image, likeCount, writer, updatedAt }: Article) { - const formattedDate = formatDate(updatedAt); +interface ArticleItemCardProps { + article: Article; +} + +export function ArticleItemCard({ article }: ArticleItemCardProps) { + const { id, title, content, image, likeCount, writer, updatedAt } = article; + + const formattedDate = useMemo(() => { + return formatDate(updatedAt); + }, [updatedAt]); return (
diff --git a/src/components/ArticleList.tsx b/src/components/ArticleList.tsx index dcbbd30a7..ad6cda1f4 100644 --- a/src/components/ArticleList.tsx +++ b/src/components/ArticleList.tsx @@ -14,17 +14,12 @@ interface ArticleListProps { export default function ArticleList({ initialArticles, initialOrder }: ArticleListProps) { const [isOpen, setIsOpen] = useState(false); - const [selectedLabel, setSelectedLabel] = useState(initialOrder === "like" ? "인기순" : "최신순"); const [orderBy, setOrderBy] = useState<"recent" | "like">(initialOrder); - const handleToggle = () => { - setIsOpen((prev) => !prev); - }; + const selectedLabel = orderBy === "like" ? "인기순" : "최신순"; - const handleSelect = (label: string, value: string) => { - setSelectedLabel(label); - const newOrder = value === "like" ? "like" : "recent"; - setOrderBy(newOrder); + const handleSelect = (label: "최신순" | "인기순", value: "recent" | "like") => { + setOrderBy(value); // 정렬이 바뀌면 page/게시글 초기화 setPage(1); @@ -34,6 +29,10 @@ export default function ArticleList({ initialArticles, initialOrder }: ArticleLi setIsOpen(false); }; + const handleToggle = () => { + setIsOpen((prev) => !prev); + }; + // 검색어 (keyword) const [keyword, setKeyword] = useState(""); @@ -132,7 +131,7 @@ export default function ArticleList({ initialArticles, initialOrder }: ArticleLi
{articles.map((article) => ( - + ))}
diff --git a/src/components/BestItemCard.tsx b/src/components/BestItemCard.tsx index bb49829e9..fc9ef1394 100644 --- a/src/components/BestItemCard.tsx +++ b/src/components/BestItemCard.tsx @@ -5,9 +5,18 @@ import badge from "@/assets/images/img_badge.svg"; import heart from "@/assets/icons/heart.svg"; import { formatDate } from "@/utils/formattedDate"; import Link from "next/link"; +import { useMemo } from "react"; -export default function BestItemCard({ id, content, updatedAt, likeCount, writer, image, title }: Article) { - const formattedDate = formatDate(updatedAt); +interface BestItemCardProps { + article: Article; +} + +export default function BestItemCard({ article }: BestItemCardProps) { + const { id, title, content, image, updatedAt, writer, likeCount } = article; + + const formattedDate = useMemo(() => { + return formatDate(updatedAt); + }, [updatedAt]); return ( diff --git a/src/components/CommentCard.tsx b/src/components/CommentCard.tsx index 5167d4c6e..08597c066 100644 --- a/src/components/CommentCard.tsx +++ b/src/components/CommentCard.tsx @@ -3,13 +3,16 @@ import ProfileImg from "@/assets/images/profile.svg"; import { Comment } from "@/types"; import { formatRelativeTime } from "@/utils/formatRelativeTime"; import Image from "next/image"; +import { useMemo } from "react"; interface CommentProps { comment: Comment; } export default function CommentCard({ comment }: CommentProps) { - const formatDate = formatRelativeTime(comment.updatedAt); + const formatDate = useMemo(() => { + return formatRelativeTime(comment.updatedAt); + }, [comment.updatedAt]); return (
diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index 7f571a37c..54694a258 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -6,7 +6,7 @@ interface DropdownProps { isOpen: boolean; selectedLabel: string; onToggle: () => void; - onSelect: (label: string, value: string) => void; + onSelect: (label: "최신순" | "인기순", value: "recent" | "like") => void; } export default function Dropdown({ isOpen, selectedLabel, onToggle, onSelect }: DropdownProps) { diff --git a/src/components/LoginForm.module.css b/src/components/LoginForm.module.css index d4bbf5a81..6ddcc2b34 100644 --- a/src/components/LoginForm.module.css +++ b/src/components/LoginForm.module.css @@ -115,4 +115,14 @@ color: #3692ff; text-decoration: underline; cursor: pointer; +} + +.errorInput { + border-color: #f74747; +} + +.errorMessage { + color: #f74747; + font-size: 14px; + margin-top: 0.20rem; } \ No newline at end of file diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 607c4ef03..de895905e 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -2,37 +2,51 @@ import styles from "./LoginForm.module.css"; import { useState } from "react"; import Image from "next/image"; import Link from "next/link"; +import { loginSchema, LoginRequest } from "@/types"; +import { useForm, SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; interface LoginFormProps { - onSubmit: (formData: { email: string; password: string }) => Promise; + onSubmit: (request: LoginRequest) => Promise; } export default function LoginForm({ onSubmit }: LoginFormProps) { const [showPassword, setShowPassword] = useState(false); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: zodResolver(loginSchema), + }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - await onSubmit({ email, password }); + const onFormSubmit: SubmitHandler = async (data) => { + try { + await onSubmit(data); + reset(); + } catch (err) { + console.error("서버오류", err); + alert("로그인 중 오류가 발생했습니다. 다시 시도해주세요."); + } }; return ( -
+
setEmail(e.target.value)} + className={`${styles.inputField} ${errors.email ? styles.errorInput : ""}`} />
+ {errors.email &&

{errors.email.message}

}
@@ -41,17 +55,17 @@ export default function LoginForm({ onSubmit }: LoginFormProps) {
setPassword(e.target.value)} + className={`${styles.inputField} ${errors.password ? styles.errorInput : ""}`} />
+ {errors.password &&

{errors.password.message}

}
+ {errors.password &&

{errors.password.message}

}
-