diff --git a/umc-master/index.html b/umc-master/index.html index 046e329..98aeab9 100644 --- a/umc-master/index.html +++ b/umc-master/index.html @@ -11,6 +11,7 @@ /> Vite + React + TS +
diff --git a/umc-master/package.json b/umc-master/package.json index 5c0fb6a..c9d54a0 100644 --- a/umc-master/package.json +++ b/umc-master/package.json @@ -12,8 +12,8 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", - "@tanstack/react-query": "^5.65.0", - "@tanstack/react-query-devtools": "^5.65.0", + "@tanstack/react-query": "^5.66.3", + "@tanstack/react-query-devtools": "^5.66.3", "@types/node": "^22.10.5", "@types/styled-components": "^5.1.34", "axios": "^1.7.9", diff --git a/umc-master/src/App.tsx b/umc-master/src/App.tsx index 5874d65..571df18 100644 --- a/umc-master/src/App.tsx +++ b/umc-master/src/App.tsx @@ -2,13 +2,18 @@ import { ThemeProvider } from 'styled-components'; import GlobalStyle from '@styles/globalStyle.ts'; import theme from '@styles/theme.ts'; import Router from './router/routes.tsx'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); function App() { return ( - - - - + + + + + + ); } diff --git a/umc-master/src/apis/authApi.ts b/umc-master/src/apis/authApi.ts new file mode 100644 index 0000000..55d9c5c --- /dev/null +++ b/umc-master/src/apis/authApi.ts @@ -0,0 +1,38 @@ +// import axiosInstance from '@apis/axios-instance'; + +// interface UserSignup { +// email: string; +// password: string; +// nickname: string; +// hashtags: string[]; +// } + +// export const postSignup = async ({ email, password, nickname, hashtags } : UserSignup) => { +// const { data } = await axiosInstance.post(`/signup`, { +// email, +// password, +// nickname, +// hashtags, +// }); +// return data; +// }; + +import axios from 'axios'; + +export const postSignup = async () => { + try { + const response = await axios.post('https://api.hmaster.shop/api/v1/signup', { + email: 'ekos555@naver.com', + password: 'asfa1234!@', + nickname: 'rael', + hashtags: ['봄', '패션', '청소', '요리', '재활용', '주택'] + }, { + headers: { + 'Content-Type': 'application/json' + } + }); + console.log('회원가입 성공:', response.data); + } catch (error) { + console.error('회원가입 오류:', error); + } +}; diff --git a/umc-master/src/apis/axios-instance.ts b/umc-master/src/apis/axios-instance.ts index bd20a2f..6afb7ac 100644 --- a/umc-master/src/apis/axios-instance.ts +++ b/umc-master/src/apis/axios-instance.ts @@ -70,7 +70,9 @@ axiosInstance.interceptors.response.use( useTokenStore.getState().clearTokens(); // 로그인 페이지로 리다이렉트 - window.location.href = RoutePaths.LOGIN; + if (window.location.pathname !== RoutePaths.LOGIN) { + window.location.href = RoutePaths.LOGIN; + } return Promise.reject(refreshError); } diff --git a/umc-master/src/apis/commentApi.ts b/umc-master/src/apis/commentApi.ts new file mode 100644 index 0000000..4de38d8 --- /dev/null +++ b/umc-master/src/apis/commentApi.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import axiosInstance from './axios-instance'; + +export const getComments = async (tipId: number) => { + try { + const { data } = await axiosInstance.get('/comments'); + return data.result.filter((comment: any) => comment.tips_id === tipId); + } catch (error: any) { + console.error('댓글 불러오기 실패:', error); + throw new Error('댓글을 불러오는 데 실패했습니다.'); + } +}; + +export const addComment = async (tipId: string, comment: string) => { + try { + const { data } = await axiosInstance.post(`/tips/${tipId}/comments`, { + comment, + }); + return data; + } catch (error: any) { + console.error('댓글 추가 실패:', error); + throw new Error('댓글 추가에 실패했습니다.'); + } +}; + +export const editComment = async (tipId: string, commentId: string, newComment: string) => { + try { + const { data } = await axiosInstance.put(`/tips/${tipId}/comments/${commentId}`, { + comment: newComment, + }); + return data; + } catch (error: any) { + console.error('댓글 수정 실패:', error); + throw new Error('댓글 수정에 실패했습니다.'); + } +}; + +export const deleteComment = async (tipId: string, commentId: string) => { + try { + const { data } = await axiosInstance.delete(`/tips/${tipId}/comments/${commentId}`); + return data; + } catch (error: any) { + console.error('댓글 삭제 실패:', error); + throw new Error('댓글 삭제에 실패했습니다.'); + } +}; diff --git a/umc-master/src/apis/queries/useCommentMutations.ts b/umc-master/src/apis/queries/useCommentMutations.ts new file mode 100644 index 0000000..e123d98 --- /dev/null +++ b/umc-master/src/apis/queries/useCommentMutations.ts @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getComments, addComment, editComment, deleteComment } from '@apis/commentApi'; +import { useUserStore } from '@store/userStore'; +export interface Comment { + comment_id: number; + user: { + user_id: number; + nickname: string; + profileImageUrl?: string | null; + }; + comment: string; + created_at: string; +} + +export const useAddComment = (tipId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (comment: string) => addComment(tipId.toString(), comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['comments', tipId] }); + }, + }); +}; + +export const useDeleteComment = (tipId: number) => { + const queryClient = useQueryClient(); + const { user } = useUserStore(); + return useMutation({ + mutationFn: async (commentId: number) => { + const comments: Comment[] = await getComments(tipId); + const comment = comments.find((c) => c.comment_id === commentId); + if (comment?.user.user_id !== user?.user_id) { + throw new Error('본인의 댓글만 삭제할 수 있습니다.'); + } + return deleteComment(tipId.toString(), commentId.toString()); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['comments', tipId] }); + }, + }); +}; + +export const useUpdateComment = (tipId: number) => { + const queryClient = useQueryClient(); + const { user } = useUserStore(); + return useMutation({ + mutationFn: async ({ commentId, newComment }: { commentId: number; newComment: string }) => { + const comments: Comment[] = await getComments(tipId); + const comment = comments.find((c) => c.comment_id === commentId); + if (comment?.user.user_id !== user?.user_id) { + throw new Error('본인의 댓글만 수정할 수 있습니다.'); + } + return editComment(tipId.toString(), commentId.toString(), newComment); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['comments', tipId] }); + }, + }); +}; diff --git a/umc-master/src/apis/queries/useCommentQueries.ts b/umc-master/src/apis/queries/useCommentQueries.ts new file mode 100644 index 0000000..120b530 --- /dev/null +++ b/umc-master/src/apis/queries/useCommentQueries.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getComments } from '@apis/commentApi'; + +export const useComments = (tipId: number) => { + return useQuery({ + queryKey: ['comments', tipId], + queryFn: () => getComments(tipId), + }); +}; diff --git a/umc-master/src/apis/queries/useSaveTipQueries.ts b/umc-master/src/apis/queries/useSaveTipQueries.ts new file mode 100644 index 0000000..da538a1 --- /dev/null +++ b/umc-master/src/apis/queries/useSaveTipQueries.ts @@ -0,0 +1,13 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { getSavedTips } from "@apis/tipApi"; + +export const useSaveTipList = () => { + return useInfiniteQuery({ + queryKey: ["savedTips"], + queryFn: () => getSavedTips(), + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + return lastPage.hasMore ? allPages.length + 1 : undefined; + }, + }); +}; diff --git a/umc-master/src/apis/queries/useTipDetailMutations.ts b/umc-master/src/apis/queries/useTipDetailMutations.ts new file mode 100644 index 0000000..d7d4230 --- /dev/null +++ b/umc-master/src/apis/queries/useTipDetailMutations.ts @@ -0,0 +1,32 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toggleLike, toggleBookmark } from '@apis/tipApi'; + +export const useToggleLike = (tipId: number) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => toggleLike(tipId), + onSuccess: (data) => { + console.log(data.message); + queryClient.invalidateQueries({ queryKey: ['tipDetail', tipId] }); + }, + onError: (error) => { + console.error('좋아요 토글 오류:', error); + }, + }); +}; + +export const useToggleBookmark = (tipId: number) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => toggleBookmark(tipId), + onSuccess: (data) => { + console.log(data.message); + queryClient.invalidateQueries({ queryKey: ['tipDetail', tipId] }); + }, + onError: (error) => { + console.error('북마크 토글 오류:', error); + }, + }); +}; diff --git a/umc-master/src/apis/queries/useTipDetailQuery.ts b/umc-master/src/apis/queries/useTipDetailQuery.ts new file mode 100644 index 0000000..66f644f --- /dev/null +++ b/umc-master/src/apis/queries/useTipDetailQuery.ts @@ -0,0 +1,10 @@ +import { getTipDetail } from '@apis/tipApi'; +import { useQuery } from '@tanstack/react-query'; + +export const useTipDetail = (tipId: number) => { + return useQuery({ + queryKey: ['tipDetail', tipId], + queryFn: () => getTipDetail(tipId), + enabled: !!tipId, // tipId가 있을 때만 실행 + }); +}; diff --git a/umc-master/src/apis/tipApi.ts b/umc-master/src/apis/tipApi.ts index bef2112..41bef19 100644 --- a/umc-master/src/apis/tipApi.ts +++ b/umc-master/src/apis/tipApi.ts @@ -14,6 +14,10 @@ export interface NewPost { imageUrls: File[]; } +export interface GetSavedParams { + page: number; +} + export const getTips = async ({ pageParam, sorted }: GetTipsParams) => { const { data } = await axiosInstance.get(`/tips/sorted?page=${pageParam}&limit=5&sort=${sorted}`); return data; @@ -50,4 +54,28 @@ export const createPost = async (newPost: NewPost): Promise => { } }; -// 다른 Tips 관련 API들... +export const getSavedTips = async () => { + try { + const { data } = await axiosInstance.get(`/users/saved-tips`); + console.log('저장된 꿀팁 API 응답:', data.result); + return data.result; + } catch (error: any) { + console.error('저장된 꿀팁 API 에러 발생:', error.response?.status, error.response?.data); + throw new Error(`저장된 꿀팁 API 요청 실패: ${error.response?.status}`); + } +}; + +export const getTipDetail = async (tipId: number) => { + const { data } = await axiosInstance.get(`/tips/${tipId}`); + return data.result; +}; + +export const toggleLike = async (tipId: number) => { + const response = await axiosInstance.post(`/tips/${tipId}/like`); + return response.data; +}; + +export const toggleBookmark = async (tipId: number) => { + const response = await axiosInstance.post(`/tips/${tipId}/bookmark`); + return response.data; +}; diff --git a/umc-master/src/assets/gray-character.png b/umc-master/src/assets/gray-character.png new file mode 100644 index 0000000..0a67ba9 Binary files /dev/null and b/umc-master/src/assets/gray-character.png differ diff --git a/umc-master/src/components/Card/Card.tsx b/umc-master/src/components/Card/Card.tsx index 5bc0e33..f99add1 100644 --- a/umc-master/src/components/Card/Card.tsx +++ b/umc-master/src/components/Card/Card.tsx @@ -40,8 +40,7 @@ const CardImageWrapper = styled.div` `; const CardImage = styled.img` - min-width: 240px; - width: 100%; + width: 240px; height: 200px; object-fit: cover; `; diff --git a/umc-master/src/components/NavigationBar/NavigationBar.tsx b/umc-master/src/components/NavigationBar/NavigationBar.tsx index ea636f9..736c344 100644 --- a/umc-master/src/components/NavigationBar/NavigationBar.tsx +++ b/umc-master/src/components/NavigationBar/NavigationBar.tsx @@ -1,5 +1,5 @@ /* eslint-disable react/prop-types */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; import LogoImage from '@assets/logo.png'; @@ -8,6 +8,9 @@ import Typography from '@components/common/typography'; import AlarmIcon from '@assets/icons/alarm.svg?react'; import AlarmModal from '@components/Modal/alarm'; import ProfileModal from '@components/Modal/profile'; +import { useUserStore } from '@store/userStore'; +import { getUsers } from '@apis/profileApi'; +import gray_character from '@assets/gray-character.png'; interface NavigationBarProps { login: boolean; @@ -16,6 +19,12 @@ interface NavigationBarProps { const NavigationBar: React.FC = ({ login }) => { const [isAlarmModalOpen, setIsAlarmModalOpen] = useState(false); const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); + const { user, fetchUser } = useUserStore(); + + useEffect(() => { + fetchUser(); // 컴포넌트 마운트 시 사용자 정보 가져오기 + }, []); + getUsers(); const toggleAlarmModal = () => setIsAlarmModalOpen((prev) => !prev); const toggleProfileModal = () => setIsProfileModalOpen((prev) => !prev); @@ -45,7 +54,11 @@ const NavigationBar: React.FC = ({ login }) => { {login ? ( - + ) : ( @@ -113,7 +126,7 @@ const UserSection = styled.div` cursor: pointer; `; -const ProfileImg = styled.div` +const ProfileImg = styled.img` width: 40px; height: 40px; background: #e0e0e0; /** TODO: 프로필 이미지 추가 */ diff --git a/umc-master/src/pages/auth/Login_components/InputForm.tsx b/umc-master/src/pages/auth/Login_components/InputForm.tsx index 0d6d7f1..2204ddb 100644 --- a/umc-master/src/pages/auth/Login_components/InputForm.tsx +++ b/umc-master/src/pages/auth/Login_components/InputForm.tsx @@ -195,6 +195,7 @@ const StyledCheckbox = styled.input` const StyledTypography = styled(Typography)` color: ${({ theme }) => theme.colors.text.gray}; + cursor: pointer; `; const Options = styled.div` diff --git a/umc-master/src/pages/auth/SignUpPage.tsx b/umc-master/src/pages/auth/SignUpPage.tsx index e650b2e..48f542f 100644 --- a/umc-master/src/pages/auth/SignUpPage.tsx +++ b/umc-master/src/pages/auth/SignUpPage.tsx @@ -9,44 +9,72 @@ import PrivacyForm from "./Signup_components/PrivacyForm"; import InterestForm from "./Signup_components/InterestForm"; import Button from "@components/Button/Button"; import { useNavigate } from "react-router-dom"; - +import { postSignup } from "@apis/authApi"; const SignUpPage: React.FC = () => { - const theme = useTheme(); - const navigate = useNavigate(); - const [sectionCount, setSectionCount] = useState(0); - const [isNextButtonEnabled, setIsNextButtonEnabled] = useState(false); +const theme = useTheme(); +const navigate = useNavigate(); +const [sectionCount, setSectionCount] = useState(0); +const [isNextButtonEnabled, setIsNextButtonEnabled] = useState(false); +const [email, setEmail] = useState(""); +const [password, setPassword] = useState(""); +const [nickname, setNickname] = useState(""); +const [hashtags, setHashtag] = useState([]); + +useEffect(() => { + setIsNextButtonEnabled(false); // 섹션이 변경될 때마다 버튼 비활성화 +}, [sectionCount]); + +const handleCheckRequired = (areRequiredChecked: boolean) => { + setIsNextButtonEnabled(areRequiredChecked); +} + +const handleEmailChange = (email: string) => { + setEmail(email); +}; + +const handlePasswordChange = (password: string) => { + setPassword(password); +}; + +const handleNicknameChange = (nickname: string) => { + setNickname(nickname); +}; - useEffect(() => { - setIsNextButtonEnabled(false); // 섹션이 변경될 때마다 버튼 비활성화 - }, [sectionCount]); - - const handleCheckRequired = (areRequiredChecked: boolean) => { - setIsNextButtonEnabled(areRequiredChecked); +const handleHashtagChange = (hashtags: string[]) => { + setHashtag(hashtags); +}; + +const handleSignUpComplete = async () => { + try { + const userSignupData = { email, password, nickname, hashtags }; + console.log("회원가입 확인:", userSignupData) + await postSignup(); + navigate("/main"); + } catch (error) { + console.error("회원가입 오류:", error); + alert("회원가입에 실패했습니다. 다시 시도해주세요."); } +}; const renderSection = () => { switch (sectionCount) { case 0: return ; case 1: - return ; + return ; case 2: - return ; + return ; case 3: - return ; + return ; case 4: - return ; + return ; default: - return ; + return ; } }; - const handleSignUpComplete = () => { - navigate("/main"); - }; - return ( diff --git a/umc-master/src/pages/auth/Signup_components/EmailForm.tsx b/umc-master/src/pages/auth/Signup_components/EmailForm.tsx index 113d0b2..647b5c9 100644 --- a/umc-master/src/pages/auth/Signup_components/EmailForm.tsx +++ b/umc-master/src/pages/auth/Signup_components/EmailForm.tsx @@ -1,4 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable react/prop-types */ +import axiosInstance from "@apis/axios-instance"; import Button from "@components/Button/Button"; import Typography from "@components/common/typography"; import Input from "@components/Input/Input"; @@ -6,6 +8,7 @@ import { useEffect, useState } from "react"; import styled, { useTheme } from "styled-components"; const emails = [ + { value: "", label: "선택" }, { value: "naver.com", label: "naver.com" }, { value: "daum.net", label: "daum.net" }, { value: "gmail.com", label: "gmail.com" }, @@ -14,21 +17,114 @@ const emails = [ { value: "outlook.com", label: "outlook.com" }, ]; -const EmailForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> = ({ onCheckRequired }) => { +const EmailForm: React.FC<{ + onCheckRequired: (isValid: boolean) => void, + onEmailChange: (email: string) => void +}> = ({ onCheckRequired, onEmailChange }) => { const theme = useTheme(); - const [email, setEmail] = useState(''); const [authCode, setAuthCode] = useState(''); + const [localPart, setLocalPart] = useState(""); + const [domain, setDomain] = useState(""); + const [emailSent, setEmailSent] = useState(false); + const [verified, setVerified] = useState(false); + const [timer, setTimer] = useState(180); + const [retryEnabled, setRetryEnabled] = useState(false); + + const fullEmail = localPart && domain ? `${localPart}@${domain}` : ""; // 이메일과 인증번호 입력이 모두 채워졌는지 확인하는 useEffect useEffect(() => { - if (email && authCode) { + if (verified) { onCheckRequired(true); // 유효성 체크 } else { onCheckRequired(false); // 유효성 체크 } - }, [email, authCode, onCheckRequired]); + }, [verified, onCheckRequired]); + + useEffect(() => { + let timerInterval: NodeJS.Timeout | undefined; + if (emailSent && timer > 0) { + timerInterval = setInterval(() => { + setTimer((prev) => prev - 1); + }, 1000); + } else if (timer === 0) { + setRetryEnabled(true); + } + return () => { + if (timerInterval) { + clearInterval(timerInterval); + } + }; + }, [emailSent, timer]); + + const handleEmailRequest = async () => { + if (!localPart || !domain) { + alert("이메일을 입력해주세요."); + return; + } + try { + await axiosInstance.post("/auth/send-verification-email", { email: fullEmail }); + setEmailSent(true); + alert("인증 코드가 발송되었습니다."); + } catch (error) { + alert("이메일 인증 요청에 실패했습니다."); + } + }; + + const handleVerifyCode = async () => { + if (!authCode) { + alert("인증번호를 입력해주세요."); + return; + } + try { + const response = await axiosInstance.post("/auth/verify-email-code", { + email: fullEmail, + code: authCode, + }); + console.log("서버 응답:", response.data); + if (response.data) { + alert("이메일 인증이 완료되었습니다."); + setVerified(true); + } else { + alert("인증번호가 올바르지 않습니다."); + } + } catch (error) { + alert("인증번호 확인에 실패했습니다."); + } + }; + + const handleDomainChange = (e: React.ChangeEvent) => { + const updatedDomain = e.target.value; + setDomain(updatedDomain); + + const updatedFullEmail = `${localPart}@${updatedDomain}`; + onEmailChange(updatedFullEmail); + + console.log("도메인 변경:", updatedFullEmail); + }; + + const handleEmailChange = (e: React.ChangeEvent) => { + const email = e.target.value; + setLocalPart(email); + + const updatedFullEmail = `${email}@${domain}`; // 최신 도메인 사용 + onEmailChange(updatedFullEmail); + + console.log("이메일 입력:", updatedFullEmail); + }; + + + const handleRetry = () => { + setLocalPart(""); + setDomain(""); + setAuthCode(""); + setEmailSent(false); + setVerified(false); + setTimer(180); + setRetryEnabled(false); + }; return ( @@ -38,24 +134,30 @@ const EmailForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> = ({ >이메일 입력 (필수) * setEmail(e.target.value)} + value={localPart} + onChange={handleEmailChange} + disabled={emailSent} /> @ - + {emails.map((email) => ( ))} - + void }> = ({ value={authCode} onChange={(e) => setAuthCode(e.target.value)} /> - + + {emailSent && ( + + 남은 시간: {Math.floor(timer / 60)}분 {timer % 60}초 + + + )} ); }; @@ -119,4 +232,15 @@ const EmailSelect = styled.select` background-color: ${({ theme }) => theme.colors.text.white}; color: ${({ theme }) => theme.colors.text.black}; } +` + +const Timer = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + gap: 10px; + margin-top: 10px; + width: 100%; /* 부모 컨테이너에서 중앙 정렬 */ + align-self: center; ` \ No newline at end of file diff --git a/umc-master/src/pages/auth/Signup_components/InterestForm.tsx b/umc-master/src/pages/auth/Signup_components/InterestForm.tsx index 24c23da..3d69070 100644 --- a/umc-master/src/pages/auth/Signup_components/InterestForm.tsx +++ b/umc-master/src/pages/auth/Signup_components/InterestForm.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/prop-types */ import Typography from "@components/common/typography"; import CategoryInputSection from "@pages/main/components/CategoriesInputSection"; import { useState } from "react"; @@ -16,7 +17,7 @@ const dummyCategories = [ { section: '주거', tags: ['주택', '원룸', '빌라', '아파트', '기숙사'] }, ]; -const InterestForm: React.FC = () => { +const InterestForm: React.FC<{ onHashtagChange: (hashtags: string[]) => void }> = ({ onHashtagChange }) => { const [selectedTags, setSelectedTags] = useState([]); @@ -24,6 +25,8 @@ const InterestForm: React.FC = () => { const isSelected = selectedTags.includes(tag); const updatedTags = isSelected ? selectedTags.filter((t) => t !== tag) : [...selectedTags, tag]; setSelectedTags(updatedTags); + onHashtagChange(updatedTags); // 상위 컴포넌트로 업데이트 + console.log("해시태그: ", updatedTags); }; const theme = useTheme(); diff --git a/umc-master/src/pages/auth/Signup_components/PasswordForm.tsx b/umc-master/src/pages/auth/Signup_components/PasswordForm.tsx index 3fd0414..0de5716 100644 --- a/umc-master/src/pages/auth/Signup_components/PasswordForm.tsx +++ b/umc-master/src/pages/auth/Signup_components/PasswordForm.tsx @@ -5,7 +5,11 @@ import { useEffect, useState } from "react"; import { styled, useTheme } from "styled-components"; -const PasswordForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> = ({ onCheckRequired }) => { +const PasswordForm: React.FC<{ + onCheckRequired: (isValid: boolean) => void; + onPasswordChange: (password: string) => void; +}> = ({ onCheckRequired, onPasswordChange }) => { + const theme = useTheme(); @@ -19,6 +23,13 @@ const PasswordForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> = onCheckRequired(false); } }, [password, confirmPassword]); + + const handlePasswordChange = (e: React.ChangeEvent) => { + const newPassword = e.target.value; + setPassword(newPassword); + onPasswordChange(newPassword); // 상위 컴포넌트에 전달 + console.log("비밀번호: ", newPassword); + }; return ( @@ -31,7 +42,7 @@ const PasswordForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> = type={'password'} placeholder={'비밀번호 입력 (숫자, 영문자, 문자 포함 8~15자 이내)'} value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={handlePasswordChange} /> void }> = ({ onCheckRequired }) => { +const PrivacyForm: React.FC<{ + onCheckRequired: (isValid: boolean) => void; + onNicknameChange: (nickname: string) => void; +}> = ({ onCheckRequired, onNicknameChange }) => { const [selectedCity, setSelectedCity] = useState("default"); const [districts, setDistricts] = useState([]); @@ -22,7 +25,8 @@ const PrivacyForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> = ( const handleNicknameChange = (e: React.ChangeEvent) => { const newNickname = e.target.value; setNickname(newNickname); - + onNicknameChange(newNickname); // 상위 컴포넌트로 업데이트 + console.log("닉네임: ", newNickname); // 닉네임이 0글자 이상일 때만 "다음" 버튼을 활성화 onCheckRequired(newNickname.length > 0); }; diff --git a/umc-master/src/pages/mypage/MyPage.tsx b/umc-master/src/pages/mypage/MyPage.tsx index 6a9757c..4deb5e6 100644 --- a/umc-master/src/pages/mypage/MyPage.tsx +++ b/umc-master/src/pages/mypage/MyPage.tsx @@ -2,7 +2,7 @@ import styled, { useTheme } from "styled-components"; import ProfileSection from "./components/ProfileSection"; import RecentTips from "./components/RecentTips"; import BestInterest from "./components/BestInterest"; -import { dummyData, dummyInterests } from "./dummyData/dummyData"; +import { dummyInterests } from "./dummyData/dummyData"; import Typography from "@components/common/typography"; const MyPage: React.FC = () => { @@ -17,7 +17,7 @@ const MyPage: React.FC = () => { >마이페이지 - + @@ -40,10 +40,19 @@ const MyPageForm = styled.div` display: flex; flex-direction: column; align-items: center; - width: 1280px; + width: 1440px; gap: 48px; - padding-top: 80px; - padding-bottom: 100px; + padding: 80px 0px 100px; + + @media (max-width: 1024px) { + gap: 32px; + padding: 60px 16px 80px; + } + + @media (max-width: 768px) { + gap: 24px; + padding: 40px 12px 60px; + } ` const ProfileCard = styled.div` @@ -51,4 +60,15 @@ const ProfileCard = styled.div` flex-direction: row; gap: 30px; flex-shrink: 0; + width: 100%; + + @media (max-width: 1024px) { + gap: 20px; + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: center; + gap: 16px; + } ` \ No newline at end of file diff --git a/umc-master/src/pages/mypage/components/ProfileSection.tsx b/umc-master/src/pages/mypage/components/ProfileSection.tsx index 83c676b..7063d58 100644 --- a/umc-master/src/pages/mypage/components/ProfileSection.tsx +++ b/umc-master/src/pages/mypage/components/ProfileSection.tsx @@ -1,20 +1,53 @@ import Typography from '@components/common/typography'; import styled, { useTheme } from 'styled-components'; import CameraImg from '@assets/icons/cameraImg.svg' -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import ProfileEditModal from '../modal/ProfileEditModal'; +import { useUserStore } from '@store/userStore'; +import { getUsers } from '@apis/profileApi'; +import gray_character from '@assets/gray-character.png'; const ProfileSection: React.FC = () => { const [isModalOpen, setIsModalOpen] = useState(false); + const { user, fetchUser, setProfileImageUrl } = useUserStore(); + const [profileImageUrl, setProfileImageUrlLocal] = useState(user?.profile_image_url || gray_character); + + useEffect(() => { + fetchUser(); // 컴포넌트 마운트 시 사용자 정보 가져오기 + }, []); + getUsers(); const theme = useTheme(); + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + const imageUrl = reader.result as string; + setProfileImageUrlLocal(imageUrl); // 로컬 상태 업데이트 + setProfileImageUrl(imageUrl); // 전역 상태 업데이트 + }; + reader.readAsDataURL(file); + } + }; + return ( - - + + document.getElementById('fileInput')?.click()} + /> + @@ -22,7 +55,7 @@ const ProfileSection: React.FC = () => { 애니 + >{user?.nickname} = ({ items }) => { - +const RecentTips: React.FC = () => { const theme = useTheme(); - const navigate = useNavigate(); // 추가 + const navigate = useNavigate(); + + // zustand 상태에서 최근 팁 가져오기 + const { recentTips } = recentStore(); const handleCardClick = (id: string) => { navigate(`/save-tip/${id}`); // 상세 페이지로 이동 }; + useEffect(() => { + // 처음 렌더링 시에 최근 본 팁이 로컬스토리지에 있으면 복원됩니다. + }, []); + return ( - 최근에 본 꿀팁 - {items.length === 0 ? ( + + 최근에 본 꿀팁 + + {recentTips.length === 0 ? ( 최근 본 꿀팁이 없습니다. ) : ( - {items.map((item) => ( - handleCardClick(item.id)} + {recentTips.map((item) => ( + handleCardClick(String(item.tipId))} /> ))} - + )} ); @@ -58,16 +51,17 @@ export default RecentTips; const RecentGoodTip = styled.div` display: flex; + width: 780px; height: 295px; flex-direction: column; align-items: flex-start; gap: 20px; flex-shrink: 0; -` +`; const TipCardList = styled.div` display: flex; align-items: center; gap: 30px; align-self: stretch; -` \ No newline at end of file +`; diff --git a/umc-master/src/pages/saveTip/DetailPage_componenets/CommentView.tsx b/umc-master/src/pages/saveTip/DetailPage_componenets/CommentView.tsx deleted file mode 100644 index 6c99704..0000000 --- a/umc-master/src/pages/saveTip/DetailPage_componenets/CommentView.tsx +++ /dev/null @@ -1,257 +0,0 @@ -/* eslint-disable react/prop-types */ -import Typography from "@components/common/typography"; -import { useCallback, useEffect, useRef, useState } from "react"; -// import { useParams } from "react-router-dom"; -import styled, { useTheme } from "styled-components"; -import { generateComments } from "../dummydata/dummydata"; -import SkeletonComment from "@components/Skeleton/SkeletonComment"; - - -const commentCount = 1000; // 실제 데이터에서 가져올 값 -const formattedNumber = new Intl.NumberFormat().format(commentCount); - -const MAX_LENGTH = 100; -const COMMENTS_PER_LOAD = 3; - -const CommentText: React.FC<{ text: string }> = ({ text }) => { - const [isExpanded, setIsExpanded] = useState(false); - const shouldShowMore = text.length > MAX_LENGTH; - const theme = useTheme(); - - return ( -
- - {isExpanded ? text : `${text.slice(0, MAX_LENGTH)}`} - - {shouldShowMore && ( - - )} -
- ); - }; - -const CommentView: React.FC = () => { - - const theme = useTheme(); - // const { tipId } = useParams<{ tipId: string }>(); - - const comment = generateComments(1300); - - const [comments, setComments] = useState<{ author: string; date: string; time: string; comment: string }[]>(comment.slice(0, COMMENTS_PER_LOAD * 2)); - const [inputValue, setInputValue] = useState(""); - const [isLoading, setIsLoading] = useState(false); // 로딩 상태 관리 - const [hasMore, setHasMore] = useState(comment.length > COMMENTS_PER_LOAD); - const observerRef = useRef(null); - const lastElementRef = useRef(null); - - const handleAddComment = () => { - if (inputValue.trim().length === 0) return; - setComments((prevComments) => [ - { - author: "내이름", // 예시로 새 댓글 작성자 지정 - date: "2025.02.02", // 예시로 새 댓글 작성 날짜 지정 - time: "2:43", // 예시로 새 댓글 작성 시간 지정 - comment: inputValue, // 새 댓글 내용 - }, - ...prevComments, - ]); - setInputValue(""); - }; - - const loadMoreData = useCallback(() => { - if (isLoading || !hasMore) return; - - setIsLoading(true); - setTimeout(() => { - const nextData = comment.slice(comments.length, comments.length + COMMENTS_PER_LOAD); - setComments((prevData) => { - const updatedComments = [...prevData, ...nextData]; - setHasMore(updatedComments.length < comment.length); // 여기서 updatedComments.length 사용! - return updatedComments; - }); - setIsLoading(false); - }, 1000); - }, [isLoading, hasMore, comments.length]); - - useEffect(() => { - if (isLoading || !hasMore) return; - - if (observerRef.current) observerRef.current.disconnect(); - - observerRef.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) { - loadMoreData(); - } - }, { threshold: 1.0 }); - - if (lastElementRef.current) observerRef.current.observe(lastElementRef.current); - - return () => observerRef.current?.disconnect(); - }, [isLoading, hasMore, loadMoreData]); - - return ( - - - - <Typography - variant="headingXxxSmall" - style={{color: theme.colors.text.black}} - >댓글</Typography> - <Typography - variant="titleXxxSmall" - style={{color: theme.colors.text.gray}} - >({formattedNumber})</Typography> - - setInputValue(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleAddComment()} - /> - - {comments.length === 0 && !isLoading ? ( - 아직 댓글이 없습니다. - ) : ( - - {comments.map((cmt, index) => ( - - - - - - {cmt.author} - - - - {cmt.date} - - - {cmt.time} - - - - - - - ))} - - {/* 마지막 요소 감지용 div */} - {hasMore && !isLoading &&
} - - {/* 스켈레톤 UI */} - {isLoading && ( - - {Array.from({ length: COMMENTS_PER_LOAD }).map((_, index) => ( - - ))} - - )} - - - )} - - ); -}; - -export default CommentView; - -const Comment = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 60px; - align-self: stretch; -` - -const CommentAdd = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 30px; - align-self: stretch; -` - -const Title = styled.div` - display: flex; - align-items: center; - align-self: stretch; - gap: 10px; -` - -const StyledInput = styled.input` - display: flex; - height: 72px; - padding: 23px 32px; - align-items: center; - align-self: stretch; - border-radius: 20px; - border: 2px solid ${({ theme }) => theme.colors.primary[400]}; - background: ${({ theme }) => theme.colors.text.white}; - - color: ${({ theme }) => theme.colors.text.gray}; - - font-family: ${({ theme }) => theme.fontFamily.regular}; - font-size: ${({ theme }) => theme.typography.body.small.size}; - font-weight: ${({ theme }) => theme.typography.body.small.weight}; - line-height: ${({ theme }) => theme.typography.body.small.lineHeight}; - letter-spacing: -0.48px; - - &:focus { - outline: none; - border-color: ${({ theme }) => theme.colors.primary[500]}; - } -` - -const CommentList = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 40px; - align-self: stretch; -` - -const CommentCard = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 32px; - align-self: stretch; -` - -const Author = styled.div` - display: flex; - align-items: center; - gap: 24px; -` - -const ProfileImg = styled.div` - width: 60px; - height: 60px; - border-radius: 30px; - background: #D9D9D9; -` - -const AuthorInfo = styled.div` - display: flex; - width: 125px; - flex-direction: column; - align-items: flex-start; - gap: 8px; -` - -const CommentDate = styled.div` - display: flex; - align-items: center; - gap: 8px; - align-self: stretch; -` - -const SkeletonWrapper = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 56px; -` \ No newline at end of file diff --git a/umc-master/src/pages/saveTip/DetailPage_componenets/PostDetail.tsx b/umc-master/src/pages/saveTip/DetailPage_componenets/PostDetail.tsx deleted file mode 100644 index e522288..0000000 --- a/umc-master/src/pages/saveTip/DetailPage_componenets/PostDetail.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import Typography from "@components/common/typography"; -import Tag from "@components/Tag/Tag"; -import { useEffect } from "react"; -import { useParams } from "react-router-dom"; -import styled, { useTheme } from "styled-components"; -import { saveTipDetailPageDataList } from "../dummydata/dummydata"; - - -const PostDetail: React.FC = () => { - - const { tipId } = useParams<{ tipId: string }>(); - - useEffect(() => { - window.scrollTo(0, 0); - }, []); - - // TODO: 추후 API 연동 시, 실제 데이터 불러오도록 수정 - const detail = saveTipDetailPageDataList.find((item) => item.id === tipId); - console.log(tipId); - - - if (!detail) { - return 해당 포스트를 찾을 수 없습니다.; - } - - const theme = useTheme(); - return ( - - - {detail.title} - - - - - - {detail.author} - - BEST 꿀팁 선정 횟수 - {detail.bestnum}회 - - - - - {detail.tags.map((tag, index) => ( - - ))} - - - - {detail.date} - {detail.time} - - - {detail.description} - - ); -}; - -export default PostDetail; - -const PostView = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 32px; - align-self: stretch; -` - -const Img = styled.div` - width: 1280px; - height: 360px; - border-radius: 20px; - background: #D9D9D9; -` - -const PostInfo = styled.div` - display: flex; - justify-content: space-between; - align-items: flex-start; - align-self: stretch; -` - -const InfoDetail = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 32px; -` - -const Author = styled.div` - display: flex; - justify-content: center; - align-items: center; - gap: 19px; -` - -const ProfileImg = styled.div` - width: 80px; - height: 80px; - border-radius: 50px; - background: #D9D9D9; -` - -const AuthorInfo = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; -` - -const Bestnum = styled.div` - display: flex; - align-items: center; - gap: 4px; -` - -const Tags = styled.div` - display: flex; - align-items: center; - gap: 12px; -` - -const PostDate = styled.div` - display: flex; - align-items: center; - gap: 8px; -` \ No newline at end of file diff --git a/umc-master/src/pages/saveTip/SaveTipDetailPage.tsx b/umc-master/src/pages/saveTip/SaveTipDetailPage.tsx index df88406..bd694ca 100644 --- a/umc-master/src/pages/saveTip/SaveTipDetailPage.tsx +++ b/umc-master/src/pages/saveTip/SaveTipDetailPage.tsx @@ -1,83 +1,33 @@ -import styled from "styled-components"; -import PostDetail from "./DetailPage_componenets/PostDetail"; -import CommentView from "./DetailPage_componenets/CommentView"; -import Likes from "@assets/savetipdetail/Likes.svg"; -import Liked from "@assets/savetipdetail/liked.svg"; -import Saves from "@assets/savetipdetail/saves.svg"; -import Saved from "@assets/savetipdetail/saved.svg"; -import Link from "@assets/savetipdetail/link.svg"; -import Typography from "@components/common/typography"; -import { dummyData } from "./dummydata/dummydata"; -import { useParams } from "react-router-dom"; -import theme from "@styles/theme"; -import { useState } from "react"; +import styled from 'styled-components'; +import { useParams } from 'react-router-dom'; +import PostDetail from './components/PostDetail'; +import CommentView from './components/CommentView'; +import FloatingToggleBtn from './components/FloatingToggleBtn'; +import { useTipDetail } from '@apis/queries/useTipDetailQuery'; const SaveTipDetailPage: React.FC = () => { - const { tipId } = useParams<{ tipId: string }>(); + const { data: detail, isLoading, error } = useTipDetail(Number(tipId)); + console.log('꿀팁 상세', detail); - const detail = dummyData.find((item) => item.id === tipId); - - if (!detail) { - return
데이터를 찾을 수 없습니다.
; - } - - const [likes, setLikes] = useState(detail.likes); - const [liked, setLiked] = useState(false); - - const handleLikeClick = () => { - if (!liked) { - setLikes(likes + 1); // 좋아요 수 증가 - setLiked(true); // 좋아요 상태로 변경 - } else { - setLikes(likes - 1); // 좋아요 취소 시 수 감소 - setLiked(false); // 좋아요 취소 상태로 변경 - } - }; - - const [saves, setSaves] = useState(detail.bookmarks); - const [saved, setSaved] = useState(false); + if (isLoading) return; + if (error) return
데이터를 불러오는 중 오류가 발생했습니다.
; + if (!detail) return
데이터를 찾을 수 없습니다.
; - const handleSaveClick = () => { - if (!saved) { - setSaves(saves + 1); // 좋아요 수 증가 - setSaved(true); // 좋아요 상태로 변경 - } else { - setSaves(saves - 1); // 좋아요 취소 시 수 감소 - setSaved(false); // 좋아요 취소 상태로 변경 - } - }; - return ( - - - - - - - - 좋아요 - {likes} - - - 저장하기 - {saves} - - - 공유하기 - 공유하기 - - + + + + + + ); }; @@ -92,45 +42,20 @@ const Container = styled.div` align-items: center; padding-top: 80px; padding-bottom: 100px; - background: #FFF; -` + background: #fff; +`; -const SaveTipDatail = styled.div` +const Content = styled.div` display: flex; - width: 1280px; + width: 80vw; flex-direction: column; justify-content: space-between; align-items: flex-start; gap: 60px; -` +`; const Line = styled.div` - width: 1280px; + width: 80vw; height: 1px; border: 1px solid ${({ theme }) => theme.colors.primary[800]}; -` - -const InteractionButtons = styled.div` - position: fixed; - right: 168px; - top: 610px; - width: 72px; - height: 366px; - display: flex; - flex-direction: column; - align-items: center; - gap: 36px; -` - -const Interaction = styled.div` - display: flex; - width: 72px; - flex-direction: column; - align-items: center; - gap: 7px; -` - -const Img = styled.img` - object-fit: cover; - cursor: pointer; -` \ No newline at end of file +`; diff --git a/umc-master/src/pages/saveTip/SaveTipPage.tsx b/umc-master/src/pages/saveTip/SaveTipPage.tsx index bf95543..2641d3b 100644 --- a/umc-master/src/pages/saveTip/SaveTipPage.tsx +++ b/umc-master/src/pages/saveTip/SaveTipPage.tsx @@ -1,37 +1,32 @@ -import Card from "@components/Card/Card"; -import Typography from "@components/common/typography"; -import styled, { useTheme } from "styled-components"; -import { dummyData as initialData } from "./dummydata/dummydata"; -import { useCallback, useEffect, useRef, useState } from "react"; -import SkeletonCard from "@components/Skeleton/SkeletonCard"; -import { useNavigate } from "react-router-dom"; +import Card from '@components/Card/Card'; +import Typography from '@components/common/typography'; +import styled, { useTheme } from 'styled-components'; +import { useCallback, useEffect, useRef } from 'react'; +import SkeletonCard from '@components/Skeleton/SkeletonCard'; +import { useNavigate } from 'react-router-dom'; +import { recentStore } from '@store/recentStore'; +import { useSaveTipList } from '@apis/queries/useSaveTipQueries'; const PAGE_SIZE = 5; +const placeholderImg = 'https://via.placeholder.com/150'; const SaveTipPage: React.FC = () => { - const theme = useTheme(); - - const [data, setData] = useState(initialData.slice(0, PAGE_SIZE * 6)); - const [isLoading, setIsLoading] = useState(false); - const [hasMore, setHasMore] = useState(initialData.length > PAGE_SIZE); + const { addRecentTip } = recentStore(); + const navigate = useNavigate(); const observerRef = useRef(null); const lastElementRef = useRef(null); - const loadMoreData = useCallback(() => { - if (isLoading || !hasMore) return; + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useSaveTipList(); - setIsLoading(true); - setTimeout(() => { - const nextData = initialData.slice(data.length, data.length + PAGE_SIZE); - setData((prevData) => [...prevData, ...nextData]); - setHasMore(data.length + PAGE_SIZE < initialData.length); - setIsLoading(false); - }, 1000); - }, [isLoading, hasMore, data.length]); + const loadMoreData = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); useEffect(() => { - if (isLoading || !hasMore) return; + if (!hasNextPage) return; if (observerRef.current) observerRef.current.disconnect(); @@ -44,46 +39,61 @@ const SaveTipPage: React.FC = () => { if (lastElementRef.current) observerRef.current.observe(lastElementRef.current); return () => observerRef.current?.disconnect(); - }, [isLoading, hasMore, loadMoreData]); + }, [hasNextPage, loadMoreData]); - const navigate = useNavigate(); // 추가 + const handleCardClick = (tipId: number) => { + console.log('🖱️ 클릭한 tipId:', tipId); + const clickedTip = data?.pages.flatMap((page) => page).find((item) => item.tipId === tipId); + console.log('🔍 찾은 팁 데이터:', clickedTip); - const handleCardClick = (id: string) => { - navigate(`/save-tip/${id}`); // 상세 페이지로 이동 + if (clickedTip) { + addRecentTip(clickedTip); + } + navigate(`/save-tip/${tipId}`); }; + if (isLoading) { + return; + } + return ( - 저장한 꿀팁 - {data.length === 0 && !isLoading ? ( + + 저장한 꿀팁 + + {data?.pages.length === 0 && !isFetchingNextPage ? ( 최근 본 꿀팁이 없습니다. ) : ( - {data.map((item) => ( - handleCardClick(item.id)} - /> - ))} - + {data?.pages + .flatMap((page) => page) + .filter(Boolean) + .map((item) => { + console.log('🔍 개별 아이템 확인:', item); + return ( + 0 + ? item.imageUrls[0]?.media_url + : placeholderImg + } + text={item.title} + likes={item.likes ?? 0} + bookmarks={item.bookmarks ?? 0} + date={item.createdAt.slice(0, 10)} + onClick={() => handleCardClick(item.tipId)} + /> + ); + })} + {/* 마지막 요소 감지용 div */} - {hasMore && !isLoading &&
} + {hasNextPage && !isFetchingNextPage &&
} {/* 스켈레톤 UI */} - {isLoading && - Array.from({ length: PAGE_SIZE }).map((_, index) => ( - - )) - } + {isFetchingNextPage && + Array.from({ length: PAGE_SIZE }).map((_, index) => )} )} @@ -101,8 +111,8 @@ const Container = styled.div` align-items: center; padding-top: 80px; padding-bottom: 100px; - background: #FFF; -` + background: #fff; +`; const SavedTips = styled.div` display: flex; @@ -110,7 +120,7 @@ const SavedTips = styled.div` flex-direction: column; align-items: center; gap: 40px; -` +`; const TipCardList = styled.div` display: flex; @@ -119,4 +129,4 @@ const TipCardList = styled.div` align-self: stretch; flex-wrap: wrap; cursor: pointer; -` \ No newline at end of file +`; diff --git a/umc-master/src/pages/saveTip/components/CommentView.tsx b/umc-master/src/pages/saveTip/components/CommentView.tsx new file mode 100644 index 0000000..20521d4 --- /dev/null +++ b/umc-master/src/pages/saveTip/components/CommentView.tsx @@ -0,0 +1,204 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { useTheme } from 'styled-components'; +import { useComments } from '@apis/queries/useCommentQueries'; +import { Comment, useAddComment, useDeleteComment, useUpdateComment } from '@apis/queries/useCommentMutations'; +import { useUserStore } from '@store/userStore'; +import Typography from '@components/common/typography'; +import SkeletonComment from '@components/Skeleton/SkeletonComment'; +import styled from 'styled-components'; +import ProfileDefault from '@assets/gray-character.png'; + +const COMMENTS_PER_LOAD = 3; + +const CommentView: React.FC = () => { + const theme = useTheme(); + const { tipId } = useParams<{ tipId: string }>(); + const { user } = useUserStore(); + + const { data: comments, isLoading, error } = useComments(Number(tipId)); + const addCommentMutation = useAddComment(Number(tipId)); + const deleteCommentMutation = useDeleteComment(Number(tipId)); + const updateCommentMutation = useUpdateComment(Number(tipId)); + + const [inputValue, setInputValue] = useState(''); + const [visibleComments, setVisibleComments] = useState(COMMENTS_PER_LOAD); + const [editMode, setEditMode] = useState<{ [key: number]: boolean }>({}); + const [editedComment, setEditedComment] = useState<{ [key: number]: string }>({}); + + const observerRef = useRef(null); + const lastElementRef = useRef(null); + + const handleAddComment = () => { + if (inputValue.trim().length === 0) return; + addCommentMutation.mutate(inputValue, { + onSuccess: () => setInputValue(''), + }); + }; + + const handleDeleteComment = (commentId: number) => { + deleteCommentMutation.mutate(commentId); + }; + + const handleEditToggle = (commentId: number, commentText: string) => { + setEditMode((prev) => ({ ...prev, [commentId]: true })); + setEditedComment((prev) => ({ ...prev, [commentId]: commentText })); + }; + + const handleEditSubmit = (commentId: number) => { + updateCommentMutation.mutate({ commentId, newComment: editedComment[commentId] }); + setEditMode((prev) => ({ ...prev, [commentId]: false })); + }; + + const loadMoreData = useCallback(() => { + setVisibleComments((prev) => prev + COMMENTS_PER_LOAD); + }, []); + + useEffect(() => { + if (!comments || comments.length <= visibleComments) return; + if (observerRef.current) observerRef.current.disconnect(); + + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + loadMoreData(); + } + }, + { threshold: 1.0 } + ); + + if (lastElementRef.current) observerRef.current.observe(lastElementRef.current); + + return () => observerRef.current?.disconnect(); + }, [comments, visibleComments, loadMoreData]); + + if (error) return 댓글을 불러오는 데 실패했습니다.; + + return ( + + + 댓글 ({comments?.length || 0}) + setInputValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddComment()} + /> + + + {isLoading + ? Array.from({ length: COMMENTS_PER_LOAD }).map((_, index) => ) + : comments.slice(0, visibleComments).map((cmt: Comment) => ( + + + + + {cmt.user.nickname} + + {new Date(cmt.created_at).toLocaleDateString()}{' '} + {new Date(cmt.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + + {user?.user_id === cmt.user.user_id && ( + + handleEditToggle(cmt.comment_id, cmt.comment)}>수정 + handleDeleteComment(cmt.comment_id)}>삭제 + + )} + + {editMode[cmt.comment_id] ? ( + setEditedComment({ ...editedComment, [cmt.comment_id]: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && handleEditSubmit(cmt.comment_id)} + /> + ) : ( + + {cmt.comment} + + )} + + ))} + {comments?.length > visibleComments &&
} + + + ); +}; + +export default CommentView; + +const CommentContainer = styled.div` + display: flex; + width: 100%; + flex-direction: column; + gap: 60px; +`; + +const CommentAdd = styled.div` + display: flex; + flex-direction: column; + gap: 40px; +`; + +const StyledInput = styled.input` + padding: 26px 32px; + border: 1px solid ${({ theme }) => theme.colors.primary[400]}; + border-radius: 20px; + font-size: 14px; +`; + +const CommentList = styled.div` + display: flex; + flex-direction: column; + gap: 40px; +`; + +const CommentCard = styled.div` + display: flex; + flex-direction: column; + padding-bottom: 10px; +`; + +const CommentHeader = styled.div` + display: flex; + align-items: center; + gap: 24px; +`; + +const ProfileImg = styled.img` + width: 60px; + height: 60px; + border-radius: 50%; +`; + +const CommentInfo = styled.div` + display: flex; + flex-direction: column; +`; + +const EditDelete = styled.div` + display: flex; + gap: 5px; + margin-left: auto; +`; + +const EditText = styled.span` + cursor: pointer; + font-size: 12px; +`; + +const DeleteText = styled.span` + cursor: pointer; + font-size: 12px; +`; + +const StyledEditInput = styled.input` + margin-top: 16px; + padding: 10px; + width: 100%; + border: 1px solid ${({ theme }) => theme.colors.primary[400]}; + border-radius: 5px; + background: #f7f7f7; +`; diff --git a/umc-master/src/pages/saveTip/components/FloatingToggleBtn.tsx b/umc-master/src/pages/saveTip/components/FloatingToggleBtn.tsx new file mode 100644 index 0000000..e554dc0 --- /dev/null +++ b/umc-master/src/pages/saveTip/components/FloatingToggleBtn.tsx @@ -0,0 +1,141 @@ +/* eslint-disable react/prop-types */ +import { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import theme from '@styles/theme'; +import Typography from '@components/common/typography'; +import Likes from '@assets/savetipdetail/Likes.svg'; +import Liked from '@assets/savetipdetail/liked.svg'; +import Saves from '@assets/savetipdetail/saves.svg'; +import Saved from '@assets/savetipdetail/saved.svg'; +import Link from '@assets/savetipdetail/link.svg'; +import { useToggleLike, useToggleBookmark } from '@apis/queries/useTipDetailMutations'; + +interface FloatingToggleBtnProps { + tipId: number; + initialLikes: number; + initialSaves: number; + userLiked: boolean; + userSaved: boolean; +} + +const FloatingToggleBtn: React.FC = ({ + tipId, + initialLikes, + initialSaves, + userLiked, + userSaved, +}) => { + const [likes, setLikes] = useState(initialLikes); + const [liked, setLiked] = useState(userLiked); + const [saves, setSaves] = useState(initialSaves); + const [saved, setSaved] = useState(userSaved); + + const { mutate: toggleLike } = useToggleLike(tipId); + const { mutate: toggleBookmark } = useToggleBookmark(tipId); + + const handleLikeClick = () => { + toggleLike(); + setLikes((prevLikes) => prevLikes + (liked ? -1 : 1)); + setLiked(!liked); + }; + + const handleSaveClick = () => { + toggleBookmark(); + setSaves((prevSaves) => prevSaves + (saved ? -1 : 1)); + setSaved(!saved); + }; + + const realUrl = 'https://umc-master-frontend.vercel.app'; // 실제 URL을 여기에 설정하세요 + // const realUrl = window.location.href; // 현재 보고 있는 페이지의 URL + const loadKakaoSDK = () => { + const script = document.createElement('script'); + script.src = 'https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js'; + script.integrity = import.meta.env.VITE_INTEGRITY_VALUE; // 환경 변수 사용 + script.crossOrigin = 'anonymous'; + script.onload = () => { + console.log('Kakao SDK 로드 완료'); + }; + document.head.appendChild(script); + }; + + loadKakaoSDK(); + + useEffect(() => { + if (!window.Kakao) return; + if (!window.Kakao.isInitialized()) { + window.Kakao.init(`${import.meta.env.VITE_JAVASCRIPT_KEY}`); // 여기에 카카오 앱 키를 넣어주세요 + } + }, []); + const shareKakao = () => { + if (!window.Kakao) { + console.error('Kakao SDK가 로드되지 않았습니다.'); + return; + } + window.Kakao.Share.sendDefault({ + objectType: 'feed', + content: { + title: '오늘의 꿀팁', + description: '오늘의 꿀팁을 보러 갈까요?', + imageUrl: 'https://mud-kage.kakao.com/dn/NTmhS/btqfEUdFAUf/FjKzkZsnoeE4o19klTOVI1/openlink_640x640s.jpg', + link: { + mobileWebUrl: realUrl, + }, + }, + buttons: [ + { + title: '나도 꿀팁 보러가기', + link: { + mobileWebUrl: realUrl, + }, + }, + ], + }); + }; + + return ( + + + + + {likes} + + + + + + {saves} + + + + + + 공유하기 + + + + ); +}; + +export default FloatingToggleBtn; + +const BtnContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + position: fixed; + right: 2%; + bottom: 5%; + gap: 26px; +`; + +const InteractionBtn = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; +`; + +const BtnImg = styled.img` + object-fit: cover; + cursor: pointer; +`; diff --git a/umc-master/src/pages/saveTip/components/PostDetail.tsx b/umc-master/src/pages/saveTip/components/PostDetail.tsx new file mode 100644 index 0000000..c9e1460 --- /dev/null +++ b/umc-master/src/pages/saveTip/components/PostDetail.tsx @@ -0,0 +1,166 @@ +/* eslint-disable react/prop-types */ +import { useEffect } from 'react'; +import styled from 'styled-components'; +import theme from '@styles/theme'; +import Typography from '@components/common/typography'; +import Tag from '@components/Tag/Tag'; +import ProfileDefault from '@assets/gray-character.png'; +interface Media { + mediaUrl: string; + mediaType: string; +} + +interface User { + userId: number; + nickname: string; + profileImageUrl: string | null; +} + +export interface TipItem { + tipId: number; + title: string; + content: string; + createdAt: string; + media: Media[]; + hashtags: []; + user: User; + likesCount: number; + savesCount: number; + isLiked: boolean; + isBookmarked: boolean; +} + +interface PostDetailProps { + detail: TipItem; +} + +const PostDetail: React.FC = ({ detail }) => { + useEffect(() => { + window.scrollTo(0, 0); + }, []); + + if (!detail) { + return 해당 포스트를 찾을 수 없습니다.; + } + + return ( + + {detail.media.length > 0 && 게시물 이미지} + + {detail.title} + + + + + + + + {detail.user.nickname} + + + + BEST 꿀팁 선정 횟수 + + + 0 회 + + + + + + {detail.hashtags.map((tag, index) => ( + + ))} + + + + + {detail.createdAt.slice(0, 10)} + + + + + + {detail.content} + + + + ); +}; + +export default PostDetail; + +const PostView = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 32px; + align-self: stretch; +`; + +const Img = styled.img` + width: 80vw; + height: 360px; + border-radius: 20px; + object-fit: cover; + background: #d9d9d9; +`; + +const PostInfo = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + align-self: stretch; +`; + +const InfoDetail = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 32px; +`; + +const Author = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 19px; +`; + +const ProfileImg = styled.img` + width: 80px; + height: 80px; + border-radius: 50px; + object-fit: cover; + background: #d9d9d9; +`; + +const AuthorInfo = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +`; + +const Bestnum = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +const Tags = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +const PostDate = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const ContentWrapper = styled.div` + white-space: pre-wrap; + word-wrap: break-word; +`; diff --git a/umc-master/src/store/recentStore.ts b/umc-master/src/store/recentStore.ts new file mode 100644 index 0000000..1430737 --- /dev/null +++ b/umc-master/src/store/recentStore.ts @@ -0,0 +1,70 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +interface Hashtag { + hashtagId: number; + name: string; +} + +interface Image { + media_url: string; + media_type: string; +} +interface Author { + userId: number; + nickname: string; + profileImageUrl: string | null; +} + +interface Tip { + tipId: number; + title: string; + content: string; + createdAt: string; + updatedAt: string; + hashtags: Hashtag[]; + imageUrls: Image[]; + likesCount: number; + savesCount: number; + author: Author; +} + +interface UserState { + recentTips: Tip[]; + addRecentTip: (tip: Tip) => void; + clearRecentTips: () => void; +} + +export const recentStore = create()( + persist( + (set) => ({ + recentTips: [], + addRecentTip: (tip) => { + set((state) => { + const newTips = [...state.recentTips]; + const existingTipIndex = newTips.findIndex((t) => t.tipId === tip.tipId); + + if (existingTipIndex !== -1) { + // 이미 있는 팁은 최신으로 업데이트 + newTips[existingTipIndex] = tip; + } else { + // 새로운 팁은 리스트의 맨 앞에 추가 + newTips.unshift(tip); + } + + // 최대 3개만 저장 + if (newTips.length > 3) { + newTips.pop(); + } + + return { recentTips: newTips }; + }); + }, + clearRecentTips: () => set({ recentTips: [] }), + }), + { + name: 'recent-tips-storage', // 로컬스토리지 키 이름 + storage: createJSONStorage(() => localStorage), + } + ) +); diff --git a/umc-master/src/store/userStore.ts b/umc-master/src/store/userStore.ts index 07f3980..069e578 100644 --- a/umc-master/src/store/userStore.ts +++ b/umc-master/src/store/userStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { getUsers } from '@apis/profileApi'; import axiosInstance from '@apis/axios-instance'; + interface User { user_id: number; email: string; @@ -27,6 +28,8 @@ interface ProfileUpdateData { interface UserState { user: User | null; + profileImageUrl: string; + setProfileImageUrl: (url: string) => void; fetchUser: () => Promise; updateProfile: (profileData: ProfileUpdateData) => Promise; clearUser: () => void; @@ -64,4 +67,7 @@ export const useUserStore = create((set) => ({ }, clearUser: () => set({ user: null }), + + profileImageUrl: "", + setProfileImageUrl: (url) => set({ profileImageUrl: url }), })); diff --git a/umc-master/yarn.lock b/umc-master/yarn.lock index a4868e1..cd60ad8 100644 --- a/umc-master/yarn.lock +++ b/umc-master/yarn.lock @@ -705,29 +705,29 @@ dependencies: "@swc/counter" "^0.1.3" -"@tanstack/query-core@5.65.0": - version "5.65.0" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.65.0.tgz#6b7c7087a36867361535b613ff39b633808052fd" - integrity sha512-Bnnq/1axf00r2grRT6gUyIkZRKzhHs+p4DijrCQ3wMlA3D3TTT71gtaSLtqnzGddj73/7X5JDGyjiSLdjvQN4w== +"@tanstack/query-core@5.66.3": + version "5.66.3" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.66.3.tgz#3ab0daa49477cfae38c45c02b8dc0bc39ec9cc5d" + integrity sha512-+2iDxH7UFdtwcry766aJszGmbByQDIzTltJ3oQAZF9bhCxHCIN3yDwHa6qDCZxcpMGvUphCRx/RYJvLbM8mucQ== "@tanstack/query-devtools@5.65.0": version "5.65.0" resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.65.0.tgz#37da5e911543b4f6d98b9a04369eab0de6044ba1" integrity sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg== -"@tanstack/react-query-devtools@^5.65.0": - version "5.65.0" - resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.65.0.tgz#951e8ddbe08b13ba0452c52f3a49af8764b4c2dd" - integrity sha512-xKoeWpHs6DcPqYmydIl+juWmZ2j4e4DaQEA/ju9PylhLI/X5eV5JG4IsI0ZrjtGwAEb4+lJv3SFwU/m6cmrljA== +"@tanstack/react-query-devtools@^5.66.3": + version "5.66.3" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.66.3.tgz#86ededd9567b92ef71dd060b66580960da88fdb2" + integrity sha512-ycICgTVQ2V6EEAXShOei8Ekxf+6IT6EQmwUgzEnJInZRTJZIcokOGB2Shp60Ky7sTAe1oeZD3tuky7gZg0gvyw== dependencies: "@tanstack/query-devtools" "5.65.0" -"@tanstack/react-query@^5.65.0": - version "5.65.0" - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.65.0.tgz#741b124ff78dd7c27cdb80cd3bd13f3972f819c1" - integrity sha512-qXdHj3SCT2xkFxgrBIe6y9Lkowlwm+tGcV++PBLFtyvEJR5Q+biTnzm5p0tdVwqA603xlju9mtV2Kd/2brobgA== +"@tanstack/react-query@^5.66.3": + version "5.66.3" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.66.3.tgz#287d37e2079291302b86ce2c5898f6a1a4aaeea9" + integrity sha512-sWMvxZ5VugPDgD1CzP7f0s9yFvjcXP3FXO5IVV2ndXlYqUCwykU8U69Kk05Qn5UvGRqB/gtj4J7vcTC6vtLHtQ== dependencies: - "@tanstack/query-core" "5.65.0" + "@tanstack/query-core" "5.66.3" "@types/cookie@^0.6.0": version "0.6.0"