diff --git a/Frontend/.env.development b/Frontend/.env.development new file mode 100644 index 0000000..460e575 --- /dev/null +++ b/Frontend/.env.development @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8080 diff --git a/Frontend/.env.production b/Frontend/.env.production new file mode 100644 index 0000000..831278c --- /dev/null +++ b/Frontend/.env.production @@ -0,0 +1 @@ +VITE_API_URL=http://solvemeup.com diff --git a/Frontend/index.html b/Frontend/index.html index 5870203..5af3882 100644 --- a/Frontend/index.html +++ b/Frontend/index.html @@ -4,7 +4,10 @@ - + + frontend diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index 5848388..a7594ff 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -12,6 +12,7 @@ "@tailwindcss/postcss": "^4.1.18", "@tanstack/react-query": "^5.90.16", "@tanstack/react-query-devtools": "^5.91.2", + "axios": "^1.13.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-redux": "^9.2.0", @@ -4439,6 +4440,33 @@ "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", "license": "MIT" }, + "node_modules/axios": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz", + "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-code-frame": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", @@ -6709,6 +6737,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -17334,6 +17377,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", diff --git a/Frontend/package.json b/Frontend/package.json index 5e1e675..20070cb 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -14,6 +14,7 @@ "@tailwindcss/postcss": "^4.1.18", "@tanstack/react-query": "^5.90.16", "@tanstack/react-query-devtools": "^5.91.2", + "axios": "^1.13.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-redux": "^9.2.0", diff --git a/Frontend/src/api/api.js b/Frontend/src/api/api.js new file mode 100644 index 0000000..0199575 --- /dev/null +++ b/Frontend/src/api/api.js @@ -0,0 +1,9 @@ +import axios from "axios"; + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + withCredentials: true, + headers: { "Content-Type": "application/json" }, +}); + +export default api; diff --git a/Frontend/src/pages/Community/CommunityDeatailPage.jsx b/Frontend/src/pages/Community/CommunityDeatailPage.jsx deleted file mode 100644 index c3718f0..0000000 --- a/Frontend/src/pages/Community/CommunityDeatailPage.jsx +++ /dev/null @@ -1,214 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useParams } from "react-router-dom"; - -const LS_POSTS = "community_posts_v1"; - -function loadPosts() { - const raw = localStorage.getItem(LS_POSTS); - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function savePosts(posts) { - localStorage.setItem(LS_POSTS, JSON.stringify(posts)); -} - -const ghostBtn = - "bg-transparent p-0 border-0 outline-none focus:outline-none text-gray-500 hover:text-black"; - -export default function CommunityDetailPage() { - const { postId } = useParams(); - const navigate = useNavigate(); - - const [post, setPost] = useState(null); - - - const [commentText, setCommentText] = useState(""); - - - const [comments] = useState([ - { - id: "default-1", - author: "USERNAME", - createdAt: "2024-11-11 18:12", - content: "짱 ~", - likeCount: 0, - dislikeCount: 0, - }, - ]); - - useEffect(() => { - const posts = loadPosts(); - const found = posts.find((p) => String(p.id) === String(postId)); - setPost(found ?? null); - }, [postId]); - - const commentCount = useMemo(() => comments.length, [comments]); - - if (!post) { - return ( -
-
-
- 게시글을 찾을 수 없습니다. -
-
- -
-
-
- ); - } - - - const updatePostCounts = (updater) => { - const posts = loadPosts(); - const next = posts.map((p) => { - if (String(p.id) !== String(postId)) return p; - return updater(p); - }); - savePosts(next); - const refreshed = next.find((p) => String(p.id) === String(postId)); - setPost(refreshed ?? null); - }; - - - const onClickAddComment = () => { - - alert("아직 안됨"); - }; - - - const onClickLikeComment = () => { - alert("아직 안됨"); - }; - - const onClickDislikeComment = () => { - alert("아직 안됨"); - }; - - return ( -
-
-
- - ← - -
커뮤니티 게시글
-
- -
- -
-
- 👤 - {post.author} - {post.createdAt} -
- - -
- - - - -
💬 {commentCount}
- - -
-
- -

{post.title}

- -
- {post.content} -
- - -
- setCommentText(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") onClickAddComment(); - }} - /> - -
- - -
- {comments.map((c) => ( -
-
-
- 👤 - {c.author} - {c.createdAt} -
- - -
- - -
-
- -

{c.content}

-
- ))} -
-
-
-
- ); -} diff --git a/Frontend/src/pages/Community/CommunityDetailPage.jsx b/Frontend/src/pages/Community/CommunityDetailPage.jsx new file mode 100644 index 0000000..2d96110 --- /dev/null +++ b/Frontend/src/pages/Community/CommunityDetailPage.jsx @@ -0,0 +1,377 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import api from "@api/api.js"; + +const ghostBtn = + "bg-transparent flex items-center gap-1 p-0 border-0 outline-none focus:outline-none text-gray-500 hover:text-black"; + +/** ========================= + * 날짜 포맷(한국 시간, "2026. 1. 28. 오전 11:00") + * ========================= */ +function formatKST(iso) { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + return new Intl.DateTimeFormat("ko-KR", { + dateStyle: "medium", + timeStyle: "short", + }).format(d); +} + +export default function CommunityDetailPage() { + const { postId } = useParams(); + const navigate = useNavigate(); + + /** ========================= + * 조회 결과 상태 + * - raw: 서버 응답 원본(디버깅용) + * - post: 화면에 뿌릴 게시글 데이터 + * - errMsg/debug: 에러/상태 표시(문제 생길 때 원인 추적) + * ========================= */ + const [raw, setRaw] = useState(null); + const [post, setPost] = useState(null); + const [errMsg, setErrMsg] = useState(""); + const [debug, setDebug] = useState("INIT"); + + /** ========================= + * 3) 댓글 입력 상태 (아직 API 연결 전) + * ========================= */ + const [commentText, setCommentText] = useState(""); + + /** ========================= + * 요청 중복/경합 방지용 AbortController 보관 + * - React StrictMode/dev 환경에서는 effect가 2번 도는 일이 흔함 + * - 이전 요청을 abort 해서 "success 후 fail" 같은 이상한 로그/상태를 줄임 + * ========================= */ + const abortRef = useRef(null); + + /** ========================= + * 내 정보(me) + 삭제 진행 상태 + * - me: 현재 로그인한 사용자 정보 (작성자 본인 여부 판별용) + * - isDeleting: 삭제 버튼 중복 클릭 방지 + * ========================= */ + const [me, setMe] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + + // "내 정보 조회" - 작성자 본인인지 판단하기 위해 필요 + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const res = await api.get("/api/users/me"); + if (cancelled) return; + + // ✅ 응답 형태 방어: res.data 혹은 res.data.data + const meObj = res.data?.data ?? res.data ?? null; + setMe(meObj); + } catch (e) { + if (cancelled) return; + console.log("[ME] failed", e?.response?.status, e?.response?.data); + setMe(null); + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + + // 여기는 게시글 상세 조회 + useEffect(() => { + if (!postId) return; + + // ✅ 이전 요청이 살아있으면 중단 + if (abortRef.current) { + abortRef.current.abort(); + } + const controller = new AbortController(); + abortRef.current = controller; + + const reqId = `${postId}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + console.log(`[DETAIL] start reqId=${reqId} postId=${postId}`); + + setDebug("EFFECT_STARTED"); + setErrMsg(""); + + (async () => { + try { + const res = await api.get(`/api/posts/${postId}`, { + signal: controller.signal, // ✅ axios(v1+) abort 지원 + }); + + console.log(`[DETAIL] success reqId=${reqId} status=${res.status}`); + + // ✅ 이 요청이 이미 abort 되었으면 반영 X + if (controller.signal.aborted) return; + + setDebug("GET_SUCCESS"); + setRaw(res.data); + setPost(res.data ?? null); + setErrMsg(""); + } catch (e) { + // ✅ abort/취소는 "실패"로 처리하지 않음 + const aborted = + controller.signal.aborted || + e?.code === "ERR_CANCELED" || + e?.name === "CanceledError"; + + if (aborted) { + console.log(`[DETAIL] aborted reqId=${reqId}`); + return; + } + + const status = e?.response?.status; + const data = e?.response?.data; + console.log(`[DETAIL] fail reqId=${reqId} status=${status}`, data); + + setDebug(`GET_FAILED_${status ?? "?"}`); + setErrMsg(`게시글을 불러오지 못했습니다. (status=${status ?? "?"})`); + setRaw(data ?? null); + setPost(null); + } + })(); + + // ✅ cleanup에서 해당 요청 abort + return () => { + controller.abort(); + console.log(`[DETAIL] cleanup(abort) reqId=${reqId}`); + }; + }, [postId]); + + const comments = useMemo(() => post?.comments ?? [], [post]); + + const commentCount = useMemo(() => { + if (typeof post?.commentCount === "number") return post.commentCount; + return comments.length; + }, [post, comments]); + + /** ========================= + * (권한) "내가 작성자인지" 판별 + * - me.id vs post.author.id 비교 + * - 둘 다 문자열로 바꿔 비교(타입 차이 방지) + * ========================= */ + const isMine = useMemo(() => { + const myId = me?.id ?? me?.userId; + const authorId = post?.author?.id ?? post?.authorId; + return !!myId && !!authorId && String(myId) === String(authorId); + }, [me, post]); + + + /** ========================= + * 게시글 삭제 + * ========================= */ + const onClickDelete = async () => { + if (!postId) return; + + const ok = window.confirm("정말 삭제할까요?"); + if (!ok) return; + + setIsDeleting(true); + try { + await api.delete(`/api/posts/${postId}`); + alert("삭제되었습니다."); + navigate("/community"); + } catch (e) { + const status = e?.response?.status; + const data = e?.response?.data; + alert( + `삭제 실패 (status=${status ?? "?"})\n` + + (data?.message || data?.error || JSON.stringify(data || {}, null, 2) || e.message) + ); + } finally { + setIsDeleting(false); + } + }; + + /** ========================= + * 에러 화면 + * - 상세 조회 실패 시 보여줌 + * ========================= */ + if (errMsg) { + return ( +
+
+
{errMsg}
+
+ +
+ +
DEBUG: {debug}
+
+            {JSON.stringify(raw, null, 2)}
+          
+
+
+ ); + } + + /** ========================= + * 로딩 화면 + * - post가 아직 없으면 "불러오는 중..." + * ========================= */ + if (!post) { + return ( +
+
+
불러오는 중...
+ +
DEBUG: {debug}
+
+            {JSON.stringify(raw, null, 2)}
+          
+
+
+ ); + } + + + + const onClickAddComment = () => alert("아직 안됨"); + const onClickLikeComment = () => alert("아직 안됨"); + const onClickDislikeComment = () => alert("아직 안됨"); + + const authorNickname = post?.author?.nickname ?? "(author 없음)"; + const createdAtText = formatKST(post?.createdAt); + const updatedAtText = formatKST(post?.updatedAt); + + return ( +
+
+
+ + ← + +
커뮤니티 게시글
+
+ +
+
+
+ face + {authorNickname} + {createdAtText} + {updatedAtText ? ( + (수정 {updatedAtText}) + ) : null} +
+ +
+
+ thumb_up + {post.likeCount ?? 0} +
+
+ thumb_down + {post.dislikeCount ?? 0} +
+
+ comment + {commentCount} +
+ + {isMine ? ( + + ) : null} + + {isMine ? ( + + ) : null} +
+
+ +

+ {post.title ?? "(title 없음)"} +

+ +
+ {post.content ?? "(content 없음)"} +
+ +
+ setCommentText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onClickAddComment(); + }} + /> + +
+ +
+ {comments.length === 0 ? ( +
댓글이 없습니다.
+ ) : ( + comments.map((c) => { + const cAuthor = + c?.author?.nickname ?? c?.authorNickname ?? c?.author ?? "익명"; + const cCreatedAt = formatKST(c?.createdAt); + + return ( +
+
+
+ face + {cAuthor} + {cCreatedAt} +
+ +
+ + +
+
+ +

{c.content ?? ""}

+
+ ); + }) + )} +
+ + {/* 디버깅용 +
DEBUG: {debug}
+
+            {JSON.stringify(raw, null, 2)}
+          
*/} +
+
+
+ ); +} diff --git a/Frontend/src/pages/Community/CommunityEditPage.jsx b/Frontend/src/pages/Community/CommunityEditPage.jsx new file mode 100644 index 0000000..7eccdd5 --- /dev/null +++ b/Frontend/src/pages/Community/CommunityEditPage.jsx @@ -0,0 +1,192 @@ +import React, { useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import api from "@api/api.js"; + +export default function CommunityEditPage() { + const { postId } = useParams(); + const navigate = useNavigate(); + + const [raw, setRaw] = useState(null); + const [post, setPost] = useState(null); + + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [errMsg, setErrMsg] = useState(""); + const [debug, setDebug] = useState("INIT"); + + // ✅ 1) 기존 글 불러와서 폼 초기값 세팅 + useEffect(() => { + if (!postId) return; + let cancelled = false; + + setDebug("FETCH_STARTED"); + setErrMsg(""); + + (async () => { + try { + const res = await api.get(`/api/posts/${postId}`); + if (cancelled) return; + + setDebug("FETCH_SUCCESS"); + setRaw(res.data); + setPost(res.data ?? null); + + // 폼 초기값 + setTitle(res.data?.title ?? ""); + setContent(res.data?.content ?? ""); + } catch (e) { + if (cancelled) return; + const status = e?.response?.status; + const data = e?.response?.data; + + setDebug(`FETCH_FAILED_${status ?? "?"}`); + setErrMsg( + `게시글을 불러오지 못했습니다. (status=${status ?? "?"})\n` + + (data?.message || data?.error || JSON.stringify(data || {}, null, 2) || e.message) + ); + setRaw(data ?? null); + setPost(null); + } + })(); + + return () => { + cancelled = true; + }; + }, [postId]); + + // ✅ 2) 수정 저장 + const submitEdit = async () => { + const body = { + title: title.trim(), + content: content.trim(), + }; + + if (!body.title) return alert("제목을 입력하세요."); + if (!body.content) return alert("내용을 입력하세요."); + + setIsSubmitting(true); + setErrMsg(""); + setDebug("SUBMIT_STARTED"); + + try { + const res = await api.put(`/api/posts/${postId}`, body); + + setDebug("SUBMIT_SUCCESS"); + setRaw((prev) => ({ prev, submitRes: res.data, sentBody: body })); + + alert("수정 완료!"); + navigate(`/community/${postId}`); + } catch (e) { + const status = e?.response?.status; + const data = e?.response?.data; + + setDebug(`SUBMIT_FAILED_${status ?? "?"}`); + setErrMsg( + `수정 실패 (status=${status ?? "?"})\n` + + (data?.message || data?.error || JSON.stringify(data || {}, null, 2) || e.message) + ); + } finally { + setIsSubmitting(false); + } + }; + + // 로딩/에러 UI + if (errMsg) { + return ( +
+
+
{errMsg}
+
+ + +
+ +
DEBUG: {debug}
+
+            {JSON.stringify(raw, null, 2)}
+          
+
+
+ ); + } + + if (!post) { + return ( +
+
+
불러오는 중...
+
DEBUG: {debug}
+
+
+ ); + } + + return ( +
+
+
+ + ← + +
게시글 수정
+
+ +
+ + setTitle(e.target.value)} + disabled={isSubmitting} + /> + + +