수상 기록
- fileInputRef.current.click()}>
- {awardPreview ? (
-
+ !isUploadingAward && fileInputRef.current.click()}>
+ {isUploadingAward ? (
+
+ 업로드 중...
+
+ ) : awardPreview ? (
+
+
+ {
+ e.stopPropagation();
+ handleRemoveAwardImage();
+ }}
+ >
+ ×
+
+
) : (
- +
+
+ +
+
+ 수상 기록 이미지
+
+
)}
@@ -281,6 +723,7 @@ export default function PostWritePage() {
);
}
+// Styled Components
const Container = styled.div`
max-width: 768px;
margin: 0 auto;
@@ -306,12 +749,17 @@ const Title = styled.h2`
`;
const SubmitButton = styled.button`
- background-color: #235ba9;
+ background-color: ${props => props.disabled ? '#ccc' : '#235ba9'};
color: white;
padding: 8px 22px;
border: none;
border-radius: 8px;
- cursor: pointer;
+ cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
+ transition: background-color 0.2s;
+
+ &:hover:not(:disabled) {
+ background-color: #1e4a8c;
+ }
`;
const Input = styled.input`
@@ -397,6 +845,52 @@ const AwardUploadBox = styled.div`
overflow: hidden;
border-radius: 8px;
margin-bottom: 20px;
+ position: relative;
+`;
+
+const ImagePreviewContainer = styled.div`
+ width: 100%;
+ height: 100%;
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+`;
+
+const RemoveButton = styled.button`
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.7);
+ color: white;
+ border: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ font-weight: bold;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.9);
+ }
+`;
+
+const UploadingIndicator = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+`;
+
+const UploadPlaceholder = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
`;
const DirectInput = styled(Input)`
@@ -457,4 +951,4 @@ const DropdownItem = styled.button`
&:hover {
background: #f6f6f6;
}
-`;
\ No newline at end of file
+`;
diff --git a/src/pages/board/ReviewBoardPage.jsx b/src/pages/board/ReviewBoardPage.jsx
index 5371e91..aff5556 100644
--- a/src/pages/board/ReviewBoardPage.jsx
+++ b/src/pages/board/ReviewBoardPage.jsx
@@ -9,114 +9,12 @@ import BoardNav from "../../layout/board/BoardNav";
import BoardSidebar from "../../layout/board/BoardSideNav";
import ReviewBoardGuide from "../../components/board/reviewboard/ReviewBoardGuide";
import Footer from "../../layout/Footer";
-import usePagination from "../../hooks/usePagination";
import Pagination from "../../components/common/Pagination";
import CustomDropdown from "../../components/common/CustomDropdown";
import { useNavigate } from "react-router-dom";
-
-export const reviews = [
- {
- id: 1,
- category: "환경",
- image: SampleReviewImg,
- title: "국제수면산업박람회 아이디어 공모전 시상식 후기!",
- content: "국제수면산업 박람회에 참가해서 영예의 대상을 수상했어요! 새롭고 흥미로운 아이디어를 나눌 수 있어서 정말 즐거운 경험이었답니다. 좋은 사람들과 함께한 뜻깊은 시간이었고 서로의 아이디어를 존중하며 소통할 수 있었던 현장이었어요. 다양한 분야의 전문가들과 인사이트를 주고받으며 수면 산업의 무한한 가능성을 다시금 느낄 수 있었고요.",
- date: "2025.04.11",
- writer: "이서정",
- likeCount: 20,
- },
- {
- id: 2,
- category: "경제",
- image: SampleReviewImg,
- title: "창업 공모전 참가 후기",
- content:
- "국제수면산업 박람회에 참가해서 영예의 대상을 수상했어요! 새롭고 흥미로운 아이디어를 나눌 수 있어서 정말 즐거운 경험이었습니다. 좋은 사람들과 함께한 뜻깊은 자리였어요.",
- date: "2025.04.10",
- writer: "김지민",
- likeCount: 12,
- },
- {
- id: 3,
- category: "사람과 사회",
- image: SampleReviewImg,
- title: "봉사활동 후기",
- content: "지역 아동센터에서 봉사한 뜻깊은 경험을 나눕니다.",
- date: "2025.04.08",
- writer: "박은서",
- likeCount: 8,
- },
- {
- id: 4,
- category: "기술",
- image: SampleReviewImg,
- title: "기술공모전 참가 후기",
- content: "사물인터넷 아이디어로 본선 진출한 후기를 공유합니다.",
- date: "2025.04.06",
- writer: "최현우",
- likeCount: 17,
- },
- {
- id: 5,
- category: "환경",
- image: SampleReviewImg,
- title: "플로깅 캠페인 참여 후기",
- content: "쓰레기를 줍는 재미와 성취감을 느꼈던 하루였습니다.",
- date: "2025.04.05",
- writer: "이수진",
- likeCount: 6,
- },
- {
- id: 6,
- category: "경제",
- image: SampleReviewImg,
- title: "스타트업 투자 피칭 후기",
- content: "투자자 앞에서 발표했던 경험을 자세히 적었습니다.",
- date: "2025.04.04",
- writer: "홍진호",
- likeCount: 11,
- },
- {
- id: 7,
- category: "사람과 사회",
- image: SampleReviewImg,
- title: "지역 사회 자원봉사 후기",
- content: "노인복지회관에서 활동한 따뜻한 이야기를 담았습니다.",
- date: "2025.04.02",
- writer: "유지연",
- likeCount: 9,
- },
- {
- id: 8,
- category: "기술",
- image: SampleReviewImg,
- title: "AI 해커톤 참가 후기",
- content: "챗봇을 개발한 경험을 정리한 후기를 공유합니다.",
- date: "2025.04.01",
- writer: "정우진",
- likeCount: 14,
- },
- {
- id: 9,
- category: "환경",
- image: SampleReviewImg,
- title: "제로웨이스트 캠페인 체험기",
- content: "일회용품 없이 생활해본 도전기를 작성했습니다.",
- date: "2025.03.30",
- writer: "박채원",
- likeCount: 7,
- },
- {
- id: 10,
- category: "경제",
- image: SampleReviewImg,
- title: "청년 창업 페어 부스 운영 후기",
- content: "부스 운영을 통해 고객과 직접 소통한 생생한 이야기입니다.",
- date: "2025.03.28",
- writer: "정하늘",
- likeCount: 13,
- },
-];
+import { getReviews } from "../../api/PostApi";
+import { CATEGORY_MAP, ACTIVITY_TYPE_MAP } from "../../api/PostApi";
+import { useReviewLikeStore } from "../../store/reviewLikeStore"; // 추가
export default function ReviewBoardPage() {
const TypeCategories = ["전체", "공모전", "봉사활동", "서포터즈", "인턴십"];
@@ -124,8 +22,14 @@ export default function ReviewBoardPage() {
const [selectedTypeCategory, setSelectedTypeCategory] = useState("전체");
const [sortOrder, setSortOrder] = useState("최신순");
const [isGuideOpen, setIsGuideOpen] = useState(false);
+ const [reviews, setReviews] = useState([]);
+ const [totalPages, setTotalPages] = useState(0);
+ const [currentPage, setCurrentPage] = useState(1);
const navigate = useNavigate();
+ // Zustand 스토어에서 좋아요 상태 관리 함수들 가져오기
+ const { setBulkLikes } = useReviewLikeStore();
+
// HelpWrapper 전체를 감싸는 ref
const helpWrapperRef = useRef(null);
@@ -140,23 +44,69 @@ export default function ReviewBoardPage() {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
- // 필터링·정렬·페이지네이션
- const filteredReviews =
- selectedCategory === "전체"
- ? reviews
- : reviews.filter((r) => r.category === selectedCategory);
+ // 리뷰 데이터 가져오기
+ useEffect(() => {
+ const fetchReviews = async () => {
+ try {
+ const keyword = selectedCategory === "전체" ? null : CATEGORY_MAP[selectedCategory];
+ const activityType = selectedTypeCategory === "전체" ? null : ACTIVITY_TYPE_MAP[selectedTypeCategory];
+ const sort = sortOrder === "최신순" ? "RECENT" : "LIKES";
+
+ console.log('정렬 기준:', {
+ sortOrder,
+ sort
+ });
+
+ // API는 0부터 시작하므로 currentPage - 1을 전달
+ const result = await getReviews(currentPage - 1, keyword, activityType, sort);
+
+ // 리뷰 데이터 설정
+ setReviews(result.content);
+ setTotalPages(result.totalPages);
+
+ // Zustand 스토어에 좋아요 상태 일괄 설정
+ if (result.content && result.content.length > 0) {
+ console.log('리뷰 리스트 좋아요 상태 일괄 설정:', result.content.map(review => ({
+ id: review.id,
+ liked: review.liked || false,
+ likeCount: review.likeCount || 0
+ })));
+
+ setBulkLikes(result.content.map(review => ({
+ id: review.id,
+ liked: review.liked || false,
+ likeCount: review.likeCount || 0
+ })));
+ }
+
+ } catch (error) {
+ console.error('리뷰 조회 실패:', error);
+ setReviews([]);
+ }
+ };
+
+ fetchReviews();
+ }, [selectedCategory, selectedTypeCategory, sortOrder, currentPage, setBulkLikes]);
- const sortedReviews = [...filteredReviews].sort((a, b) => {
- if (sortOrder === "최신순") return new Date(b.date) - new Date(a.date);
- if (sortOrder === "추천순") return b.likeCount - a.likeCount;
- return 0;
- });
+ const handlePageChange = (page) => {
+ setCurrentPage(page);
+ };
- const itemsPerPage = 9;
- const { currentPage, totalPages, currentData, goToPage } = usePagination(
- sortedReviews,
- itemsPerPage
- );
+ const handleTypeCategoryChange = (type) => {
+ setSelectedTypeCategory(type);
+ setCurrentPage(1);
+ };
+
+ const handleCategoryChange = (category) => {
+ setSelectedCategory(category);
+ setCurrentPage(1);
+ };
+
+ const handleSortChange = (sort) => {
+ console.log('정렬 변경:', sort);
+ setSortOrder(sort);
+ setCurrentPage(1);
+ };
return (
<>
@@ -182,7 +132,7 @@ export default function ReviewBoardPage() {
@@ -193,12 +143,12 @@ export default function ReviewBoardPage() {
navigate("/board/write")}>
@@ -207,24 +157,43 @@ export default function ReviewBoardPage() {
- {currentData.map((review) => (
-
- ))}
+ {reviews.length > 0 ? (
+ reviews.map((review) => (
+
+ ))
+ ) : (
+
+ 등록된 후기가 없습니다.
+
+ )}
-
+ {totalPages > 0 && (
+
+ )}
>
);
}
-// styled-components (변경 없음)
+// styled-components
const HeaderSection = styled.div`
background-color: #f9fbff;
text-align: center;
@@ -300,6 +269,10 @@ const WriteButton = styled.button`
align-items: center;
gap: 8px;
cursor: pointer;
+
+ &:hover {
+ background-color: #1a4a8a;
+ }
`;
const WriteIcon = styled.img`
@@ -312,4 +285,16 @@ const CardGrid = styled.div`
grid-template-columns: repeat(3, 1fr);
gap: 24px;
padding: 24px 0;
+ min-height: 400px;
+`;
+
+const NoReviewsMessage = styled.div`
+ grid-column: 1 / -1;
+ text-align: center;
+ padding: 60px 20px;
+ font-size: 18px;
+ color: #666;
+ background-color: #f8f9fa;
+ border-radius: 12px;
+ margin: 20px 0;
`;
diff --git a/src/pages/board/ReviewEditPage.jsx b/src/pages/board/ReviewEditPage.jsx
new file mode 100644
index 0000000..2a6c192
--- /dev/null
+++ b/src/pages/board/ReviewEditPage.jsx
@@ -0,0 +1,873 @@
+import React, { useState, useRef, useEffect } from "react";
+import styled from "styled-components";
+import { EditorContent, useEditor } from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import Underline from "@tiptap/extension-underline";
+import Image from "@tiptap/extension-image";
+import TextAlign from "@tiptap/extension-text-align";
+import TextStyle from "@tiptap/extension-text-style";
+import Color from "@tiptap/extension-color";
+import ToolBar from "../../components/board/ToolBar";
+import BoardNav from "../../layout/board/BoardNav";
+import CustomDropdown from "../../components/common/CustomDropdown";
+import MonthPicker from "../../components/common/CustomMonthPicker";
+import ImageAlertModal from "../../components/board/ImageAlertModal";
+import PointAlertModal from "../../components/board/PointAlertModal";
+import NotPointAlertModal from "../../components/board/NotPointAlertModal";
+import AwardNotVerifiedModal from "../../components/board/NotAwardModal";
+import AwardAlertModal from "../../components/board/NotAllAlertModal";
+import { useReview } from "../../query/usePost";
+import {
+ extractImageUrls,
+ CATEGORY_MAP,
+ ACTIVITY_TYPE_MAP,
+ ACTIVITY_PERIOD_MAP,
+ uploadSingleImage,
+ updateReview
+} from "../../api/PostApi";
+import { useNavigate, useParams } from "react-router-dom";
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+export default function ReviewEditPage() {
+ const navigate = useNavigate();
+ const { id } = useParams();
+ const queryClient = useQueryClient();
+
+ const [title, setTitle] = useState("");
+ const [activityName, setActivityName] = useState("");
+ const [activityPeriod, setActivityPeriod] = useState("");
+ const [activityEndDate, setActivityEndDate] = useState("");
+ const [category, setCategory] = useState("");
+ const [type, setType] = useState("");
+ const [awardPreview, setAwardPreview] = useState(null);
+ const [awardImageUrl, setAwardImageUrl] = useState('');
+ const [isUploadingAward, setIsUploadingAward] = useState(false);
+ const [errors, setErrors] = useState({});
+ const [showImageAlert, setShowImageAlert] = useState(false);
+ const [showPointAlert, setShowPointAlert] = useState(false);
+ const [showNotPointAlert, setShowNotPointAlert] = useState(false);
+ const [showNotAwardAlert, setShowNotAwardAlert] = useState(false);
+ const [showNotAllAlert, setShowNotAllAlert] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isDataLoaded, setIsDataLoaded] = useState(false);
+
+ // 중복 제출 방지를 위한 ref
+ const submitLockRef = useRef(false);
+
+ // 기존 데이터 불러오기
+ const {
+ data: reviewData,
+ isLoading: isReviewLoading,
+ isError: isReviewError
+ } = useReview(id);
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Underline,
+ Image,
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
+ TextStyle,
+ Color,
+ ],
+ content: "",
+ });
+
+ const fileInputRef = useRef(null);
+ const editorRef = useRef(null);
+
+ // 모달 상태 초기화 함수
+ const resetModalStates = () => {
+ setShowImageAlert(false);
+ setShowPointAlert(false);
+ setShowNotPointAlert(false);
+ setShowNotAwardAlert(false);
+ setShowNotAllAlert(false);
+ };
+
+ // 서버 응답에 따른 모달 표시 함수
+ const handleReviewResponse = (result) => {
+ console.log('=== 후기 수정 서버 응답 분석 시작 ===');
+ console.log('전체 서버 응답:', result);
+ console.log('result.result 존재 여부:', !!result?.result);
+
+ // 서버 응답 구조 확인 (result.result에 실제 데이터가 있을 수 있음)
+ const actualData = result?.result || result;
+ console.log('실제 데이터:', actualData);
+
+ // 응답에서 OCR 결과와 수상기록 검증 상태 확인 (실제 서버 응답 필드명 사용)
+ const ocrResult = actualData?.ocrResult; // 이미지 검증 결과
+ const awardResult = actualData?.awardOcrResult; // 수상기록 검증 결과
+
+ console.log('검증 결과:', { ocrResult, awardResult });
+ console.log('ocrResult - 타입:', typeof ocrResult, '값:', ocrResult, '엄격 비교 true:', ocrResult === true);
+ console.log('awardResult - 타입:', typeof awardResult, '값:', awardResult, '엄격 비교 true:', awardResult === true, '엄격 비교 null:', awardResult === null);
+
+ // 명확한 검증 로직
+ console.log('=== 조건 검사 시작 ===');
+
+ const condition1 = ocrResult === false && awardResult === false;
+ const condition2 = ocrResult === false && awardResult !== false; // 이미지만 실패
+ const condition3 = awardResult === false && ocrResult !== false; // 수상기록만 실패
+ const condition4 = ocrResult === true && awardResult === null;
+ const condition5 = ocrResult === true && awardResult === true;
+
+ console.log('조건 1 (모든 자료 실패):', condition1);
+ console.log('조건 2 (이미지 실패):', condition2);
+ console.log('조건 3 (수상기록 실패):', condition3);
+ console.log('조건 4 (성공 - null):', condition4);
+ console.log('조건 5 (성공 - true):', condition5);
+
+ if (condition1) {
+ // 수상기록 false & ocrResult false → 모든 자료 검증 실패
+ console.log('✅ 조건 1 실행: 모든 자료 검증 실패');
+ setShowNotAllAlert(true);
+ } else if (condition2) {
+ // ocrResult가 false → 이미지 검증 실패
+ console.log('✅ 조건 2 실행: 이미지 검증 실패');
+ setShowNotPointAlert(true);
+ } else if (condition3) {
+ // 수상기록이 false → 수상기록 검증 실패
+ console.log('✅ 조건 3 실행: 수상기록 검증 실패');
+ setShowNotAwardAlert(true);
+ } else if (condition4) {
+ // 수상기록 null & ocrResult true → 검증 성공
+ console.log('✅ 조건 4 실행: 검증 성공 (ocrResult: true, awardResult: null)');
+ setShowPointAlert(true);
+ } else if (condition5) {
+ // 수상기록 true & ocrResult true → 검증 성공
+ console.log('✅ 조건 5 실행: 검증 성공 (ocrResult: true, awardResult: true)');
+ setShowPointAlert(true);
+ } else {
+ // 기타 경우는 실패로 처리
+ console.log('✅ 조건 6 실행: 기타 경우 - 실패로 처리');
+ console.log('ocrResult:', ocrResult, 'awardResult:', awardResult);
+ setShowNotPointAlert(true);
+ }
+
+ console.log('=== 조건 검사 완료 ===');
+ };
+
+ // 수정 API 호출을 위한 mutation
+ const updateReviewMutation = useMutation({
+ mutationFn: ({ reviewId, reviewData }) => updateReview(reviewId, reviewData),
+ onSuccess: (result) => {
+ queryClient.invalidateQueries(['review', id]);
+ queryClient.invalidateQueries(['reviews']);
+
+ // 서버 응답에 따른 모달 표시
+ handleReviewResponse(result);
+ },
+ onError: (error) => {
+ console.error('리뷰 수정 실패:', error);
+
+ let errorMessage = '게시물 수정에 실패했습니다.';
+
+ if (error.response?.data) {
+ const serverError = error.response.data;
+ if (serverError.detail) {
+ errorMessage += `\n상세: ${serverError.detail}`;
+ }
+ if (serverError.title) {
+ errorMessage += `\n오류: ${serverError.title}`;
+ }
+ } else if (error.message) {
+ errorMessage += `\n${error.message}`;
+ }
+
+ alert(errorMessage);
+ }
+ });
+
+ useEffect(() => {
+ // 로그인 상태 확인
+ const token = localStorage.getItem('token');
+ const userId = localStorage.getItem('userId');
+
+ if (!token || !userId) {
+ alert('로그인이 필요한 서비스입니다.');
+ navigate('/login');
+ return;
+ }
+
+ // 게시물 데이터가 로드되면 에디터에 설정
+ if (reviewData && editor && !isDataLoaded) {
+ console.log('기존 리뷰 데이터:', reviewData);
+
+ // 작성자 확인 - PostEditPage와 동일한 방식
+ if (String(reviewData.userId) !== String(userId)) {
+ alert('수정 권한이 없습니다.');
+ navigate(`/board/review/${id}`);
+ return;
+ }
+
+ setTitle(reviewData.title || "");
+ setActivityName(reviewData.activityName || "");
+ setCategory(reviewData.keyword || "");
+ setType(reviewData.activityType || "");
+ setActivityPeriod(reviewData.activityPeriod || "");
+
+ // 활동 종료일 설정
+ if (reviewData.activityEndDate) {
+ // 서버에서 받은 날짜를 YYYY.MM 형태로 변환
+ const date = new Date(reviewData.activityEndDate);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ setActivityEndDate(`${year}.${month}`);
+ }
+
+ // 에디터 내용 설정
+ if (reviewData.content) {
+ editor.commands.setContent(reviewData.content);
+ }
+
+ // 수상 기록 이미지 설정
+ if (reviewData.awardImageUrl) {
+ setAwardImageUrl(reviewData.awardImageUrl);
+ setAwardPreview(reviewData.awardImageUrl);
+ }
+
+ setIsDataLoaded(true);
+ console.log('리뷰 데이터 로드 완료:', reviewData);
+ }
+ }, [reviewData, editor, navigate, id, isDataLoaded]);
+
+ // 로딩 상태 체크
+ useEffect(() => {
+ if (!isReviewLoading && !reviewData) {
+ alert('게시물을 찾을 수 없습니다.');
+ navigate('/board/review');
+ }
+ }, [isReviewLoading, reviewData, navigate]);
+
+ const insertImage = (src) => {
+ if (!editor) return;
+
+ let imgCount = 0;
+ editor.state.doc.descendants((node) => {
+ if (node.type.name === 'image') imgCount += 1;
+ });
+
+ if (imgCount >= 5) {
+ alert('이미지는 최대 5개까지만 업로드할 수 있습니다.');
+ return;
+ }
+
+ editor
+ .chain()
+ .focus()
+ .setImage({ src })
+ .createParagraphNear()
+ .focus()
+ .run();
+ };
+
+ const handleAwardImageChange = async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ if (!file.type.startsWith('image/')) {
+ alert('이미지 파일만 업로드 가능합니다.');
+ return;
+ }
+
+ try {
+ setIsUploadingAward(true);
+ setAwardPreview(URL.createObjectURL(file));
+ const uploadedUrl = await uploadSingleImage(file);
+ setAwardImageUrl(uploadedUrl);
+ console.log('수상 기록 이미지 업로드 완료:', uploadedUrl);
+ } catch (error) {
+ console.error('수상 기록 이미지 업로드 실패:', error);
+ alert('이미지 업로드에 실패했습니다: ' + error.message);
+ setAwardPreview(null);
+ setAwardImageUrl('');
+ } finally {
+ setIsUploadingAward(false);
+ }
+ };
+
+ const handleRemoveAwardImage = () => {
+ setAwardPreview(null);
+ setAwardImageUrl('');
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ };
+
+ const validateForm = () => {
+ const newErrors = {};
+
+ if (!activityName.trim()) newErrors.activityName = "대외활동 이름을 입력해주세요.";
+ if (!category) newErrors.category = "분야 카테고리를 선택해주세요.";
+ if (!type) newErrors.type = "유형 카테고리를 선택해주세요.";
+ if (!activityPeriod) newErrors.activityPeriod = "활동 기간을 선택해주세요.";
+
+ // 매핑 검증 추가
+ if (category && !CATEGORY_MAP[category]) {
+ newErrors.category = "유효하지 않은 카테고리입니다.";
+ }
+ if (type && !ACTIVITY_TYPE_MAP[type]) {
+ newErrors.type = "유효하지 않은 활동 유형입니다.";
+ }
+ if (activityPeriod && !ACTIVITY_PERIOD_MAP[activityPeriod]) {
+ newErrors.activityPeriod = "유효하지 않은 활동 기간입니다.";
+ }
+
+ if (!title.trim()) newErrors.title = "제목을 입력해주세요.";
+ if (!editor?.getText().trim()) newErrors.content = "본문을 입력해주세요.";
+
+ setErrors(newErrors);
+
+ // 디버깅용 로그
+ if (Object.keys(newErrors).length > 0) {
+ console.log('유효성 검사 실패:', newErrors);
+ }
+
+ const isOnlyContentError = Object.keys(newErrors).length === 1 && newErrors.content;
+
+ if (isOnlyContentError && editorRef.current) {
+ setTimeout(() => {
+ editorRef.current.scrollIntoView({ behavior: "smooth" });
+ }, 100);
+ }
+
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async () => {
+ // 1차 방어: submitLock ref로 중복 실행 방지
+ if (submitLockRef.current) {
+ console.log('이미 제출 중입니다. (submitLock)');
+ return;
+ }
+
+ // 2차 방어: isSubmitting 상태로 중복 실행 방지
+ if (isSubmitting) {
+ console.log('이미 제출 중입니다. (isSubmitting)');
+ return;
+ }
+
+ console.log('수정 시작 - isSubmitting:', isSubmitting);
+
+ // 즉시 락 설정
+ submitLockRef.current = true;
+
+ // 이미지 검증
+ const hasEditorImage = editor?.getHTML().includes('
![]()
{
+ setIsSubmitting(false);
+ console.log('수정 상태 해제 완료');
+ }, 500);
+ }
+ };
+
+ // 다시 제출하기 핸들러 - 수정 페이지에서 계속 수정
+ const handleResubmit = () => {
+ resetModalStates();
+ // 수정 페이지이므로 모달만 닫고 현재 페이지에서 계속 수정 가능
+ };
+
+ // 모달 확인 버튼 핸들러 (성공 시 상세 페이지로 이동)
+ const handleModalConfirm = () => {
+ resetModalStates();
+ navigate(`/board/review/${id}`, { replace: true });
+ };
+
+ // 로딩 중일 때
+ if (isReviewLoading) {
+ return (
+ <>
+
+
+ 게시물을 불러오는 중...
+
+ >
+ );
+ }
+
+ // 에러 발생 시
+ if (isReviewError) {
+ return (
+ <>
+
+
+ 게시물을 불러오는데 실패했습니다.
+
+ >
+ );
+ }
+
+ // 게시물이 없을 때
+ if (!reviewData) {
+ return (
+ <>
+
+
+ 게시물을 찾을 수 없습니다.
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ {showImageAlert &&
setShowImageAlert(false)} />}
+ {showPointAlert && }
+ {showNotPointAlert && (
+
+ )}
+ {showNotAwardAlert && (
+
+ )}
+ {showNotAllAlert && (
+
+ )}
+
+
+
+
+ 후기 글 수정
+
+
+ {(isSubmitting || updateReviewMutation.isPending) ? '수정 중...' : '수정 완료'}
+
+
+
+
+
+
+ 대외활동 이름 *
+
+ {errors.activityName && {errors.activityName}}
+ setActivityName(e.target.value)}
+ readOnly
+ style={{ backgroundColor: '#f5f5f5', cursor: 'not-allowed' }}
+ />
+ * 활동 이름은 수정할 수 없습니다.
+
+
+
+
+
분야 카테고리 선택 *
+ {errors.category &&
{errors.category}}
+
+ {["환경", "사람과 사회", "경제", "기술"].map((cat) => (
+
+ ))}
+
+
* 분야 카테고리는 수정할 수 없습니다.
+
+
유형 카테고리 선택 *
+ {errors.type &&
{errors.type}}
+
+ {["공모전", "봉사활동", "인턴십", "서포터즈"].map((typeItem) => (
+
+ ))}
+
+
* 유형 카테고리는 수정할 수 없습니다.
+
+
+
+ 활동 기간 선택 *
+ {}}
+ placeholder="활동 기간"
+ borderColor="#ccc"
+ placeholderColor="#aaa"
+ height="22px"
+ disabled
+ />
+ {errors.activityPeriod && {errors.activityPeriod}}
+ * 활동 기간은 수정할 수 없습니다.
+
+
+
+ 활동 종료일 선택
+ {}}
+ placeholder="활동 종료일"
+ borderColor="#ccc"
+ placeholderColor="#aaa"
+ height="22px"
+ disabled
+ />
+ * 활동 종료일은 수정할 수 없습니다.
+
+
+
+
+
+
+ 수상 기록
+ !isUploadingAward && fileInputRef.current.click()}>
+ {isUploadingAward ? (
+
+ 업로드 중...
+
+ ) : awardPreview ? (
+
+
+ {
+ e.stopPropagation();
+ handleRemoveAwardImage();
+ }}
+ >
+ ×
+
+
+ ) : (
+
+ +
+
+ 수상 기록 이미지
+
+
+ )}
+
+
+
+
+
+
+
+ setTitle(e.target.value)}
+ />
+ {errors.title && {errors.title}}
+
+
+ {editor && }
+
+
+ {errors.content && {errors.content}}
+
+ >
+ );
+}
+
+// Styled Components
+const Container = styled.div`
+ max-width: 768px;
+ margin: 0 auto;
+ padding: 24px;
+`;
+
+const Header = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+const TitleRow = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: relative;
+`;
+
+const Title = styled.h2`
+ font-weight: bold;
+ font-size: 35px;
+`;
+
+const SubmitButton = styled.button`
+ background-color: ${props => props.disabled ? '#ccc' : '#235ba9'};
+ color: white;
+ padding: 8px 22px;
+ border: none;
+ border-radius: 8px;
+ cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
+ transition: background-color 0.2s;
+
+ &:hover:not(:disabled) {
+ background-color: #1e4a8c;
+ }
+`;
+
+const Input = styled.input`
+ padding: 12px;
+ font-size: 14px;
+ border: 1px solid #235ba9;
+ border-radius: 6px;
+ width: 97%;
+ margin-bottom: 8px;
+ height: 22px;
+
+ &::placeholder {
+ color: #aaa;
+ font-size: 14px;
+ }
+`;
+
+const ActivityInput = styled(Input)`
+ &:read-only {
+ background-color: #f5f5f5;
+ cursor: not-allowed;
+ color: #666;
+ }
+`;
+
+const ErrorText = styled.div`
+ color: red;
+ font-size: 11px;
+ margin: 4px 0 4px;
+`;
+
+const SubTitle = styled.div`
+ font-weight: 600;
+ margin: 12px 0 8px;
+`;
+
+const CheckboxGroup = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ margin-bottom: 20px;
+
+ label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+
+ input[type="checkbox"]:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+`;
+
+const ReadOnlyNotice = styled.div`
+ font-size: 8.7px;
+ color: #666;
+ margin-bottom: 16px;
+ margin-top: 5px;
+ font-style: italic;
+`;
+
+const Row = styled.div`
+ display: flex;
+ gap: 12px;
+`;
+
+const EditorWrapper = styled.div`
+ border: 1px solid #235ba9;
+ border-radius: 6px;
+ padding: 12px;
+ min-height: 800px;
+ margin-top: 16px;
+ width: 97%;
+
+ .ProseMirror {
+ min-height: 200px;
+ font-size: 16px;
+ outline: none;
+ }
+
+ img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin: 0 auto;
+ }
+`;
+
+const ActivityNameSection = styled.div`
+ grid-column: 1 / 2;
+`;
+
+const AwardSection = styled.div`
+ grid-column: 2 / 3;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+
+const AwardUploadBox = styled.div`
+ width: 90%;
+ height: 190px;
+ border: 1px solid #235ba9;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ background: #fff;
+ overflow: hidden;
+ border-radius: 8px;
+ margin-bottom: 20px;
+ position: relative;
+`;
+
+const ImagePreviewContainer = styled.div`
+ width: 100%;
+ height: 100%;
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+`;
+
+const RemoveButton = styled.button`
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.7);
+ color: white;
+ border: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ font-weight: bold;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.9);
+ }
+`;
+
+const UploadingIndicator = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+`;
+
+const UploadPlaceholder = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+`;
+
+const Divider = styled.hr`
+ border: none;
+ border-top: 1.5px solid #d9d9d9;
+ margin: 16px 0;
+ width: 100%;
+`;
+
+const LoadingMessage = styled.div`
+ text-align: center;
+ padding: 40px;
+ font-size: 16px;
+ color: #666;
+`;
+
+const ErrorMessage = styled.div`
+ text-align: center;
+ padding: 40px;
+ font-size: 16px;
+ color: #ff4444;
+`;
\ No newline at end of file
diff --git a/src/pages/issue/GlobalIssueDetailPage.jsx b/src/pages/issue/GlobalIssueDetailPage.jsx
index 7271791..8fa08b8 100644
--- a/src/pages/issue/GlobalIssueDetailPage.jsx
+++ b/src/pages/issue/GlobalIssueDetailPage.jsx
@@ -1,149 +1,195 @@
-import React, { useEffect, useState } from 'react';
+import React from 'react';
import styled from 'styled-components';
-import { useLocation, useNavigate } from 'react-router-dom';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useQueryClient } from '@tanstack/react-query';
import MainNav from '../../layout/MainNav';
import Footer from '../../layout/Footer';
-import ActivityCard from '../../components/activity/ActivityCard';
-import issueCardSample from '../../assets/images/issue/ic_IssueCardSample.png';
import linkIcon from '../../assets/images/issue/ic_Link.png';
-import bookmarkButton from '../../assets/images/common/BookmarkButton.png';
-import bookmarkFilledButton from '../../assets/images/common/BookmarkFilledButton.png';
-
-const dummyActivities = [
- {
- id: 1,
- title: '제 22회 한국 경제 논문 공모전',
- tags: ['#경제', '#공모전'],
- date: '2025.04.15~2025.04.20',
- image: issueCardSample,
- bookmarked: false,
- },
- {
- id: 2,
- title: '경제 공모전',
- tags: ['#경제', '#공모전'],
- date: '2025.04.15~2025.04.20',
- image: issueCardSample,
- bookmarked: false,
- },
- {
- id: 3,
- title: '경제 봉사활동',
- tags: ['#경제', '#봉사활동'],
- date: '2025.04.15~2025.04.20',
- image: issueCardSample,
- bookmarked: false,
- },
- {
- id: 4,
- title: '경제 서포터즈',
- tags: ['#경제', '#서포터즈'],
- date: '2025.04.15~2025.04.20',
- image: issueCardSample,
- bookmarked: false,
- },
- {
- id: 5,
- title: '환경 서포터즈',
- tags: ['#환경', '#서포터즈'],
- date: '2025.04.15~2025.04.20',
- image: issueCardSample,
- bookmarked: false,
- },
- {
- id: 6,
- title: '사람과 사회 서포터즈',
- tags: ['#사람과 사회', '#서포터즈'],
- date: '2025.04.15~2025.04.20',
- image: issueCardSample,
- bookmarked: false,
- },
-];
-
+import BookmarkButtonIcon from '../../assets/images/common/BookmarkButton.png';
+import BookmarkFilledIcon from '../../assets/images/common/BookmarkFilledButton.png';
+import { useIssueDetail, useToggleIssueBookmark } from '../../query/useIssues';
+import { useActivitiesByKeywordLimited, useToggleBookmark } from '../../query/useActivities';
+import ActivityCard from '../../components/activity/SmallActivityCard';
+import { formatDate } from '../../utils/formatDate';
export default function GlobalIssueDetailPage() {
- useEffect(() => {
- window.scrollTo({ top: 0, left: 0 });}, []);
- const location = useLocation();
+ const { id } = useParams();
const navigate = useNavigate();
- const { label, title } = location.state || {};
+
+ // 쿼리 클라이언트 및 훅들
+ const queryClient = useQueryClient();
+ const toggleBookmarkMutation = useToggleBookmark();
+
+ // 이슈 데이터 조회
+ const { data: issue, isLoading, error } = useIssueDetail(id);
+ const toggleIssueBookmark = useToggleIssueBookmark();
+
+ // 추천 활동 조회
+ const {
+ data: recommendedActivities,
+ isLoading: activitiesLoading,
+ error: activitiesError,
+ isError: isActivitiesError
+ } = useActivitiesByKeywordLimited(
+ issue?.keyword,
+ {
+ enabled: !!issue?.keyword
+ }
+ );
- const [bookmarked, setBookmarked] = useState(false);
- const toggleBookmark = () => setBookmarked((prev) => !prev);
+ // 이슈 북마크 토글
+ const handleIssueBookmarkToggle = () => {
+ toggleIssueBookmark.mutate(id);
+ };
- const filteredActivities = dummyActivities.filter((activity) =>
- activity.tags.includes(label) );
+ // 활동 북마크 토글
+const handleActivityBookmarkToggle = async (activityId) => {
+ // 즉시 UI 업데이트 (optimistic)
+ queryClient.setQueryData(
+ ['activitiesByKeywordLimited', issue?.keyword],
+ (oldData) => {
+ if (!oldData || !Array.isArray(oldData)) return oldData;
+
+ return oldData.map((activity) =>
+ (activity.id === activityId || activity.activityId === activityId)
+ ? { ...activity, bookmarked: !activity.bookmarked }
+ : activity
+ );
+ }
+ );
+
+ // 서버 요청
+ try {
+ await toggleBookmarkMutation.mutateAsync(activityId);
+ } catch (error) {
+ // 실패 시 롤백
+ queryClient.setQueryData(
+ ['activitiesByKeywordLimited', issue?.keyword],
+ (oldData) => {
+ if (!oldData || !Array.isArray(oldData)) return oldData;
+
+ return oldData.map((activity) =>
+ (activity.id === activityId || activity.activityId === activityId)
+ ? { ...activity, bookmarked: !activity.bookmarked } // 다시 원래대로
+ : activity
+ );
+ }
+ );
+ console.error('북마크 토글 실패:', error);
+ }
+};
+
+ if (isLoading) {
+ return (
+
+
+
+ 이슈를 불러오는 중...
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ 이슈를 불러오는데 실패했습니다: {error.message}
+
+
+
+
+ );
+ }
+
+ if (!issue) {
+ return (
+
+
+
+ 이슈를 찾을 수 없습니다.
+
+
+
+
+ );
+ }
return (
-
-
+
+
-
+
-
{title}
- 2025.04.07
-
- {issue.title}
+ {formatDate(issue.issueDate)}
+
+
+ onClick={handleIssueBookmarkToggle}
+ />
-
+
내용 요약
-
- 7일 국내 증시는 윤석열 전 대통령 파면 결정으로 정치적 불확실성이 해소됐음에도 불구하고,
- 미국의 전 세계 수입품에 대한 관세 부과로 촉발된 글로벌 관세 전쟁 우려로 인해 코스피와
- 코스닥이 5% 넘게 급락했다. 미국 증시의 폭락 여파로 아시아 주요 증시도 큰 하락세를
- 보였으며, 원·달러 환율 역시 급등했다. 전문가들은 정치 리스크 해소가 단발적으로는
- 국내 증시에 긍정적 영향을 줄 수 있으나, 글로벌 경제 불확실성에 따른 변동성에
- 당분간 유의할 필요가 있다고 분석했다.
-
-
-
-
-
- 관심이 있다면 원본 기사 확인하기
-
+ {issue.content}
+ {issue.siteUrl && (
+
+
+
+ 관심이 있다면 원본 기사 확인하기
+
+
+ )}
-
+
추천 활동
- navigate(`/more-detail?query=${encodeURIComponent(label)}`)}>
- 더보기 >
-
-
+
+ {isActivitiesError && (
+ {activitiesError?.message}
+ )}
+
- {filteredActivities.map((activity) => (
-
- {}}
- />
-
- ))}
+ {activitiesLoading ? (
+ 추천 활동을 불러오는 중...
+ ) : recommendedActivities && recommendedActivities.length > 0 ? (
+ recommendedActivities.map((activity) => (
+ handleActivityBookmarkToggle(activity.id)}
+ isClosed={activity.isClosed}
+ siteUrl={activity.siteUrl || 'https://naver.com'}
+ />
+ ))
+ ) : (
+ 관련 활동이 없습니다.
+ )}
-
);
}
+// 스타일 컴포넌트들
const PageWrapper = styled.div`
display: flex;
flex-direction: column;
@@ -157,13 +203,27 @@ const ContentWrapper = styled.div`
`;
const RecommendWrapper = styled.div`
+ width: 100%;
max-width: 1200px;
margin: 0 auto;
- padding: 0 20px 40px 20px;
+ padding: 0 20px;
+`;
+
+const HeaderWrapper = styled.div`
+ position: relative;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+`;
+
+const LabelWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
`;
const Label = styled.div`
- color: #1a1a1a;
+ color: #656565;
font-size: 20px;
font-weight: 500;
margin-bottom: 12px;
@@ -175,12 +235,21 @@ const Title = styled.h1`
margin-bottom: 8px;
`;
-const Date = styled.p`
+const DateText = styled.p`
font-size: 17px;
color: #888;
margin-bottom: 20px;
`;
+const BookmarkIcon = styled.img`
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 50px;
+ height: 50px;
+ cursor: pointer;
+`;
+
const Divider = styled.hr`
border: none;
border-top: 1px solid #ddd;
@@ -220,9 +289,18 @@ const OriginalLink = styled.a`
align-items: center;
justify-content: center;
font-size: 20px;
- color: #235BA9
+ color: #235BA9;
margin-top: 10px;
cursor: pointer;
+ text-decoration: none;
+
+ &:hover {
+ color: #FFCD4A;
+ }
+
+ &:visited {
+ color: #235BA9;
+ }
`;
const LinkImage = styled.img`
@@ -235,59 +313,70 @@ const RecommendCardsHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 24px;
+ margin: 40px 0 20px;
`;
-const RecommendTitle = styled.h3`
- font-size: 30px;
+const RecommendTitle = styled.h2`
+ font-size: 24px;
font-weight: 700;
- margin-top: 0;
-`;
-
-const MoreLink = styled.a`
- font-size: 14px;
- color: #000;
- cursor: pointer;
- text-decoration: none;
- margin-bottom: -70px;
-
- &:hover {
- text-decoration: underline;
- }
+ color: #333;
`;
const RecommendCards = styled.div`
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 15px;
- width: 100%;
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: space-between;
+ gap: 20px;
+ margin-top: 20px;
+ margin-bottom: 40px;
+ overflow-x: auto;
`;
-const CardWrapper = styled.div`
- transform: scale(0.9);
- transform-origin: top left;
- width: fit-content;
- zoom: 0.9;
+const LoadingContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 400px;
+ font-size: 18px;
`;
-const LabelWrapper = styled.div`
+const ErrorContainer = styled.div`
display: flex;
+ flex-direction: column;
+ justify-content: center;
align-items: center;
- gap: 8px;
+ min-height: 400px;
+ gap: 20px;
+
+ button {
+ padding: 10px 20px;
+ background-color: #235BA9;
+ color: white;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+ }
`;
-const HeaderWrapper = styled.div`
- position: relative;
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
+const LoadingText = styled.div`
+ grid-column: 1 / -1;
+ text-align: center;
+ padding: 40px 0;
+ font-size: 16px;
+ color: #666;
`;
-const BookmarkIcon = styled.img`
- position: absolute;
- top: 0;
- right: 0;
- width: 32px;
- height: 32px;
- cursor: pointer;
+const NoActivitiesText = styled.div`
+ grid-column: 1 / -1;
+ text-align: center;
+ padding: 40px 0;
+ font-size: 16px;
+ color: #666;
+`;
+
+const ErrorMessage = styled.div`
+ color: #ff0000;
+ text-align: center;
+ margin: 20px 0;
+ font-size: 16px;
`;
diff --git a/src/pages/issue/GlobalIssuePage.jsx b/src/pages/issue/GlobalIssuePage.jsx
index 0a8b581..789bc56 100644
--- a/src/pages/issue/GlobalIssuePage.jsx
+++ b/src/pages/issue/GlobalIssuePage.jsx
@@ -5,71 +5,74 @@ import Footer from '../../layout/Footer';
import { useLocation, useNavigate } from 'react-router-dom';
import IssueCard from '../../components/issue/IssueCard';
import CategoryFilter from '../../components/common/CategoryFilter';
-import usePagination from '../../hooks/usePagination';
import Pagination from '../../components/common/Pagination';
-
-import issueCardSample from '../../assets/images/issue/ic_IssueCardSample.png';
-import noImage from '../../assets/images/main/ic_NoImage.png';
-
-const dummyData = [
- ...Array.from({ length: 4 }, (_, i) => ({
- id: i + 1,
- title: '글로벌 ‘관세 전쟁’ 공포 … 국내 증시 ‘털썩’ ',
- category: '#경제',
- thumbnailUrl: noImage,
- })),
- ...Array.from({ length: 4 }, (_, i) => ({
- id: i + 5,
- title: '환경 이슈 ' + (i + 1),
- category: '#환경',
- thumbnailUrl: issueCardSample,
- })),
- ...Array.from({ length: 4 }, (_, i) => ({
- id: i + 9,
- title: '사람과 사회 이슈 ' + (i + 1),
- category: '#사람과 사회',
- thumbnailUrl: issueCardSample,
- })),
- ...Array.from({ length: 4 }, (_, i) => ({
- id: i + 13,
- title: '기술 이슈 ' + (i + 1),
- category: '#기술',
- thumbnailUrl: issueCardSample,
- })),
-];
+import { useIssues, useIssuesByKeyword, useToggleIssueBookmark } from '../../query/useIssues';
export default function GlobalIssuePage() {
const [activeCategory, setActiveCategory] = useState('전체');
- const [bookmarkedIds, setBookmarkedIds] = useState([]);
- const itemsPerPage = 12;
-
+ const [currentPage, setCurrentPage] = useState(0);
+
const location = useLocation();
const query = new URLSearchParams(location.search).get('query');
-
const navigate = useNavigate();
+ // 조건부 API 호출
+ const { data: allIssues, isLoading: allLoading, error: allError } = useIssues(currentPage);
+ const { data: keywordIssues, isLoading: keywordLoading, error: keywordError } = useIssuesByKeyword({
+ keyword: activeCategory,
+ page: currentPage
+ });
+
+ const toggleBookmark = useToggleIssueBookmark();
+
useEffect(() => {
window.scrollTo({ top: 0, left: 0 });
if (query) setActiveCategory(query);
}, [query]);
- const toggleBookmark = (id) => {
- setBookmarkedIds(prev =>
- prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]
- );
+ const handleBookmarkToggle = (issueId) => {
+ toggleBookmark.mutate(issueId);
};
- const filteredData =
- activeCategory === '전체'
- ? dummyData
- : dummyData.filter(item => item.category.includes(activeCategory));
+ const handleCategoryChange = (category) => {
+ setActiveCategory(category);
+ setCurrentPage(0);
+ };
+
+ const handlePageChange = (page) => {
+ setCurrentPage(page - 1);
+ };
- const {
- currentPage,
- totalPages,
- currentData: paginatedData,
- goToPage
- } = usePagination(filteredData, itemsPerPage);
+ // 현재 카테고리에 따라 적절한 데이터 선택
+ const isShowingAll = activeCategory === '전체';
+ const currentData = isShowingAll ? allIssues : keywordIssues;
+ const isLoading = isShowingAll ? allLoading : keywordLoading;
+ const error = isShowingAll ? allError : keywordError;
+
+ if (isLoading) {
+ return (
+
+
+
+ 이슈를 불러오는 중...
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ 이슈를 불러오는데 실패했습니다: {error.message}
+
+
+
+
+ );
+ }
return (
@@ -82,29 +85,32 @@ export default function GlobalIssuePage() {
{
- setActiveCategory(cat);
- goToPage(1);
- }}
+ onSelectCategory={handleCategoryChange}
/>
- {paginatedData.map(item => (
+ {currentData?.content?.map(issue => (
toggleBookmark(item.id)}
- onClick={() => navigate(`/global-issue/${item.id}`, { state: { label: item.category, title: item.title } })}
+ key={issue.id}
+ id={issue.id}
+ title={issue.title}
+ tag={issue.category}
+ image={issue.thumbnailUrl}
+ bookmarked={issue.bookmarked}
+ onToggle={() => handleBookmarkToggle(issue.id)}
+ onClick={() => navigate(`/global-issue/${issue.id}`, {
+ state: { label: issue.category, title: issue.title }
+ })}
/>
- ))}
+ )) || []}
- {activeCategory === '전체' && (
-
+ {currentData?.totalPages > 1 && (
+
)}
@@ -112,6 +118,38 @@ export default function GlobalIssuePage() {
);
}
+// 스타일 컴포넌트들은 기존과 동일...
+
+
+// 기존 스타일 + 추가 스타일
+const LoadingContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 400px;
+ font-size: 18px;
+`;
+
+const ErrorContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ min-height: 400px;
+ gap: 20px;
+
+ button {
+ padding: 10px 20px;
+ background-color: #235BA9;
+ color: white;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+ }
+`;
+
+
+
const Wrapper = styled.div`
display: flex;
flex-direction: column;
diff --git a/src/pages/issue/MoreDetailPage.jsx b/src/pages/issue/MoreDetailPage.jsx
deleted file mode 100644
index f2af18c..0000000
--- a/src/pages/issue/MoreDetailPage.jsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import React from 'react';
-import { useEffect } from 'react';
-import styled from 'styled-components';
-import { useLocation } from 'react-router-dom';
-import MainNav from '../../layout/MainNav';
-import Footer from '../../layout/Footer';
-import ActivityCard from '../../components/activity/ActivityCard';
-import Pagination from '../../components/common/Pagination';
-import usePagination from '../../hooks/usePagination';
-import activityImage from '../../assets/images/activity/ic_ActivityImage.png';
-
-// 정규화 함수 추가
-function normalizeLabel(label) {
- if (!label) return '';
- return '#' + label.replace(/\s/g, '').replace('#', '');
-}
-
-export default function MoreDetailPage() {
- useEffect(() => {
- window.scrollTo({ top: 0, left: 0 });}, []);
-
- const location = useLocation();
- const queryParams = new URLSearchParams(location.search);
- const rawFilterTag = queryParams.get('query');
- const filterTag = normalizeLabel(rawFilterTag); // 정규화된 필터 태그
-
- const dummyActivities = [
- ...Array.from({ length: 15 }, (_, idx) => ({
- id: idx + 1,
- title: `제 ${idx + 1}회 한국 경제 논문 공모전`,
- tags: ['#경제', '#공모전'],
- date: '2025.04.15~2025.04.20',
- image: activityImage,
- bookmarked: false,
- isClosed: [5, 9, 14].includes(idx + 1),
- })),
- {
- id: 16,
- title: '기후위기 대응 환경 세미나',
- tags: ['#환경', '#공모전'],
- date: '2025.04.10~2025.04.20',
- image: activityImage,
- bookmarked: false,
- isClosed: false,
- },
- {
- id: 17,
- title: '에코 자원순환 캠페인',
- tags: ['#환경', '#봉사활동'],
- date: '2025.04.12~2025.04.22',
- image: activityImage,
- bookmarked: false,
- isClosed: false,
- },
- {
- id: 18,
- title: '지역사회 혁신 프로젝트',
- tags: ['#사람과사회', '#서포터즈'],
- date: '2025.04.14~2025.04.25',
- image: activityImage,
- bookmarked: false,
- isClosed: true,
- },
- {
- id: 19,
- title: '청소년 멘토링 봉사',
- tags: ['#사람과사회', '#봉사활동'],
- date: '2025.04.15~2025.04.30',
- image: activityImage,
- bookmarked: false,
- isClosed: false,
- },
- {
- id: 20,
- title: '그린리더 환경 인턴십',
- tags: ['#환경', '#인턴십'],
- date: '2025.04.16~2025.04.30',
- image: activityImage,
- bookmarked: false,
- isClosed: true,
- }
- ];
-
- const filteredActivities = dummyActivities.filter((activity) =>
- filterTag ? activity.tags.includes(filterTag) : true
- );
-
- const itemsPerPage = 12;
- const { currentPage, totalPages, currentData, goToPage } = usePagination(filteredActivities, itemsPerPage);
-
- return (
-
-
-
- 추천 활동
-
-
- {currentData.map((activity) => (
- {}}
- isClosed={activity.isClosed}
- />
- ))}
-
-
-
-
-
-
- );
-}
-
-const PageWrapper = styled.div`
- background-color: #fff;
- min-height: 100vh;
-`;
-
-const Content = styled.div`
- max-width: 1400px;
- margin: 40px auto;
- padding: 0 30px;
-`;
-
-const PageTitle = styled.h1`
- font-size: 28px;
- margin-bottom: 32px;
-`;
-
-const CardGrid = styled.div`
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 32px;
- margin-bottom: 40px;
-`;
diff --git a/src/pages/login/LoginPage.jsx b/src/pages/login/LoginPage.jsx
index 7702b99..6584ce7 100644
--- a/src/pages/login/LoginPage.jsx
+++ b/src/pages/login/LoginPage.jsx
@@ -61,17 +61,37 @@ export default function OnboardingPage() {
if (!token) return;
try {
- // 토큰 저장 (axiosInstance 인터셉터가 자동으로 헤더에 추가)
- localStorage.setItem('token', token);
+ // 토큰 저장 - axiosInstance에서 Token(대문자)로 조회하므로 대문자로 저장
+ localStorage.setItem('Token', token);
+ console.log('토큰 저장 완료:', token);
+
+ // URL에서 토큰 제거 (보안을 위해)
+ window.history.replaceState({}, document.title, window.location.pathname);
// 프로필 조회
const { data } = await axiosInstance.get('/users/profile');
const { result } = data;
+ console.log('프로필 조회 응답:', data);
+ console.log('result 객체:', result);
+
+ // **API 문서에 따라 id를 userId로 저장 (오타 수정)**
+ if (result && result.id) {
+ localStorage.setItem('userId', result.id); // result.Id → result.id로 수정
+ localStorage.setItem('nickname', result.nickname || '');
+ localStorage.setItem('email', result.email || '');
+ console.log('저장된 userId (from id):', result.id);
+ console.log('저장된 nickname:', result.nickname);
+ } else {
+ console.warn('id가 응답에 포함되지 않음:', result);
+ }
+
// 프로필 미완료 시 모달 표시
if (!result || !result.nickname || !result.keyword) {
+ console.log('프로필 미완료, 모달 표시');
setShowModal(true);
} else {
+ console.log('프로필 완료, 메인으로 이동');
navigate('/main');
}
} catch (error) {
@@ -88,7 +108,13 @@ export default function OnboardingPage() {
// 구글 로그인 연동
const handleGoogleLoginClick = () => {
- window.location.href = "http://61.109.236.137:8080/oauth2/authorization/google";
+ window.location.href = "/oauth2/authorization/google";
+ };
+
+ const handleModalClose = () => {
+ setShowModal(false);
+ // 모달 닫힌 후 메인으로 이동
+ navigate('/main');
};
return (
@@ -120,12 +146,12 @@ export default function OnboardingPage() {
- {showModal && setShowModal(false)} />}
+ {showModal && }
);
}
-// 스타일 컴포넌트들은 기존 코드와 동일
+// Styled Components
const Wrapper = styled.div`
width: 100%;
max-width: 100vw;
@@ -229,6 +255,12 @@ const GoogleButton = styled.button`
font-size: 30px;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
cursor: pointer;
+
+ &:hover {
+ box-shadow: 0 6px 8px rgba(0, 0, 0, 0.3);
+ transform: translateY(-2px);
+ transition: all 0.2s ease;
+ }
`;
const GoogleIcon = styled.img`
diff --git a/src/pages/login/ProfileModal.jsx b/src/pages/login/ProfileModal.jsx
index 9771ef8..d42cbff 100644
--- a/src/pages/login/ProfileModal.jsx
+++ b/src/pages/login/ProfileModal.jsx
@@ -4,6 +4,7 @@ import avatar from '../../assets/images/profile/DefaultProfile.png';
import cameraIcon from '../../assets/images/profile/ic_ProfileCamera.png';
import InterestModal from './InterestModal';
import NotoSansKR from '../../assets/fonts/NotoSansKR-VariableFont_wght.ttf';
+import { getPresignedUrl } from '../../api/userApi';
const NotoSansFont = `
@font-face {
@@ -23,16 +24,92 @@ export default function ProfileModal({ onClose }) {
const [nickname, setNickname] = useState('');
const [isFocused, setIsFocused] = useState(false);
const [profileImg, setProfileImg] = useState(avatar);
+ const [uploadedImageUrl, setUploadedImageUrl] = useState('');
+ const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef();
const handleCameraClick = () => fileInputRef.current?.click();
- const handleFileChange = (e) => {
+ const uploadImageToS3 = async (file) => {
+ try {
+ setIsUploading(true);
+
+ const fileExtension = file.name.split('.').pop();
+ // UUID 생성을 위한 간단한 함수 또는 crypto.randomUUID() 사용
+ const uniqueId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
+ const imageName = `${uniqueId}.${fileExtension}`; // profiles/ 제거
+
+ // PresignedUrl 가져오기
+ const presignedUrl = await getPresignedUrl(imageName);
+ console.log('Presigned URL 발급 성공:', presignedUrl);
+
+ // S3에 이미지 업로드
+ const response = await fetch(presignedUrl, {
+ method: 'PUT',
+ body: file,
+ headers: {
+ 'Content-Type': file.type,
+ },
+ // CORS 관련 옵션 제거
+ mode: 'cors'
+ });
+
+ console.log('Upload response status:', response.status);
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('Upload error response:', errorText);
+ throw new Error(`Upload failed: ${response.status} ${errorText}`);
+ }
+
+ // 업로드된 이미지 URL 저장 (서버에서 반환하는 실제 경로 사용)
+ const imageUrl = presignedUrl.split('?')[0]; // 쿼리 파라미터 제거
+ setUploadedImageUrl(imageUrl);
+ console.log('이미지 업로드 성공:', imageUrl);
+
+ return imageUrl;
+ } catch (error) {
+ console.error('상세 업로드 오류:', {
+ message: error.message,
+ stack: error.stack,
+ name: error.name
+ });
+ throw error;
+ } finally {
+ setIsUploading(false);
+ }
+};
+
+ const handleFileChange = async (e) => {
const file = e.target.files[0];
if (file) {
- const reader = new FileReader();
- reader.onloadend = () => setProfileImg(reader.result); // base64 저장
- reader.readAsDataURL(file);
+ try {
+ // 파일 크기 체크 (5MB 제한)
+ if (file.size > 5 * 1024 * 1024) {
+ alert('파일 크기는 5MB 이하여야 합니다.');
+ return;
+ }
+
+ // 파일 타입 체크
+ if (!file.type.startsWith('image/')) {
+ alert('이미지 파일만 업로드 가능합니다.');
+ return;
+ }
+
+ // 미리보기 이미지 설정
+ const reader = new FileReader();
+ reader.onloadend = () => setProfileImg(reader.result);
+ reader.readAsDataURL(file);
+
+ // S3에 이미지 업로드
+ await uploadImageToS3(file);
+ } catch (error) {
+ console.error('파일 처리 중 오류 발생:', error);
+ alert('이미지 업로드에 실패했습니다: ' + error.message);
+ // 실패 시 기본 이미지로 되돌리기
+ setProfileImg(avatar);
+ setUploadedImageUrl('');
+ }
}
};
@@ -50,12 +127,14 @@ export default function ProfileModal({ onClose }) {
프로필을 설정해주세요.
-
+
+ {isUploading && 업로드 중...}
0}
onClick={handleNext}
- disabled={nickname.trim().length === 0}
+ disabled={nickname.trim().length === 0 || isUploading}
>
다음
@@ -80,7 +159,7 @@ export default function ProfileModal({ onClose }) {
)}
>
@@ -142,7 +221,21 @@ const CameraIcon = styled.div`
background-repeat: no-repeat;
background-position: center;
z-index: 10;
- cursor: pointer;
+ cursor: ${({ $isUploading }) => ($isUploading ? 'not-allowed' : 'pointer')};
+ opacity: ${({ $isUploading }) => ($isUploading ? 0.5 : 1)};
+`;
+
+const UploadingIndicator = styled.div`
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(0, 0, 0, 0.7);
+ color: white;
+ padding: 8px 12px;
+ border-radius: 8px;
+ font-size: 12px;
+ z-index: 20;
`;
const HiddenFileInput = styled.input`
@@ -178,4 +271,4 @@ const NextButton = styled.button`
border-radius: 30px;
cursor: ${({ $active }) => ($active ? 'pointer' : 'default')};
transition: background-color 0.2s ease;
-`;
\ No newline at end of file
+`;
diff --git a/src/pages/main/MainPage.jsx b/src/pages/main/MainPage.jsx
index bc42253..c569ba8 100644
--- a/src/pages/main/MainPage.jsx
+++ b/src/pages/main/MainPage.jsx
@@ -1,27 +1,25 @@
-import React, { useState } from 'react';
+import React from 'react';
import styled from 'styled-components';
import { useNavigate } from 'react-router-dom';
import MainNav from '../../layout/MainNav';
import Footer from '../../layout/Footer';
import IssueCard from '../../components/issue/IssueCard';
import Chatbot from '../../components/chatbot/Chatbot';
+import { useIssues, useToggleIssueBookmark } from '../../query/useIssues';
-import globalIssueImage from '../../assets/images/main/ic_GlobalIssue.png';
-import issueCardSample from '../../assets/images/issue/ic_IssueCardSample.png';
import environmentButton from '../../assets/images/main/EnvironmentButton.png';
import peopleButton from '../../assets/images/main/PeopleButton.png';
import economyButton from '../../assets/images/main/EconomyButton.png';
import techButton from '../../assets/images/main/TechButton.png';
+import mainBanner from '../../assets/images/main/mainbanner.png'
export default function MainPage() {
- const [bookmarked, setBookmarked] = useState(new Array(3).fill(false));
const navigate = useNavigate();
+ const toggleBookmark = useToggleIssueBookmark();
- const dummyIssues = [
- { title: "글로벌 '관세 전쟁' 공포 ... 국내 증시 '타격'", tag: '#정치' },
- { title: "글로벌 '관세 전쟁' 공포 ... 국내 증시 '타격'", tag: '#경제' },
- { title: "글로벌 '관세 전쟁' 공포 ... 국내 증시 '타격'", tag: '#사회' },
- ];
+ // 최신 이슈 3개 조회
+ const { data: issuesData, isLoading } = useIssues(0);
+ const latestIssues = issuesData?.content?.slice(0, 3) || [];
const categories = [
{ title: '환경', img: environmentButton },
@@ -30,27 +28,17 @@ export default function MainPage() {
{ title: '기술', img: techButton },
];
- const toggleBookmark = (idx) => {
- const updated = [...bookmarked];
- updated[idx] = !updated[idx];
- setBookmarked(updated);
+ const handleBookmarkToggle = (issueId) => {
+ toggleBookmark.mutate(issueId);
};
return (
-
-
-
- 최신 글로벌 이슈를 알아보자
- navigate('/global-issue')}>알아보기 >
-
-
-
-
-
-
+
+
+
글로벌 이슈
@@ -75,16 +63,22 @@ export default function MainPage() {
navigate('/global-issue')}>더보기 >
- {dummyIssues.map((item, idx) => (
- toggleBookmark(idx)}
- />
- ))}
+ {isLoading ? (
+ 로딩 중...
+ ) : latestIssues.length > 0 ? (
+ latestIssues.map((issue) => (
+ handleBookmarkToggle(issue.id)}
+ />
+ ))
+ ) : (
+ 최신 이슈가 없습니다.
+ )}
@@ -108,42 +102,15 @@ const MainContent = styled.main`
`;
const HeroSection = styled.section`
- width: 104%;
+ width: 100%;
background-color: #F6FAFF;
display: flex;
justify-content: center;
- margin-left: -50px;
`;
-const HeroInner = styled.div`
+const BannerImage = styled.img`
width: 100%;
- height: 530px;
- display: flex;
- justify-content: space-between;
- align-items: center;
-`;
-
-const HeroLeft = styled.div`
- margin-left: 100px;
- font-family: 'NotoSansKR-VariableFont_wght';
-`;
-
-const HeroRight = styled.div``;
-
-const HeroTitle = styled.h2`
- font-size: 60px;
- font-weight: 700;
- margin-bottom: 16px;
-`;
-
-const HeroLink = styled.p`
- cursor: pointer;
- font-size: 40px;
-`;
-
-const HeroImage = styled.img`
- width: 578px;
- height: 578px;
+ height: auto;
`;
const Wrapper = styled.section`
@@ -205,13 +172,29 @@ const MoreLink = styled.span`
color: #000;
text-align: right;
margin-bottom: 16px;
- padding-right: 105px;
+ padding-right: 180px;
`;
const IssueGrid = styled.div`
display: flex;
justify-content: center;
- gap: 150px;
+ gap: 70px;
flex-wrap: wrap;
margin-bottom: 150px;
+`;
+
+const LoadingText = styled.div`
+ grid-column: 1 / -1;
+ text-align: center;
+ padding: 40px 0;
+ font-size: 16px;
+ color: #666;
+`;
+
+const NoIssuesText = styled.div`
+ grid-column: 1 / -1;
+ text-align: center;
+ padding: 40px 0;
+ font-size: 16px;
+ color: #666;
`;
\ No newline at end of file
diff --git a/src/pages/main/MoreActivityPage.jsx b/src/pages/main/MoreActivityPage.jsx
index 3eeba16..3896c97 100644
--- a/src/pages/main/MoreActivityPage.jsx
+++ b/src/pages/main/MoreActivityPage.jsx
@@ -1,79 +1,144 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useLocation } from 'react-router-dom';
+import { useQueryClient } from '@tanstack/react-query';
import MainNav from '../../layout/MainNav';
import Footer from '../../layout/Footer';
import ActivityCard from '../../components/activity/ActivityCard';
import Pagination from '../../components/common/Pagination';
-import usePagination from '../../hooks/usePagination';
-import activityImage from '../../assets/images/activity/ic_ActivityImage.png';
-import { useEffect } from 'react';
-
-const dummyGlobalIssues = Array.from({ length: 40 }, (_, idx) => ({
- id: idx + 1,
- title: '제 22회 한국 경제 논문 공모전',
- tags: ['#경제', '#공모전'],
- date: '2025.04.15~2025.04.20',
- image: activityImage,
-}));
+import { searchAllActivities } from '../../api/MainSearchApi';
+import { useToggleBookmark } from '../../query/useActivities';
+import { formatDate } from '../../utils/formatDate';
export default function MoreActivityPage() {
useEffect(() => {
- window.scrollTo({ top: 0, left: 0 });}, []);
+ window.scrollTo({ top: 0, left: 0 });
+ }, []);
+
const location = useLocation();
+ const queryClient = useQueryClient();
const query = new URLSearchParams(location.search).get('query')?.toLowerCase() || '';
- const [bookmarkedIds, setBookmarkedIds] = useState([]);
+
+ const [currentPage, setCurrentPage] = useState(0);
+ const [searchResults, setSearchResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [totalPages, setTotalPages] = useState(0);
- const filtered = query
- ? dummyGlobalIssues.filter(
- (item) =>
- item.title.toLowerCase().includes(query) ||
- item.tags.some((tag) => tag.toLowerCase().includes(query))
- )
- : dummyGlobalIssues;
+ const toggleBookmark = useToggleBookmark();
- const itemsPerPage = 12;
-
- const {
- currentPage,
- totalPages,
- currentData: paginatedData,
- goToPage,
- } = usePagination(filtered, itemsPerPage);
-
- const toggleBookmark = (id) => {
- setBookmarkedIds((prev) =>
- prev.includes(id) ? prev.filter((bid) => bid !== id) : [...prev, id]
+ useEffect(() => {
+ if (query) {
+ fetchSearchResults(query, currentPage);
+ }
+ }, [query, currentPage]);
+
+ const fetchSearchResults = async (searchQuery, page) => {
+ setLoading(true);
+ try {
+ const response = await searchAllActivities({
+ keyword: searchQuery,
+ activityType: null,
+ page,
+ size: 12
+ });
+ if (response.isSuccess) {
+ setSearchResults(response.result.content);
+ setTotalPages(response.result.totalPages);
+ }
+ } catch (error) {
+ console.error('검색 결과를 가져오는데 실패했습니다:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 북마크 토글 함수 수정 (optimistic update 적용)
+ const handleBookmarkToggle = async (activityId) => {
+ // 즉시 UI 업데이트 (optimistic update)
+ setSearchResults(prevResults =>
+ prevResults.map(activity =>
+ activity.activityId === activityId
+ ? { ...activity, bookmarked: !activity.bookmarked }
+ : activity
+ )
);
+
+ // 서버 요청
+ try {
+ await toggleBookmark.mutateAsync(activityId);
+
+ // 관련 쿼리 무효화
+ queryClient.invalidateQueries(['activities']);
+ queryClient.invalidateQueries(['bookmarkedActivities']);
+ } catch (error) {
+ // 실패 시 롤백
+ setSearchResults(prevResults =>
+ prevResults.map(activity =>
+ activity.activityId === activityId
+ ? { ...activity, bookmarked: !activity.bookmarked } // 다시 원래대로
+ : activity
+ )
+ );
+ console.error('북마크 토글 실패:', error);
+ }
};
return (
- ‘{query}’의 검색 결과
+ '{query}'의 검색 결과
활동
-
- {paginatedData.map((item) => (
- toggleBookmark(item.id)}
- />
- ))}
-
-
- {filtered.length > itemsPerPage && (
-
+ {loading ? (
+ 검색 중...
+ ) : searchResults.length === 0 ? (
+ 검색 결과가 없습니다.
+ ) : (
+ <>
+
+ {searchResults.map((activity) => {
+ // 태그 배열 생성 (키워드와 액티비티 타입 포함)
+ const tags = [];
+ if (activity.keyword) {
+ tags.push(`#${activity.keyword}`);
+ }
+ if (activity.activityType) {
+ // activityType을 한글로 변환
+ const typeMap = {
+ 'VOLUNTEER': '봉사활동',
+ 'CONTEST': '공모전',
+ 'SUPPORTERS': '서포터즈',
+ 'INTERNSHIP': '인턴십'
+ };
+ tags.push(`#${typeMap[activity.activityType] || activity.activityType}`);
+ }
+
+ return (
+ handleBookmarkToggle(activity.activityId)}
+ isClosed={new Date() > new Date(activity.endDate)} // 마감 여부 확인
+ siteUrl={activity.siteUrl || 'https://naver.com'}
+ />
+ );
+ })}
+
+
+ {totalPages > 1 && (
+ setCurrentPage(page - 1)}
+ />
+ )}
+ >
)}
@@ -109,3 +174,17 @@ const CardGrid = styled.div`
max-width: 1540px;
margin: 0 auto;
`;
+
+const LoadingMessage = styled.p`
+ text-align: center;
+ font-size: 18px;
+ color: #666;
+ margin-top: 40px;
+`;
+
+const NoResult = styled.p`
+ text-align: center;
+ font-size: 18px;
+ color: #999;
+ margin-top: 40px;
+`;
diff --git a/src/pages/main/MoreGlobalPage.jsx b/src/pages/main/MoreGlobalPage.jsx
index ca2483f..ded9cbf 100644
--- a/src/pages/main/MoreGlobalPage.jsx
+++ b/src/pages/main/MoreGlobalPage.jsx
@@ -1,77 +1,111 @@
// src/pages/MoreGlobalPage.jsx
-import React, { useState } from 'react';
-import { useEffect } from 'react';
+import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
-import { useLocation } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
import MainNav from '../../layout/MainNav';
import Footer from '../../layout/Footer';
import IssueCard from '../../components/issue/IssueCard';
import Pagination from '../../components/common/Pagination';
-import issueCardSample from '../../assets/images/issue/ic_IssueCardSample.png';
-import usePagination from '../../hooks/usePagination';
-
-const dummyGlobalIssues = Array.from({ length: 40 }, (_, idx) => ({
- id: idx + 1,
- title: '제 22회 한국 경제 논문 공모전',
- tag: '#경제',
- image: issueCardSample,
-}));
+import { searchAllIssues } from '../../api/MainSearchApi';
+import { useToggleIssueBookmark } from '../../query/useIssues';
+import { useQueryClient } from '@tanstack/react-query';
export default function MoreGlobalPage() {
useEffect(() => {
- window.scrollTo({ top: 0, left: 0 });}, []);
+ window.scrollTo({ top: 0, left: 0 });
+ }, []);
+
const location = useLocation();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
const query = new URLSearchParams(location.search).get('query')?.toLowerCase() || '';
- const finalIssues = query
- ? dummyGlobalIssues.filter(
- (item) =>
- item.title.toLowerCase().includes(query) ||
- item.tag.toLowerCase().includes(query)
- )
- : dummyGlobalIssues;
-
- const [bookmarkedIds, setBookmarkedIds] = useState([]);
- const itemsPerPage = 12;
-
- const {
- currentPage,
- totalPages,
- currentData: paginatedIssues,
- goToPage,
- } = usePagination(finalIssues, itemsPerPage);
-
- const toggleBookmark = (id) => {
- setBookmarkedIds((prev) =>
- prev.includes(id) ? prev.filter((bid) => bid !== id) : [...prev, id]
- );
+ const [searchResults, setSearchResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [totalPages, setTotalPages] = useState(0);
+ const [currentPage, setCurrentPage] = useState(0);
+
+ const toggleIssueBookmark = useToggleIssueBookmark();
+
+ useEffect(() => {
+ if (query) {
+ fetchSearchResults(query, currentPage);
+ }
+ }, [query, currentPage]);
+
+ const fetchSearchResults = async (searchQuery, page) => {
+ setLoading(true);
+ try {
+ const response = await searchAllIssues({
+ keyword: searchQuery,
+ page,
+ size: 12
+ });
+ if (response.isSuccess) {
+ setSearchResults(response.result.content);
+ setTotalPages(response.result.totalPages);
+ }
+ } catch (error) {
+ console.error('검색 결과를 가져오는데 실패했습니다:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleGlobalBookmark = (id) => {
+ toggleIssueBookmark.mutate(id, {
+ onSuccess: () => {
+ // 검색 결과 업데이트
+ setSearchResults(prevResults =>
+ prevResults.map(issue =>
+ issue.id === id
+ ? { ...issue, bookmarked: !issue.bookmarked }
+ : issue
+ )
+ );
+ // 관련 쿼리 무효화
+ queryClient.invalidateQueries(['issues']);
+ queryClient.invalidateQueries(['bookmarkedIssues']);
+ }
+ });
};
return (
- ‘{query}’의 검색 결과
+ '{query}'의 검색 결과
글로벌 이슈
-
- {(finalIssues.length === 1 ? finalIssues : paginatedIssues).map((item) => (
- toggleBookmark(item.id)}
- />
- ))}
-
- {finalIssues.length > itemsPerPage && (
-
+ {loading ? (
+ 검색 중...
+ ) : searchResults.length === 0 ? (
+ 검색 결과가 없습니다.
+ ) : (
+ <>
+
+ {searchResults.map((item) => (
+ handleGlobalBookmark(item.id)}
+ onClick={() => navigate(`/global-issue/${item.id}`, {
+ state: { label: item.keyword, title: item.title }
+ })}
+ />
+ ))}
+
+ {totalPages > 1 && (
+ setCurrentPage(page - 1)}
+ />
+ )}
+ >
)}
@@ -107,3 +141,17 @@ const CardGrid = styled.div`
max-width: 1540px;
margin: 0 auto;
`;
+
+const LoadingMessage = styled.p`
+ text-align: center;
+ font-size: 18px;
+ color: #666;
+ margin-top: 40px;
+`;
+
+const NoResult = styled.p`
+ text-align: center;
+ font-size: 18px;
+ color: #999;
+ margin-top: 40px;
+`;
diff --git a/src/pages/main/SearchResultPage.jsx b/src/pages/main/SearchResultPage.jsx
index d1e6397..f469ba1 100644
--- a/src/pages/main/SearchResultPage.jsx
+++ b/src/pages/main/SearchResultPage.jsx
@@ -1,40 +1,32 @@
// src/pages/SearchResultPage.jsx
import React, { useState, useRef, useEffect } from 'react';
import styled from 'styled-components';
+import { useQueryClient } from '@tanstack/react-query';
import MainNav from '../../layout/MainNav';
import Footer from '../../layout/Footer';
import IssueCard from '../../components/issue/IssueCard';
import ActivityCard from '../../components/activity/ActivityCard';
-import issueCardSample from '../../assets/images/issue/ic_IssueCardSample.png';
-import activityImage from '../../assets/images/activity/ic_ActivityImage.png';
import { useLocation, useNavigate } from 'react-router-dom';
import SearchBar from '../../components/search/SearchBar';
-
-const dummyGlobalIssues = [
- { id: 1, title: '글로벌 관세 전쟁 공포', tag: '#경제', image: issueCardSample },
- { id: 2, title: '기후 변화와 탄소 중립 경제', tag: '#환경', image: issueCardSample },
- { id: 3, title: 'AI 기술의 미래와 사회 변화 경제', tag: '#기술', image: issueCardSample },
- { id: 4, title: '세계 경제 포럼: 글로벌 정책 전환 경제', tag: '#정치', image: issueCardSample },
-];
-
-const dummyActivities = [
- { id: 1, title: '제 22회 한국 경제 논문 공모전', tags: ['#경제', '#공모전'], date: '2025.04.15~2025.04.20', image: activityImage },
- { id: 2, title: '환경 인턴십', tags: ['#환경', '#인턴십'], date: '2025.04.15~2025.04.20', image: activityImage },
- { id: 3, title: '기술 봉사활동', tags: ['#기술', '#봉사활동'], date: '2025.04.15~2025.04.20', image: activityImage },
- { id: 4, title: '제 22회 한국 경제 논문 공모전', tags: ['#경제', '#공모전'], date: '2025.04.15~2025.04.20', image: activityImage },
- { id: 5, title: '제 22회 한국 경제 논문 공모전', tags: ['#경제', '#공모전'], date: '2025.04.15~2025.04.20', image: activityImage },
- { id: 6, title: '제 22회 한국 경제 논문 공모전', tags: ['#경제', '#공모전'], date: '2025.04.15~2025.04.20', image: activityImage },
-];
+import { searchIssues, searchActivities } from '../../api/MainSearchApi';
+import { useToggleIssueBookmark } from '../../query/useIssues';
+import { useToggleBookmark } from '../../query/useActivities';
+import { formatDate } from '../../utils/formatDate';
export default function SearchResultPage() {
const location = useLocation();
const navigate = useNavigate();
const inputRef = useRef();
+ const queryClient = useQueryClient();
const [inputValue, setInputValue] = useState('');
const [searchQuery, setSearchQuery] = useState('');
- const [bookmarkedGlobalIds, setBookmarkedGlobalIds] = useState([]);
- const [bookmarkedActivityIds, setBookmarkedActivityIds] = useState([]);
+ const [searchResults, setSearchResults] = useState([]);
+ const [activityResults, setActivityResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const toggleIssueBookmark = useToggleIssueBookmark();
+ const toggleActivityBookmark = useToggleBookmark();
useEffect(() => {
const params = new URLSearchParams(location.search);
@@ -42,39 +34,86 @@ export default function SearchResultPage() {
if (queryFromURL) {
setInputValue(queryFromURL);
setSearchQuery(queryFromURL);
+ fetchSearchResults(queryFromURL);
}
}, [location.search]);
+ const fetchSearchResults = async (query) => {
+ if (!query.trim()) return;
+
+ setLoading(true);
+ try {
+ const [issuesResponse, activitiesResponse] = await Promise.all([
+ searchIssues({ keyword: query }),
+ searchActivities({ keyword: query, activityType: null })
+ ]);
+
+ if (issuesResponse.isSuccess) {
+ setSearchResults(issuesResponse.result.content);
+ }
+ if (activitiesResponse.isSuccess) {
+ setActivityResults(activitiesResponse.result.content);
+ }
+ } catch (error) {
+ console.error('검색 결과를 가져오는데 실패했습니다:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
const handleSearch = () => {
if (inputValue.trim()) {
navigate(`/search?query=${inputValue.trim()}`);
}
};
- const lowerQuery = searchQuery.toLowerCase();
-
- const filteredGlobal = dummyGlobalIssues.filter(
- (item) =>
- item.title.toLowerCase().includes(lowerQuery) ||
- item.tag.toLowerCase().includes(lowerQuery)
- );
-
- const filteredActivities = dummyActivities.filter(
- (item) =>
- item.title.toLowerCase().includes(lowerQuery) ||
- item.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
- );
-
- const toggleGlobalBookmark = (id) => {
- setBookmarkedGlobalIds((prev) =>
- prev.includes(id) ? prev.filter((bid) => bid !== id) : [...prev, id]
- );
+ const handleGlobalBookmark = (id) => {
+ toggleIssueBookmark.mutate(id, {
+ onSuccess: () => {
+ // 검색 결과 업데이트
+ setSearchResults(prevResults =>
+ prevResults.map(issue =>
+ issue.id === id
+ ? { ...issue, bookmarked: !issue.bookmarked }
+ : issue
+ )
+ );
+ // 관련 쿼리 무효화
+ queryClient.invalidateQueries(['issues']);
+ queryClient.invalidateQueries(['bookmarkedIssues']);
+ }
+ });
};
- const toggleActivityBookmark = (id) => {
- setBookmarkedActivityIds((prev) =>
- prev.includes(id) ? prev.filter((bid) => bid !== id) : [...prev, id]
+ // 활동 북마크 토글 함수 수정 (글로벌 이슈 상세페이지 참고)
+ const handleActivityBookmark = async (activityId) => {
+ // 즉시 UI 업데이트 (optimistic update)
+ setActivityResults(prevResults =>
+ prevResults.map(activity =>
+ activity.activityId === activityId
+ ? { ...activity, bookmarked: !activity.bookmarked }
+ : activity
+ )
);
+
+ // 서버 요청
+ try {
+ await toggleActivityBookmark.mutateAsync(activityId);
+
+ // 관련 쿼리 무효화
+ queryClient.invalidateQueries(['activities']);
+ queryClient.invalidateQueries(['bookmarkedActivities']);
+ } catch (error) {
+ // 실패 시 롤백
+ setActivityResults(prevResults =>
+ prevResults.map(activity =>
+ activity.activityId === activityId
+ ? { ...activity, bookmarked: !activity.bookmarked } // 다시 원래대로
+ : activity
+ )
+ );
+ console.error('북마크 토글 실패:', error);
+ }
};
return (
@@ -93,55 +132,86 @@ export default function SearchResultPage() {
{searchQuery && (
<>
- ‘{searchQuery}’의 검색 결과
+ '{searchQuery}'의 검색 결과
- {filteredGlobal.length === 0 && filteredActivities.length === 0 ? (
+ {loading ? (
+ 검색 중...
+ ) : searchResults.length === 0 && activityResults.length === 0 ? (
검색 결과가 없습니다.
) : (
<>
- {filteredGlobal.length > 0 && (
+ {searchResults.length > 0 && (
글로벌 이슈
- navigate(`/more/global?query=${searchQuery}`)}>
- 더보기 >
-
+ {searchResults.length > 5 && (
+ navigate(`/more/global?query=${searchQuery}`)}>
+ 더보기 >
+
+ )}
- {filteredGlobal.map((item) => (
+ {searchResults.slice(0, 4).map((issue) => (
toggleGlobalBookmark(item.id)}
+ key={issue.id}
+ id={issue.id}
+ title={issue.title}
+ tag={`#${issue.keyword}` || '#카테고리 없음'}
+ image={issue.imageUrl || ''}
+ bookmarked={issue.bookmarked}
+ onToggle={() => handleGlobalBookmark(issue.id)}
+ onClick={() => navigate(`/global-issue/${issue.id}`, {
+ state: { label: issue.keyword, title: issue.title }
+ })}
/>
))}
)}
- {filteredActivities.length > 0 && (
+ {activityResults.length > 0 && (
활동
- navigate(`/more/activity?query=${searchQuery}`)}>
- 더보기 >
-
+ {activityResults.length > 4 && (
+ navigate(`/more/activity?query=${searchQuery}`)}>
+ 더보기 >
+
+ )}
- {filteredActivities.map((item) => (
- toggleActivityBookmark(item.id)}
- />
- ))}
+ {activityResults.slice(0, 4).map((activity) => {
+ // 태그 배열 생성 (키워드와 액티비티 타입 포함)
+ const tags = [];
+ if (activity.keyword) {
+ tags.push(`#${activity.keyword}`);
+ }
+ if (activity.activityType) {
+ // activityType을 한글로 변환
+ const typeMap = {
+ 'VOLUNTEER': '봉사활동',
+ 'CONTEST': '공모전',
+ 'SUPPORTERS': '서포터즈',
+ 'INTERNSHIP': '인턴십'
+ };
+ tags.push(`#${typeMap[activity.activityType] || activity.activityType}`);
+ }
+
+ return (
+ handleActivityBookmark(activity.activityId)}
+ isClosed={new Date() > new Date(activity.endDate)} // 마감 여부 확인
+ siteUrl={activity.siteUrl || 'https://naver.com'}
+ />
+ );
+ })}
)}
@@ -218,3 +288,10 @@ const NoResult = styled.p`
color: #999;
margin-top: 40px;
`;
+
+const LoadingMessage = styled.p`
+ text-align: center;
+ font-size: 18px;
+ color: #666;
+ margin-top: 40px;
+`;
diff --git a/src/pages/my/MyPage.jsx b/src/pages/my/MyPage.jsx
index b720190..a4785e2 100644
--- a/src/pages/my/MyPage.jsx
+++ b/src/pages/my/MyPage.jsx
@@ -156,12 +156,13 @@ export default function MyPage() {
}
}, [location.state]);
- const dummyTypeStats = [
- { activityType: 'CONTEST', count: 4 },
- { activityType: 'VOLUNTEER', count: 2 },
- { activityType: 'INTERNSHIP', count: 1 },
- { activityType: 'SUPPORTERS', count: 0 },
- ];
+ // ActivityTypeChart 컴포넌트에서 직접 API를 호출하므로 더이상 필요하지 않음
+ // const dummyTypeStats = [
+ // { activityType: 'CONTEST', count: 4 },
+ // { activityType: 'VOLUNTEER', count: 2 },
+ // { activityType: 'INTERNSHIP', count: 1 },
+ // { activityType: 'SUPPORTERS', count: 0 },
+ // ];
const handleTabClick = (tabName) => {
console.log('탭 클릭됨:', tabName);
@@ -271,7 +272,7 @@ export default function MyPage() {
{activeTab === 'statistics' && (
-
+
)}
{activeTab === 'bookmark' && }
@@ -420,7 +421,7 @@ const CardTitle = styled.h4`
`;
const LevelImage = styled.img`
- width: 60px;
+ width: 67px;
height: 60px;
margin-bottom: 8px;
`;
diff --git a/src/pages/my/ProfileEditPage.jsx b/src/pages/my/ProfileEditPage.jsx
index d370774..198a75e 100644
--- a/src/pages/my/ProfileEditPage.jsx
+++ b/src/pages/my/ProfileEditPage.jsx
@@ -1,50 +1,149 @@
-import React, { useState, useRef } from 'react';
+import React, { useState, useRef, useEffect } from 'react';
import styled from 'styled-components';
import { useNavigate } from 'react-router-dom';
import InterestSelect from '../../components/interest/InterestSelect';
import MainNav from '../../layout/MainNav';
import Footer from '../../layout/Footer';
+import { updateUserProfile, getUserProfile, logout, uploadProfileImage } from '../../api/userApi';
import judyIcon from '../../assets/images/level/ic_Judy.png';
import cameraIcon from '../../assets/images/profile/ic_ProfileCamera.png';
export default function ProfileEditPage() {
const [nickname, setNickname] = useState('');
const [interest, setInterest] = useState('');
- const [profileImg, setProfileImg] = useState(() => {
- const savedImage = localStorage.getItem('profileImg');
- return savedImage ? savedImage : judyIcon;
- });
+ const [profileImg, setProfileImg] = useState(judyIcon);
+ const [profileImageFile, setProfileImageFile] = useState(null); // 선택된 파일 저장
+ const [isLoading, setIsLoading] = useState(false);
+ const [isImageUploading, setIsImageUploading] = useState(false);
const fileInputRef = useRef();
const navigate = useNavigate();
+ // 현재 프로필 정보를 불러오기
+ useEffect(() => {
+ const loadProfile = async () => {
+ try {
+ const profile = await getUserProfile();
+ setNickname(profile.nickname || '');
+ setInterest(profile.keyword || '');
+ setProfileImg(profile.profileUrl || judyIcon);
+ } catch (error) {
+ console.error('프로필 로드 실패:', error);
+ // 실패시 localStorage에서 가져오기 (fallback)
+ setNickname(localStorage.getItem('nickname') || '');
+ setInterest(localStorage.getItem('keyword') || '');
+ const savedImage = localStorage.getItem('profileImg');
+ setProfileImg(savedImage || judyIcon);
+ }
+ };
+
+ loadProfile();
+ }, []);
+
const handleCameraClick = () => fileInputRef.current?.click();
- const handleFileChange = (e) => {
+ const handleFileChange = async (e) => {
const file = e.target.files[0];
if (file) {
- const reader = new FileReader();
- reader.onloadend = () => {
- setProfileImg(reader.result);
- localStorage.setItem('profileImg', reader.result); // 저장
- };
- reader.readAsDataURL(file);
+ // 파일 크기 체크 (5MB 제한)
+ if (file.size > 5 * 1024 * 1024) {
+ alert('이미지 파일 크기는 5MB 이하만 업로드 가능합니다.');
+ return;
+ }
+
+ // 파일 형식 체크
+ if (!file.type.startsWith('image/')) {
+ alert('이미지 파일만 업로드 가능합니다.');
+ return;
+ }
+
+ setIsImageUploading(true);
+
+ try {
+ console.log('이미지 업로드 시작:', file);
+
+ // S3에 이미지 업로드
+ const uploadedImageUrl = await uploadProfileImage(file);
+
+ console.log('업로드된 이미지 URL:', uploadedImageUrl);
+
+ // 업로드된 URL을 상태에 저장
+ setProfileImg(uploadedImageUrl);
+ setProfileImageFile(file); // 파일도 저장 (미리보기용)
+
+ alert('이미지가 성공적으로 업로드되었습니다.');
+
+ } catch (error) {
+ console.error('이미지 업로드 실패:', error);
+ alert(`이미지 업로드에 실패했습니다.\n${error.message}`);
+ } finally {
+ setIsImageUploading(false);
+ }
} else {
setProfileImg(judyIcon);
- localStorage.removeItem('profileImg'); //지우기
+ setProfileImageFile(null);
}
};
- const handleComplete = () => {
- if (window.confirm(`${nickname} / 관심분야: ${interest}\n\n위 정보로 수정하시겠습니까?`)) {
- localStorage.setItem('nickname', nickname);
- localStorage.setItem('keyword', interest);
- localStorage.setItem('profileUrl', profileImg);
+ const handleComplete = async () => {
+ if (!nickname.trim()) {
+ alert('닉네임을 입력해주세요.');
+ return;
+ }
+
+ if (!interest) {
+ alert('관심분야를 선택해주세요.');
+ return;
+ }
+
+ if (!window.confirm(`닉네임: ${nickname}\n관심분야: ${interest}\n\n위 정보로 수정하시겠습니까?`)) {
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const updateData = {
+ nickname: nickname.trim(),
+ keyword: interest,
+ profileUrl: profileImg === judyIcon ? null : profileImg
+ };
+
+ console.log('프로필 수정 요청:', updateData);
+
+ const result = await updateUserProfile(updateData);
+
+ console.log('프로필 수정 성공:', result);
+
+ // localStorage도 업데이트 (동기화)
+ localStorage.setItem('nickname', result.nickname);
+ localStorage.setItem('keyword', result.keyword);
+ if (result.profileUrl) {
+ localStorage.setItem('profileUrl', result.profileUrl);
+ }
+
+ alert('프로필이 성공적으로 수정되었습니다.');
navigate('/mypage');
+
+ } catch (error) {
+ console.error('프로필 수정 실패:', error);
+ alert(`프로필 수정에 실패했습니다.\n${error.message}`);
+ } finally {
+ setIsLoading(false);
}
};
- const handleLogout = () => {
- navigate('/');
+ const handleLogout = async () => {
+ if (window.confirm('정말 로그아웃 하시겠습니까?')) {
+ try {
+ await logout();
+ alert('로그아웃되었습니다.');
+ navigate('/');
+ } catch (error) {
+ console.error('로그아웃 처리 중 오류:', error);
+ // 에러가 발생해도 로그아웃 처리
+ navigate('/');
+ }
+ }
};
return (
@@ -54,14 +153,19 @@ export default function ProfileEditPage() {
-
+
+ {isImageUploading && 업로드 중...}
@@ -75,7 +179,12 @@ export default function ProfileEditPage() {
관심분야 선택
- 수정 완료
+
+ {isLoading ? '수정 중...' : '수정 완료'}
+
로그아웃