diff --git a/components/addboard/AddBoardForm.module.css b/components/addboard/AddBoardForm.module.css new file mode 100644 index 00000000..368539b9 --- /dev/null +++ b/components/addboard/AddBoardForm.module.css @@ -0,0 +1,39 @@ +.form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.header h2 { + font-size: 1.25rem; + font-weight: 700; + line-height: 1.5rem; + color: var(--gray800); +} + +.button { + line-height: 1.625rem; + padding: 8px 23px; +} + +.input { + gap: 12px; +} + +@media screen and (min-width: 768px) { + .form { + gap: 24px; + } +} + +@media screen and (min-width: 1200px) { + .header h2 { + line-height: 2rem; + } +} diff --git a/components/addboard/AddBoardForm.tsx b/components/addboard/AddBoardForm.tsx new file mode 100644 index 00000000..6f280ee5 --- /dev/null +++ b/components/addboard/AddBoardForm.tsx @@ -0,0 +1,131 @@ +import { ChangeEvent, useState, useEffect, MouseEvent } from "react"; +import { useRouter } from "next/router"; +import FileInput from "../ui/FileInput"; +import Input from "../ui/Input"; +import Textarea from "../ui/Textarea"; +import Button from "../ui/Button"; +import { fetchData } from "@/lib/fetchData"; +import { ARTICLE_URL, IMAGE_URL } from "@/constants/url"; +import { useAuth } from "@/contexts/AuthProvider"; +import styles from "./AddBoardForm.module.css"; + +interface Board { + imgFile: File | null; + title: string; + content: string; +} + +type BoardField = keyof Board; + +const INITIAL_BOARD: Board = { + imgFile: null, + title: "", + content: "", +}; + +const AddBoardForm = () => { + const [isDisabled, setIsDisabled] = useState(true); + const [values, setValues] = useState(INITIAL_BOARD); + const { accessToken } = useAuth(); + const router = useRouter(); + + const handleChange = (name: BoardField, value: Board[BoardField]): void => { + setValues((prevValues) => { + return { + ...prevValues, + [name]: value, + }; + }); + }; + + const handleInputChange = ( + e: ChangeEvent | ChangeEvent + ) => { + const { name, value } = e.target; + handleChange(name as BoardField, value); + }; + + const handleSubmit = async ( + e: MouseEvent + ): Promise => { + e.preventDefault(); + + const { imgFile, ...otherValues } = values; + let url = null; + + if (imgFile) { + const formData = new FormData(); + formData.append("image", imgFile); + + const response = await fetchData(IMAGE_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: formData, + }); + url = response.url; + } + + const { id } = await fetchData(ARTICLE_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: url ? { image: url, ...otherValues } : { ...otherValues }, + }); + router.push(`/board/${id}`); + }; + + const checkFormEmpty = (values: Board): boolean => { + const { title, content } = values; + + return !title || !content; + }; + + useEffect(() => { + setIsDisabled(checkFormEmpty(values)); + }, [values]); + + return ( +
+
+

게시글 쓰기

+ +
+ + + + ); +}; + +export default Textarea; diff --git a/components/ui/XButton.module.css b/components/ui/XButton.module.css new file mode 100644 index 00000000..0c63ec2f --- /dev/null +++ b/components/ui/XButton.module.css @@ -0,0 +1,4 @@ +.icon { + margin-right: -4px; + cursor: pointer; +} diff --git a/components/ui/XButton.tsx b/components/ui/XButton.tsx new file mode 100644 index 00000000..4b46eab7 --- /dev/null +++ b/components/ui/XButton.tsx @@ -0,0 +1,23 @@ +import { MouseEvent } from "react"; +import styles from "./XButton.module.css"; +import Image from "next/image"; +import xIcon from "@/public/ic_x.svg"; + +interface Props { + className: string; + onClick: (e: MouseEvent) => void; +} + +const XButton = ({ className = "", onClick = () => {} }: Props) => { + return ( + + ); +}; + +export default XButton; diff --git a/constants/url.ts b/constants/url.ts new file mode 100644 index 00000000..abeb364d --- /dev/null +++ b/constants/url.ts @@ -0,0 +1,5 @@ +export const ARTICLE_URL = "https://panda-market-api.vercel.app/articles"; +export const IMAGE_URL = "https://panda-market-api.vercel.app/images/upload"; +export const LOGIN_URL = "https://panda-market-api.vercel.app/auth/signIn"; +export const REFRESH_URL = + "https://panda-market-api.vercel.app/auth/refresh-token"; diff --git a/contexts/AuthProvider.tsx b/contexts/AuthProvider.tsx new file mode 100644 index 00000000..3c1a7e40 --- /dev/null +++ b/contexts/AuthProvider.tsx @@ -0,0 +1,74 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { fetchData } from "@/lib/fetchData"; +import { LOGIN_URL, REFRESH_URL } from "@/constants/url"; + +interface AuthContextType { + accessToken: string | null; + login: () => Promise; + logout: VoidFunction; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider = ({ children }: AuthProviderProps) => { + const [accessToken, setAccessToken] = useState(null); + const [refreshToken, setRefreshToken] = useState(null); + + const refreshAccessToken = useCallback(async () => { + try { + const { accessToken } = await fetchData(REFRESH_URL, { + method: "POST", + body: { refreshToken }, + }); + setAccessToken(accessToken); + } catch (error) { + throw new Error(`Fetch failed: ${error}`); + } + }, [refreshToken]); + + const login = async () => { + try { + const { accessToken, refreshToken } = await fetchData(LOGIN_URL, { + method: "POST", + body: { email: "bonobono@email.com", password: "12341234" }, + }); + setAccessToken(accessToken); + setRefreshToken(refreshToken); + } catch (error) { + throw new Error(`Login failed: ${error}`); + } + }; + + const logout = () => { + setAccessToken(null); + setRefreshToken(null); + }; + + useEffect(() => { + const interval = setInterval(refreshAccessToken, 15 * 60 * 1000); + return () => clearInterval(interval); + }, [refreshAccessToken]); + + return ( + + {children} + + ); +}; + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (!context) throw new Error("useAuth must be used within an AuthProvider"); + return context; +}; diff --git a/hooks/useObserver.ts b/hooks/useObserver.ts new file mode 100644 index 00000000..0fb05d6c --- /dev/null +++ b/hooks/useObserver.ts @@ -0,0 +1,30 @@ +import { MutableRefObject, useEffect } from "react"; + +const useObserver = ( + observerRef: MutableRefObject, + hasMore: boolean, + isLoading: boolean, + handleLoad: VoidFunction +) => { + useEffect(() => { + if (observerRef.current) observerRef.current.disconnect(); + + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isLoading) { + handleLoad(); + } + }, + { threshold: 1 } + ); + + const target = document.querySelector("#end-of-list"); + if (target) observerRef.current.observe(target); + + return () => { + if (observerRef.current) observerRef.current.disconnect(); + }; + }, [observerRef, handleLoad, hasMore, isLoading]); +}; + +export default useObserver; diff --git a/lib/fetchData.ts b/lib/fetchData.ts index ae84aaca..7b09535c 100644 --- a/lib/fetchData.ts +++ b/lib/fetchData.ts @@ -4,7 +4,7 @@ type FetchOptions = { query?: Record; method?: "GET" | "POST" | "PUT" | "DELETE"; headers?: Record; - body?: Record | string | null; + body?: Record | string | FormData | null; }; export const fetchData = async (url: string, options: FetchOptions = {}) => { @@ -17,14 +17,25 @@ export const fetchData = async (url: string, options: FetchOptions = {}) => { } const { method = "GET", headers = {}, body } = options; + const isFormData = body instanceof FormData; + + const requestHeaders = { + ...(isFormData + ? headers + : { "Content-Type": "application/json", ...headers }), + }; + + const requestBody = + method !== "GET" && body + ? isFormData + ? body + : JSON.stringify(body) + : null; const response = await fetch(fullUrl, { method, - headers: { - "Content-Type": "application/json", - ...headers, - }, - body: method !== "GET" && body ? JSON.stringify(options.body) : null, + headers: requestHeaders, + body: requestBody, }); if (!response.ok) { diff --git a/lib/formatDate.ts b/lib/formatDate.ts index 5b476e44..7eebd375 100644 --- a/lib/formatDate.ts +++ b/lib/formatDate.ts @@ -6,3 +6,32 @@ export const formatDate = (date: string) => day: "2-digit", }) .replace(/\.$/, ""); + +export const formatDateWithTime = (date: string) => + new Date(date).toLocaleDateString("ko-kr", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + +export const timeAgo = (date: string) => { + const now: Date = new Date(); + const past: Date = new Date(date); + const diffMs = now.getTime() - past.getTime(); + const diffSec = Math.floor(diffMs / 1000); + + const minute = 60; + const hour = 60 * minute; + const day = 24 * hour; + const week = 7 * day; + + if (diffSec < minute) return "방금 전"; + if (diffSec < hour) return `${Math.floor(diffSec / minute)}분 전`; + if (diffSec < day) return `${Math.floor(diffSec / hour)}시간 전`; + if (diffSec < week) return `${Math.floor(diffSec / day)}일 전`; + + return formatDateWithTime(date); +}; diff --git a/next.config.js b/next.config.js index 1d527eac..7ac3be99 100644 --- a/next.config.js +++ b/next.config.js @@ -2,13 +2,11 @@ const nextConfig = { reactStrictMode: true, images: { - remotePatterns: [ - { - protocol: "https", - hostname: "sprint-fe-project.s3.ap-northeast-2.amazonaws.com", - port: "", - pathname: "/Sprint_Mission/**", - }, + domains: [ + "sprint-fe-project.s3.ap-northeast-2.amazonaws.com", + "via.placeholder.com", + "flexible.img.hani.co.kr", + "example.com", ], }, }; diff --git a/pages/_app.tsx b/pages/_app.tsx index d020895d..84406ebd 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -4,17 +4,18 @@ import Head from "next/head"; import type { AppProps } from "next/app"; import Header from "@/components/layout/Header"; import Container from "@/components/layout/Container"; +import { AuthProvider } from "@/contexts/AuthProvider"; export default function App({ Component, pageProps }: AppProps) { return ( - <> + 판다 마켓
- + - + ); } diff --git a/pages/addboard.tsx b/pages/addboard.tsx new file mode 100644 index 00000000..dce621f1 --- /dev/null +++ b/pages/addboard.tsx @@ -0,0 +1,11 @@ +import AddBoardForm from "@/components/addboard/AddBoardForm"; + +const AddBoard = () => { + return ( + <> + + + ); +}; + +export default AddBoard; diff --git a/pages/board/[id].tsx b/pages/board/[id].tsx new file mode 100644 index 00000000..f24a1f20 --- /dev/null +++ b/pages/board/[id].tsx @@ -0,0 +1,96 @@ +import { + ChangeEvent, + FormEvent, + useCallback, + useEffect, + useState, +} from "react"; +import { useRouter } from "next/router"; +import Link from "next/link"; +import { GetServerSidePropsContext } from "next"; +import BoardDetail from "@/components/board/BoardDetail"; +import AddCommentForm from "@/components/board/AddCommentForm"; +import Comments from "@/components/board/Comments"; +import Button from "@/components/ui/Button"; +import { ARTICLE_URL } from "@/constants/url"; +import { fetchData } from "@/lib/fetchData"; +import { useAuth } from "@/contexts/AuthProvider"; +import { ArticleProps, CommentProps } from "@/types/articleTypes"; +import styles from "@/styles/Board.module.css"; +import Image from "next/image"; +import backIcon from "@/public/ic_back.svg"; + +export const getServerSideProps = async ( + context: GetServerSidePropsContext +) => { + const id = context.params?.["id"]; + const { list } = await fetchData(`${ARTICLE_URL}/${id}/comments`, { + query: { limit: 5 }, + }); + + return { + props: { + comments: list, + }, + }; +}; + +const BoardDetailPage = ({ + comments: initialComments, +}: { + comments: CommentProps[]; +}) => { + const [board, setBoard] = useState(); + const [comments, setComments] = useState(initialComments); + const [comment, setComment] = useState(""); + const { accessToken } = useAuth(); + + const router = useRouter(); + const { id } = router.query; + + const getBoard = useCallback(async () => { + const response = await fetchData(`${ARTICLE_URL}/${id}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + setBoard(response); + }, [accessToken, id]); + + useEffect(() => { + getBoard(); + }, [getBoard]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const newComment = await fetchData(`${ARTICLE_URL}/${id}/comments`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: { content: comment }, + }); + setComment(""); + setComments((prevComments) => [newComment, ...prevComments]); + }; + + return ( + <> + {board && } + + + + + + + ); +}; + +export default BoardDetailPage; diff --git a/pages/boards.tsx b/pages/boards.tsx index a03057be..53c7361b 100644 --- a/pages/boards.tsx +++ b/pages/boards.tsx @@ -1,24 +1,42 @@ import BestBoards from "@/components/boards/BestBoards"; -import BoardList from "@/components/boards/BoardList"; +import BoardList, { BoardListProps } from "@/components/boards/BoardList"; import { fetchData } from "@/lib/fetchData"; -import { ArticleProps } from "@/types/articleTypes"; +import { GetServerSidePropsContext } from "next"; +import { useAuth } from "@/contexts/AuthProvider"; +import { useEffect } from "react"; + +export const getServerSideProps = async ( + context: GetServerSidePropsContext +) => { + const keyword = context.query["keyword"] ?? ""; -export const getStaticProps = async () => { const BASE_URL = "https://panda-market-api.vercel.app/articles"; - const { list } = await fetchData(BASE_URL); + const { list } = await fetchData(BASE_URL, { + query: { keyword: typeof keyword === "string" ? keyword : "" }, + }); return { props: { initialBoards: list, + initialKeyword: keyword, }, }; }; -const Boards = ({ initialBoards }: { initialBoards: ArticleProps[] }) => { +const Boards = ({ initialBoards, initialKeyword }: BoardListProps) => { + const { login } = useAuth(); + + useEffect(() => { + login(); + }, [login]); + return ( <> - + ); }; diff --git a/public/Img_reply_empty.svg b/public/Img_reply_empty.svg new file mode 100644 index 00000000..f8ca0a29 --- /dev/null +++ b/public/Img_reply_empty.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/ic_back.svg b/public/ic_back.svg new file mode 100644 index 00000000..d0770ae6 --- /dev/null +++ b/public/ic_back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/ic_kebab.svg b/public/ic_kebab.svg new file mode 100644 index 00000000..dd7ed7f5 --- /dev/null +++ b/public/ic_kebab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/ic_plus.svg b/public/ic_plus.svg new file mode 100644 index 00000000..5bb9abf5 --- /dev/null +++ b/public/ic_plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/ic_x.svg b/public/ic_x.svg new file mode 100644 index 00000000..f6674f7f --- /dev/null +++ b/public/ic_x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/styles/Board.module.css b/styles/Board.module.css new file mode 100644 index 00000000..ff83e7a5 --- /dev/null +++ b/styles/Board.module.css @@ -0,0 +1,18 @@ +.link { + display: flex; + justify-content: center; + align-items: center; + pointer-events: none; +} + +.button { + display: flex; + align-items: center; + gap: 8px; + border-radius: 40px; + font-size: 1.125rem; + font-weight: 600; + line-height: 1.625rem; + padding: 11px 40px; + pointer-events: auto; +} diff --git a/types/articleTypes.ts b/types/articleTypes.ts index 945ba594..55274c99 100644 --- a/types/articleTypes.ts +++ b/types/articleTypes.ts @@ -1,6 +1,7 @@ export interface WriterProps { id: number; nickname: string; + image?: string; } export interface ArticleProps { @@ -13,3 +14,11 @@ export interface ArticleProps { updatedAt: string; writer: WriterProps; } + +export interface CommentProps { + id: number; + content: string; + createdAt: string; + updatedAt: string; + writer: WriterProps; +}