diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7b426b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +npm-debug.log +.npm +.nyc_output +.coverage +.cache +.git +.github +README.md +.env +.DS_Store +*.log \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..eabb1ae --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,64 @@ +name: Deploy to Kubernetes + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test -- --coverage --watchAll=false + + - name: Build application + run: npm run build + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Container Registry + uses: docker/login-action@v2 + with: + registry: ${{ secrets.REGISTRY_URL }} # Docker Registry URL + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ${{ secrets.REGISTRY_URL }}/trendist-frontend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Set up kubectl + uses: azure/setup-kubectl@v3 + with: + version: 'latest' + + - name: Configure kubectl + run: | + echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config + + - name: Update deployment + run: | + sed -i 's|leeseojung/trendist-frontend:latest|${{ secrets.REGISTRY_URL }}/trendist-frontend:${{ github.sha }}|' k8s/deployment.yaml + kubectl apply -f k8s/ + kubectl rollout status deployment/trendist-frontend \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4d29575..8692cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # misc .DS_Store +.env .env.local .env.development.local .env.test.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..31ac5e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM node:18-alpine as build + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Set environment variables for build +ENV REACT_APP_API_BASE_URL=http://trendist.site +ENV REACT_APP_FALLBACK_API_URL=http://trendist.site + +# Build the app +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy build files to nginx +COPY --from=build /app/build /usr/share/nginx/html + +# Copy nginx configuration (optional) +# COPY nginx.conf /etc/nginx/nginx.conf + +# Expose port 80 +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..4acf39c --- /dev/null +++ b/deploy.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 환경 변수 설정 (필요에 따라 수정) +REGISTRY_URL="leeseojung" # Docker Hub 사용자명 +IMAGE_NAME="trendist-frontend" +TAG="latest" + +echo -e "${YELLOW}🚀 TRENDIST 프론트엔드 배포를 시작합니다...${NC}" + +# 1. 애플리케이션 빌드 +echo -e "${YELLOW}📦 애플리케이션을 빌드 중...${NC}" +npm run build + +if [ $? -ne 0 ]; then + echo -e "${RED}❌ 빌드에 실패했습니다.${NC}" + exit 1 +fi + +# 2. Docker 이미지 빌드 +echo -e "${YELLOW}🐳 Docker 이미지를 빌드 중...${NC}" +docker build -t ${REGISTRY_URL}/${IMAGE_NAME}:${TAG} . + +if [ $? -ne 0 ]; then + echo -e "${RED}❌ Docker 이미지 빌드에 실패했습니다.${NC}" + exit 1 +fi + +# 3. Docker 이미지 푸시 +echo -e "${YELLOW}📤 Docker 이미지를 레지스트리에 푸시 중...${NC}" +docker push ${REGISTRY_URL}/${IMAGE_NAME}:${TAG} + +if [ $? -ne 0 ]; then + echo -e "${RED}❌ Docker 이미지 푸시에 실패했습니다.${NC}" + exit 1 +fi + +# 4. Kubernetes에 배포 +echo -e "${YELLOW}☸️ Kubernetes에 배포 중...${NC}" +kubectl apply -f k8s/ + +if [ $? -ne 0 ]; then + echo -e "${RED}❌ Kubernetes 배포에 실패했습니다.${NC}" + exit 1 +fi + +# 5. 배포 상태 확인 +echo -e "${YELLOW}⏳ 배포 상태를 확인 중...${NC}" +kubectl rollout status deployment/trendist-frontend + +if [ $? -eq 0 ]; then + echo -e "${GREEN}✅ 배포가 성공적으로 완료되었습니다!${NC}" + echo -e "${GREEN}🌐 서비스 정보:${NC}" + kubectl get pods -l app=trendist-frontend + kubectl get svc trendist-frontend-service +else + echo -e "${RED}❌ 배포 확인에 실패했습니다.${NC}" + exit 1 +fi \ No newline at end of file diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..3eb4774 --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,102 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: trendist-frontend-config +data: + REACT_APP_API_BASE_URL: "http://trendist.site" + REACT_APP_FALLBACK_API_URL: "http://trendist.site" + NODE_ENV: "production" + default.conf: | + server { + listen 80; + server_name trendist.site; + root /usr/share/nginx/html; + index index.html; + + # 가장 구체적인 API 경로들을 먼저 처리 + location /users/ { + proxy_pass http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/users/; + proxy_set_header Host k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /global-issues { + proxy_pass http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/global-issues; + proxy_set_header Host k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /activities/ { + proxy_pass http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/activities/; + proxy_set_header Host k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /reviews/ { + proxy_pass http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/reviews/; + proxy_set_header Host k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /posts/ { + proxy_pass http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/posts/; + proxy_set_header Host k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /rankings/ { + proxy_pass http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/rankings/; + proxy_set_header Host k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # API 프록시 설정 + location /api/ { + proxy_pass http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/; + proxy_set_header Host k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/ http://trendist.site/; + } + + # OAuth 콜백 처리 + location /login/oauth2/code/google { + proxy_pass http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/login/oauth2/code/google; + proxy_set_header Host k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect default; + proxy_redirect ~^http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2\.ke\.kr-central-2\.kakaocloud\.com(.*)$ http://trendist.site$1; + } + + # OAuth 인증 처리 + location /oauth2/ { + proxy_pass http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/oauth2/; + proxy_set_header Host k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/ http://trendist.site/; + } + + + + # 정적 파일 서빙 + location / { + try_files $uri $uri/ /index.html; + } + } \ No newline at end of file diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..c5fcc18 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: trendist-frontend + labels: + app: trendist-frontend +spec: + replicas: 2 + selector: + matchLabels: + app: trendist-frontend + template: + metadata: + labels: + app: trendist-frontend + spec: + containers: + - name: trendist-frontend + image: leeseojung/trendist-frontend:v1.1.21 + imagePullPolicy: Always + ports: + - containerPort: 80 + resources: + requests: + memory: "64Mi" + cpu: "250m" + limits: + memory: "128Mi" + cpu: "500m" + env: + - name: NODE_ENV + value: "production" + envFrom: + - configMapRef: + name: trendist-frontend-config + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/conf.d/ + readOnly: true + volumes: + - name: nginx-config + configMap: + name: trendist-frontend-config \ No newline at end of file diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..f344a4c --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: trendist-frontend-ingress + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + # SSL 인증서가 있다면 아래 주석 해제 + # cert-manager.io/cluster-issuer: "letsencrypt-prod" +spec: + ingressClassName: nginx + # SSL 설정 (필요시) + # tls: + # - hosts: + # - your-domain.com + # secretName: trendist-tls + rules: + - host: trendist.site + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: trendist-frontend-service + port: + number: 80 \ No newline at end of file diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..8132374 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: trendist-frontend-service + labels: + app: trendist-frontend +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + selector: + app: trendist-frontend \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 90f1f6e..4a6e6d3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,7 @@ // src/App.js import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +//import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import AppRoutes from './routes/Route'; const queryClient = new QueryClient({ @@ -22,10 +22,10 @@ export default function App() { return ( - {/* 개발 환경에서만 DevTools 표시 */} + {/* 개발 환경에서만 DevTools 표시 {process.env.NODE_ENV === 'development' && ( - )} + )}*/} ); } diff --git a/src/api/ActivitiesApi.js b/src/api/ActivitiesApi.js index b581a7b..aa208bc 100644 --- a/src/api/ActivitiesApi.js +++ b/src/api/ActivitiesApi.js @@ -3,7 +3,7 @@ import api from './axiosInstance'; // 키워드 및 타입 매핑 const KEYWORD_MAP = { '환경': 'Environment', - '사람과사회': 'PeopleAndSociety', + '사람과 사회': 'PeopleAndSociety', '경제': 'Economy', '기술': 'Technology' }; @@ -17,7 +17,7 @@ const ACTIVITY_TYPE_MAP = { const REVERSE_KEYWORD_MAP = { 'Environment': '환경', - 'PeopleAndSociety': '사람과사회', + 'PeopleAndSociety': '사람과 사회', 'Economy': '경제', 'Technology': '기술' }; @@ -30,14 +30,14 @@ const REVERSE_TYPE_MAP = { }; class ActivitiesApi { - // 전체/필터 활동 목록 조회 (기존) + // 전체/필터 활동 목록 조회 (다중 필터 지원) async getActivities(params = {}) { try { const { page = 0, fieldFilter, typeFilter } = params; const queryParams = new URLSearchParams(); queryParams.append('page', page.toString()); - // 키워드 필터 추가 + // 키워드 필터 추가 (전체가 아닌 경우) if (fieldFilter && fieldFilter !== '전체') { const mappedKeyword = KEYWORD_MAP[fieldFilter]; if (mappedKeyword) { @@ -45,7 +45,7 @@ class ActivitiesApi { } } - // 활동 타입 필터 추가 (쿼리 방식) + // 활동 타입 필터 추가 (전체가 아닌 경우) if (typeFilter && typeFilter !== '전체') { const mappedType = ACTIVITY_TYPE_MAP[typeFilter]; if (mappedType) { @@ -122,6 +122,27 @@ class ActivitiesApi { } } + // 키워드별 활동 4개 조회 (마감 기한 내) +async getActivitiesByKeywordLimited(keyword) { + try { + const mappedKeyword = KEYWORD_MAP[keyword] || keyword; + + const response = await api.get(`/activities/keyword/${mappedKeyword}`); + if (!response.data.isSuccess) { + throw new Error(response.data.message || '키워드별 활동 조회에 실패했습니다.'); + } + + const activities = response.data.result; + const transformedActivities = activities.map(this.transformActivity); + + return transformedActivities; + } catch (error) { + console.error('키워드별 활동 4개 조회 실패:', error); + throw this.handleApiError(error); + } +} + + // 북마크 토글 async toggleBookmark(activityId) { try { @@ -143,7 +164,14 @@ class ActivitiesApi { if (!response.data.isSuccess) { throw new Error(response.data.message || '북마크한 활동글 조회에 실패했습니다.'); } - return response.data.result; + + const result = response.data.result; + const transformedContent = result.content.map(this.transformActivity); + + return { + ...result, + content: transformedContent + }; } catch (error) { console.error('북마크한 활동글 조회 실패:', error); throw this.handleApiError(error); @@ -154,10 +182,12 @@ class ActivitiesApi { transformActivity = (activity) => { const koreanKeyword = REVERSE_KEYWORD_MAP[activity.keyword] || activity.keyword; const koreanType = REVERSE_TYPE_MAP[activity.activityType] || activity.activityType; + const formatDate = (dateString) => { if (!dateString) return ''; return dateString.split('T')[0].replace(/-/g, '.'); }; + const now = new Date(); const endDate = new Date(activity.endDate); const isClosed = endDate < now; @@ -181,6 +211,7 @@ class ActivitiesApi { }; }; + // API 에러 처리 handleApiError = (error) => { if (error.response) { const status = error.response.status; @@ -203,3 +234,4 @@ class ActivitiesApi { const activitiesApi = new ActivitiesApi(); export default activitiesApi; +export { KEYWORD_MAP, ACTIVITY_TYPE_MAP, REVERSE_KEYWORD_MAP, REVERSE_TYPE_MAP }; diff --git a/src/api/ChatBotApi.js b/src/api/ChatBotApi.js new file mode 100644 index 0000000..125e9b9 --- /dev/null +++ b/src/api/ChatBotApi.js @@ -0,0 +1,270 @@ +import axios from 'axios'; + +// 챗봇 전용 axios 인스턴스 생성 +const chatbotInstance = axios.create({ + baseURL: 'http://central-01.tcp.tunnel.elice.io:50643', + timeout: 30000, // 챗봇 응답은 시간이 좀 걸릴 수 있어서 15초로 설정 + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, +}); + +// 요청 인터셉터에서 토큰 추가 +chatbotInstance.interceptors.request.use(config => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}, error => { + console.error('챗봇 요청 설정 오류:', error); + return Promise.reject(error); +}); + +// 응답 인터셉터 +chatbotInstance.interceptors.response.use( + response => response, + error => { + if (error.response) { + console.error('챗봇 서버 응답 오류:', error.response.data); + } else if (error.request) { + console.error('챗봇 서버 응답 없음:', error.request); + } else { + console.error('챗봇 요청 오류:', error.message); + } + return Promise.reject(error); + } +); + +// 챗봇 질문 API +export const askChatbot = async (userId, question) => { + try { + console.log('챗봇 질문 요청:', { userId, question }); + + const response = await chatbotInstance.get(`/chatbot/${userId}/web`, { + params: { + question: question, + request: 'ask' + }, + timeout: 30000 // 30초로 타임아웃 연장 + }); + + console.log('챗봇 응답:', response.data); + + return response.data.answer; + } catch (error) { + console.error('챗봇 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 챗봇 대화 초기화 API +export const resetChatbot = async (userId) => { + try { + console.log('챗봇 대화 초기화 요청:', { userId }); + + const response = await chatbotInstance.get(`/chatbot/${userId}/web`, { + params: { + question: '', + request: 'reset' + }, + timeout: 30000 // 30초로 타임아웃 연장 + }); + + console.log('챗봇 초기화 응답:', response.data); + + return response.data.answer; + } catch (error) { + console.error('챗봇 초기화 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 관심사 기반 추천 챗봇 질문 API +export const askChatbotHistoryRecommendation = async (userId, question) => { + try { + console.log('관심사 기반 추천 챗봇 질문 요청:', { userId, question }); + + const response = await chatbotInstance.get(`/chatbot/${userId}/history-recommendation`, { + params: { + question: question, + request: 'ask' + }, + timeout: 30000 // 30초로 타임아웃 연장 + }); + + console.log('관심사 기반 추천 챗봇 응답:', response.data); + + return response.data.answer; + } catch (error) { + console.error('관심사 기반 추천 챗봇 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 관심사 기반 추천 챗봇 대화 초기화 API +export const resetChatbotHistoryRecommendation = async (userId) => { + try { + console.log('관심사 기반 추천 챗봇 대화 초기화 요청:', { userId }); + + const response = await chatbotInstance.get(`/chatbot/${userId}/history-recommendation`, { + params: { + question: '', + request: 'reset' + }, + timeout: 30000 // 30초로 타임아웃 연장 + }); + + console.log('관심사 기반 추천 챗봇 초기화 응답:', response.data); + + return response.data.answer; + } catch (error) { + console.error('관심사 기반 추천 챗봇 초기화 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 분야 기반(키워드) 추천 챗봇 질문 API +export const askChatbotKeywordRecommendation = async (userId, question) => { + try { + console.log('분야 기반 추천 챗봇 질문 요청:', { userId, question }); + + const response = await chatbotInstance.get(`/chatbot/${userId}/keyword-recommendation`, { + params: { + question: question, + request: 'ask' + }, + timeout: 30000 // 30초로 타임아웃 연장 + }); + + console.log('분야 기반 추천 챗봇 응답:', response.data); + + return response.data.answer; + } catch (error) { + console.error('분야 기반 추천 챗봇 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 분야 기반(키워드) 추천 챗봇 대화 초기화 API +export const resetChatbotKeywordRecommendation = async (userId) => { + try { + console.log('분야 기반 추천 챗봇 대화 초기화 요청:', { userId }); + + const response = await chatbotInstance.get(`/chatbot/${userId}/keyword-recommendation`, { + params: { + question: '', + request: 'reset' + }, + timeout: 30000 // 30초로 타임아웃 연장 + }); + + console.log('분야 기반 추천 챗봇 초기화 응답:', response.data); + + return response.data.answer; + } catch (error) { + console.error('분야 기반 추천 챗봇 초기화 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 일상 대화(기타 질문) 챗봇 질문 API +export const askChatbotOthers = async (userId, question) => { + try { + console.log('일상 대화 챗봇 질문 요청:', { userId, question }); + + const response = await chatbotInstance.get(`/chatbot/${userId}/others`, { + params: { + question: question, + request: 'ask' + }, + timeout: 30000 // 30초로 타임아웃 연장 + }); + + console.log('일상 대화 챗봇 응답:', response.data); + + return response.data.answer; + } catch (error) { + console.error('일상 대화 챗봇 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 일상 대화(기타 질문) 챗봇 대화 초기화 API +export const resetChatbotOthers = async (userId) => { + try { + console.log('일상 대화 챗봇 대화 초기화 요청:', { userId }); + + const response = await chatbotInstance.get(`/chatbot/${userId}/others`, { + params: { + question: '', + request: 'reset' + }, + timeout: 30000 // 30초로 타임아웃 연장 + }); + + console.log('일상 대화 챗봇 초기화 응답:', response.data); + + return response.data.answer; + } catch (error) { + console.error('일상 대화 챗봇 초기화 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; diff --git a/src/api/IssueApi.js b/src/api/IssueApi.js new file mode 100644 index 0000000..d1ccd1d --- /dev/null +++ b/src/api/IssueApi.js @@ -0,0 +1,183 @@ +// api/IssuesApi.js +import api from './axiosInstance'; + +const KEYWORD_MAP = { + '환경': 'Environment', + '사람과 사회': 'PeopleAndSociety', + '경제': 'Economy', + '기술': 'Technology' +}; + +const REVERSE_KEYWORD_MAP = { + 'Environment': '환경', + 'PeopleAndSociety': '사람과 사회', + 'Economy': '경제', + 'Technology': '기술' +}; + +class IssuesApi { + // 전체 이슈 조회 + async getIssues(page = 0) { + try { + const response = await api.get(`/issues?page=${page}`); + if (!response.data.isSuccess) { + throw new Error(response.data.message || 'API 요청이 실패했습니다.'); + } + + const result = response.data.result; + const transformedContent = result.content.map(this.transformIssue); + + return { + ...result, + content: transformedContent + }; + } catch (error) { + console.error('이슈 목록 조회 실패:', error); + throw this.handleApiError(error); + } + } + + async getIssuesByKeyword({ keyword, page = 0 }) { + try { + // keyword가 객체인 경우 처리 + const keywordValue = typeof keyword === 'object' ? keyword.value || keyword.keyword : keyword; + const mappedKeyword = KEYWORD_MAP[keywordValue] || keywordValue; + + const response = await api.get(`/issues/keyword/${mappedKeyword}?page=${page}`); + if (!response.data.isSuccess) { + throw new Error(response.data.message || '키워드별 이슈 조회에 실패했습니다.'); + } + + const result = response.data.result; + const transformedContent = result.content.map(this.transformIssue); + + return { + ...result, + content: transformedContent + }; + } catch (error) { + console.error('키워드별 이슈 조회 실패:', error); + throw this.handleApiError(error); + } + } + + // 특정 이슈 상세 조회 +async getIssueDetail(issueId) { + try { + const response = await api.get(`/issues/${issueId}`); + if (!response.data.isSuccess) { + throw new Error(response.data.message || '이슈 상세 조회에 실패했습니다.'); + } + + const issue = response.data.result; + const koreanKeyword = REVERSE_KEYWORD_MAP[issue.keyword] || issue.keyword; + + return { + id: issue.id, + title: issue.title, + content: issue.content, + issueDate: issue.issueDate, + siteUrl: issue.siteUrl, + imageUrl: issue.imageUrl || '/assets/images/main/ic_NoImage.png', + keyword: koreanKeyword, + category: `${koreanKeyword}`, + bookmarked: issue.bookmarked || false + }; + } catch (error) { + console.error('이슈 상세 조회 실패:', error); + throw this.handleApiError(error); + } +} + + // 이슈 북마크 토글 + async toggleIssueBookmark(issueId) { + try { + const response = await api.post(`/issues/${issueId}/bookmark`); + if (!response.data.isSuccess) { + throw new Error(response.data.message || '북마크 처리에 실패했습니다.'); + } + return response.data.result; + } catch (error) { + console.error('이슈 북마크 토글 실패:', error); + throw this.handleApiError(error); + } + } + +// 북마크된 이슈 목록 조회 +async getBookmarkedIssues(page = 0) { + try { + const response = await api.get(`/profile/issues/bookmark?page=${page}`); + if (response.data.isSuccess) { + return { + ...response.data.result, + content: response.data.result.content.map(issue => { + const koreanKeyword = REVERSE_KEYWORD_MAP[issue.keyword] || issue.keyword; + + return { + id: issue.issueId, + title: issue.title, + category: `#${koreanKeyword}`, + date: issue.issueDate, + bookmarkId: issue.bookmarkId + }; + }) + }; + } + throw new Error(response.data.message || '북마크한 이슈 조회에 실패했습니다.'); + } catch (error) { + console.error('북마크한 이슈 조회 에러:', error); + throw error; + } +} + + // 북마크된 이슈 전용 변환 메서드 추가 + transformBookmarkedIssue = (bookmark) => { + const koreanKeyword = REVERSE_KEYWORD_MAP[bookmark.keyword] || bookmark.keyword; + + return { + bookmarkId: bookmark.bookmarkId, + id: bookmark.issueId, + title: bookmark.title, + category: `#${koreanKeyword}`, + keyword: koreanKeyword, + issueDate: bookmark.issueDate, + bookmarked: true + }; + }; + transformIssue = (issue) => { + const koreanKeyword = REVERSE_KEYWORD_MAP[issue.keyword] || issue.keyword; + + return { + id: issue.id, + title: issue.title, + category: `#${koreanKeyword}`, + keyword: koreanKeyword, + thumbnailUrl: issue.imageUrl || '/assets/images/main/ic_NoImage.png', + bookmarked: issue.bookmarked || false + }; + }; + + handleApiError = (error) => { + if (error.response) { + const status = error.response.status; + const message = error.response.data?.message || error.message; + switch (status) { + case 400: return new Error('잘못된 요청입니다.'); + case 401: return new Error('로그인이 필요합니다.'); + case 403: return new Error('접근 권한이 없습니다.'); + case 404: return new Error('요청한 데이터를 찾을 수 없습니다.'); + case 500: return new Error('서버 오류가 발생했습니다.'); + default: return new Error(message); + } + } else if (error.request) { + return new Error('네트워크 연결을 확인해주세요.'); + } else { + return new Error(error.message || '알 수 없는 오류가 발생했습니다.'); + } + }; +} + + + +const issuesApi = new IssuesApi(); +export default issuesApi; diff --git a/src/api/MainSearchApi.js b/src/api/MainSearchApi.js new file mode 100644 index 0000000..287258f --- /dev/null +++ b/src/api/MainSearchApi.js @@ -0,0 +1,112 @@ +import axiosInstance from './axiosInstance'; + +const KEYWORD_MAP = { + '환경': 'Environment', + '사람과 사회': 'PeopleAndSociety', + '경제': 'Economy', + '기술': 'Technology' +}; + +const ACTIVITY_TYPE_MAP = { + '봉사활동': 'VOLUNTEER', + '공모전': 'CONTEST', + '서포터즈': 'SUPPORTERS', + '인턴십': 'INTERNSHIP' +}; + +const REVERSE_KEYWORD_MAP = { + 'Environment': '환경', + 'PeopleAndSociety': '사람과 사회', + 'Economy': '경제', + 'Technology': '기술' +}; + +const REVERSE_TYPE_MAP = { + 'VOLUNTEER': '봉사활동', + 'CONTEST': '공모전', + 'SUPPORTERS': '서포터즈', + 'INTERNSHIP': '인턴십' +}; + +export const searchIssues = async ({ keyword, page = 0, size = 4 }) => { + const response = await axiosInstance.get('/issues/search', { + params: { + keyword, + page, + size + } + }); + + if (response.data.isSuccess) { + response.data.result.content = response.data.result.content.map(issue => ({ + ...issue, + keyword: REVERSE_KEYWORD_MAP[issue.keyword] || issue.keyword + })); + } + + return response.data; +}; + +export const searchAllIssues = async ({ keyword, page = 0, size = 12 }) => { + const response = await axiosInstance.get('/issues/search', { + params: { + keyword, + page, + size + } + }); + + if (response.data.isSuccess) { + response.data.result.content = response.data.result.content.map(issue => ({ + ...issue, + keyword: REVERSE_KEYWORD_MAP[issue.keyword] || issue.keyword + })); + } + + return response.data; +}; + +export const toggleBookmark = async (issueId) => { + const response = await axiosInstance.post(`/issues/${issueId}/bookmark`); + return response.data; +}; + +export const searchActivities = async ({ keyword, activityType, page = 0, size = 4 }) => { + const response = await axiosInstance.get('/activities/search', { + params: { + keyword, + page, + size + } + }); + + if (response.data.isSuccess) { + response.data.result.content = response.data.result.content.map(activity => ({ + ...activity, + keyword: REVERSE_KEYWORD_MAP[activity.keyword] || activity.keyword, + activityType: REVERSE_TYPE_MAP[activity.activityType] || activity.activityType + })); + } + + return response.data; +}; + +export const searchAllActivities = async ({ keyword, activityType, page = 0, size = 12 }) => { + const response = await axiosInstance.get('/activities/search', { + params: { + keyword, + page, + size + } + }); + + if (response.data.isSuccess) { + response.data.result.content = response.data.result.content.map(activity => ({ + ...activity, + keyword: REVERSE_KEYWORD_MAP[activity.keyword] || activity.keyword, + activityType: REVERSE_TYPE_MAP[activity.activityType] || activity.activityType + })); + } + + return response.data; +}; \ No newline at end of file diff --git a/src/api/PostApi.js b/src/api/PostApi.js new file mode 100644 index 0000000..6c5388d --- /dev/null +++ b/src/api/PostApi.js @@ -0,0 +1,1000 @@ +import axiosInstance from './axiosInstance'; + +// 카테고리 매핑 +export const CATEGORY_MAP = { + '환경': 'Environment', + '사람과 사회': 'PeopleAndSociety', + '경제': 'Economy', + '기술': 'Technology' +}; + +// 카테고리 매핑 (영문 -> 한국어) +export const REVERSE_CATEGORY_MAP = { + 'Environment': '환경', + 'PeopleAndSociety': '사람과 사회', + 'Economy': '경제', + 'Technology': '기술' +}; + +export const ACTIVITY_TYPE_MAP = { + '공모전': 'CONTEST', + '봉사활동': 'VOLUNTEER', + '인턴십': 'INTERNSHIP', + '서포터즈': 'SUPPORTERS' +}; + +export const ACTIVITY_PERIOD_MAP = { + '하루': 'OneDay', + '일주일': 'OneWeek', + '1개월': 'OneMonth', + '6개월 이내': 'WithinSixMonths', + '6개월 이상': 'OverSixMonths' +}; + +export const createPost = async (postData) => { + try { + // imageUrls를 포함한 전체 데이터를 한 번에 전송 + const response = await axiosInstance.post('/posts', postData); + + if (response.data.isSuccess) { + console.log('게시물과 이미지가 성공적으로 생성되었습니다.'); + return response.data.result; + } else { + throw new Error(response.data.message || '게시물 작성에 실패했습니다.'); + } + } catch (error) { + console.error('자유 게시판 작성 API 호출 실패:', error); + throw error; + } +}; + +// 후기 게시판 게시물 생성 (이미지 URL 개별 처리) +export const createReview = async (reviewData) => { + try { + // imageUrls 배열을 분리하여 처리 + const { imageUrls, ...restData } = reviewData; + + // 기본 리뷰 데이터 먼저 전송 (imageUrls 제외) + const response = await axiosInstance.post('/reviews', restData); + + if (response.data.isSuccess) { + const reviewId = response.data.result.id; + + // 이미지 URLs를 개별적으로 저장 + if (imageUrls && imageUrls.length > 0) { + const imageUploadPromises = imageUrls.map(async (imageUrl) => { + return await axiosInstance.post(`/reviews/${reviewId}/images`, { + imageUrl: imageUrl + }); + }); + + // 모든 이미지 URL 저장 완료까지 대기 + await Promise.all(imageUploadPromises); + console.log(`${imageUrls.length}개 이미지 URL이 개별적으로 저장되었습니다.`); + } + + return response.data.result; + } else { + throw new Error(response.data.message || '리뷰 작성에 실패했습니다.'); + } + } catch (error) { + console.error('후기 게시판 작성 API 호출 실패:', error); + throw error; + } +}; + +// 개별 이미지 URL 추가 함수 (자유게시판용) +export const addImageToPost = async (postId, imageUrl) => { + try { + const response = await axiosInstance.post(`/posts/${postId}/images`, { + imageUrl: imageUrl + }); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '이미지 추가에 실패했습니다.'); + } + } catch (error) { + console.error('게시물 이미지 추가 API 호출 실패:', error); + throw error; + } +}; + +// 개별 이미지 URL 추가 함수 (리뷰용) +export const addImageToReview = async (reviewId, imageUrl) => { + try { + const response = await axiosInstance.post(`/reviews/${reviewId}/images`, { + imageUrl: imageUrl + }); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '이미지 추가에 실패했습니다.'); + } + } catch (error) { + console.error('리뷰 이미지 추가 API 호출 실패:', error); + throw error; + } +}; + +// 에디터에서 이미지 URL 추출 함수 +export const extractImageUrls = (htmlContent) => { + const imgRegex = /]+src="([^">]+)"/g; + const urls = []; + let match; + + while ((match = imgRegex.exec(htmlContent)) !== null) { + urls.push(match[1]); + } + + return urls; +}; + +// 자유 게시판 전체 게시물 조회 +export const getPosts = async (page = 0, size = 10) => { + try { + const response = await axiosInstance.get('/posts', { + params: { + page, + size + } + }); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '게시물 조회에 실패했습니다.'); + } + } catch (error) { + console.error('자유 게시판 조회 API 호출 실패:', error); + throw error; + } +}; + +// 자유 게시판 특정 게시물 조회 +export const getPost = async (postId) => { + try { + const response = await axiosInstance.get(`/posts/${postId}`); + return response.data; + } catch (error) { + console.error('게시물 상세 조회 API 호출 실패:', error); + throw error; + } +}; + +// 게시물 수정 (API 스펙에 맞게 단순화) +export const updatePost = async (postId, postData) => { + try { + console.log('게시물 수정 요청:', { postId, postData }); + + // API 스펙에 맞게 전체 데이터를 한 번에 전송 + const response = await axiosInstance.patch(`/posts/update/${postId}`, postData); + + console.log('게시물 수정 응답:', response.data); + + if (response.data.isSuccess) { + return response.data; + } else { + throw new Error(response.data.message || '게시물 수정에 실패했습니다.'); + } + } catch (error) { + console.error('게시물 수정 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + + +// 리뷰 수정 - API 스펙에 맞게 전체 데이터 한 번에 전송 +export const updateReview = async (reviewId, reviewData) => { + try { + console.log('리뷰 수정 요청:', { reviewId, reviewData }); + + // API 스펙에 맞는 요청 데이터 구성 + const requestData = { + title: reviewData.title, + content: reviewData.content, + awardImageUrl: reviewData.awardImageUrl || null, + imageUrls: reviewData.imageUrls || [] + }; + + console.log('전송할 데이터:', requestData); + + const response = await axiosInstance.patch(`/reviews/update/${reviewId}`, requestData); + + console.log('리뷰 수정 응답:', response.data); + + if (response.data.isSuccess) { + return response.data; + } else { + throw new Error(response.data.message || '리뷰 수정에 실패했습니다.'); + } + } catch (error) { + console.error('리뷰 수정 API 호출 실패:', error); + + if (error.response) { + console.error('서버 응답:', error.response.data); + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 게시물 삭제 - API 스펙에 맞게 PATCH 메서드 사용 +export const deletePost = async (postId) => { + try { + const response = await axiosInstance.patch(`/posts/delete/${postId}`); + + if (response.data.isSuccess) { + return response.data; + } else { + throw new Error(response.data.message || '게시물 삭제에 실패했습니다.'); + } + } catch (error) { + console.error('게시물 삭제 API 호출 실패:', error); + throw error; + } +}; + + +// 리뷰 게시판 게시물 삭제 +export const deleteReview = async (reviewId) => { + try { + const response = await axiosInstance.patch(`/reviews/delete/${reviewId}`); + + if (response.data.isSuccess) { + return response.data; + } else { + throw new Error(response.data.message || '리뷰 삭제에 실패했습니다.'); + } + } catch (error) { + console.error('리뷰 삭제 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 게시물 좋아요/취소 +export const togglePostLike = async (postId) => { + try { + console.log('좋아요 토글 요청:', postId); + + const response = await axiosInstance.post(`/posts/${postId}/like`); + + console.log('좋아요 토글 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '좋아요 처리에 실패했습니다.'); + } + } catch (error) { + console.error('좋아요 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 여러 이미지 업로드를 위한 presigned URL 발급 +export const getMultiplePresignedUrls = async (imageNames) => { + try { + const response = await axiosInstance.post('/presignedurls/images', { + imageNames: imageNames + }); + + if (response.data.isSuccess) { + return response.data.result.PresignedUrls; + } else { + throw new Error(response.data.message || 'PresignedUrl 발급에 실패했습니다.'); + } + } catch (error) { + console.error('여러 PresignedUrl API 호출 실패:', error); + throw error; + } +}; + +// 여러 이미지를 S3에 업로드 +export const uploadMultipleImages = async (files) => { + try { + // 파일명 생성 + const imageNames = files.map(file => { + const timestamp = Date.now(); + const randomString = Math.random().toString(36).substring(2, 8); + const extension = file.name.split('.').pop(); + return `${timestamp}-${randomString}.${extension}`; + }); + + // Presigned URL들 가져오기 + const presignedUrls = await getMultiplePresignedUrls(imageNames); + + // 각 파일을 S3에 업로드 + const uploadPromises = files.map(async (file, index) => { + const presignedUrl = presignedUrls[index]; + + const response = await fetch(presignedUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type, + } + }); + + if (!response.ok) { + throw new Error(`파일 ${file.name} 업로드 실패: ${response.status}`); + } + + // 업로드된 이미지 URL 반환 (쿼리 파라미터 제거) + return presignedUrl.split('?')[0]; + }); + + const uploadedUrls = await Promise.all(uploadPromises); + return uploadedUrls; + + } catch (error) { + console.error('여러 이미지 업로드 실패:', error); + throw error; + } +}; + +// 좋아요 순으로 게시물 조회 +export const getPostsByLikes = async (page = 0, size = 10) => { + try { + const response = await axiosInstance.get('/posts/like', { + params: { + page: page, + size: size + } + }); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '좋아요 순 게시물 조회에 실패했습니다.'); + } + } catch (error) { + console.error('좋아요 순 게시물 조회 API 호출 실패:', error); + throw error; + } +}; + +// 특정 게시물의 모든 댓글 조회 +export const getComments = async (postId) => { + try { + const response = await axiosInstance.get(`/comments/${postId}`); + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '댓글 조회에 실패했습니다.'); + } + } catch (error) { + console.error('댓글 조회 API 호출 실패:', error); + throw error; + } +}; + +// 특정 게시물에 댓글 작성 +export const postComment = async (postId, content) => { + try { + const response = await axiosInstance.post(`/comments/${postId}`, { content }); + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '댓글 작성에 실패했습니다.'); + } + } catch (error) { + console.error('댓글 작성 API 호출 실패:', error); + throw error; + } +}; + +// 특정 댓글 수정 +export const updateComment = async (commentId, content) => { + try { + const response = await axiosInstance.patch(`/comments/update/${commentId}`, { content }); + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '댓글 수정에 실패했습니다.'); + } + } catch (error) { + console.error('댓글 수정 API 호출 실패:', error); + throw error; + } +}; + +// 특정 댓글 삭제 +export const deleteComment = async (commentId) => { + try { + const response = await axiosInstance.patch(`/comments/delete/${commentId}`); + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '댓글 삭제에 실패했습니다.'); + } + } catch (error) { + console.error('댓글 삭제 API 호출 실패:', error); + throw error; + } +}; + +// 단일 이미지 업로드를 위한 presigned URL 발급 +export const getPresignedUrl = async (imageName) => { + try { + const response = await axiosInstance.post('/presignedurls/image', { + imageName: imageName + }); + + if (response.data.isSuccess) { + return response.data.result.presignedUrl; + } else { + throw new Error(response.data.message || 'PresignedUrl 발급에 실패했습니다.'); + } + } catch (error) { + console.error('PresignedUrl API 호출 실패:', error); + throw error; + } +}; + +// 단일 이미지를 S3에 업로드 +export const uploadSingleImage = async (file) => { + try { + // 파일명 생성 + const timestamp = Date.now(); + const randomString = Math.random().toString(36).substring(2, 8); + const extension = file.name.split('.').pop(); + const imageName = `award-${timestamp}-${randomString}.${extension}`; + + // Presigned URL 가져오기 + const presignedUrl = await getPresignedUrl(imageName); + + // S3에 업로드 + const response = await fetch(presignedUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type, + } + }); + + if (!response.ok) { + throw new Error(`파일 업로드 실패: ${response.status}`); + } + + // 업로드된 이미지 URL 반환 (쿼리 파라미터 제거) + return presignedUrl.split('?')[0]; + + } catch (error) { + console.error('단일 이미지 업로드 실패:', error); + throw error; + } +}; + +// 기존 활동에 대한 리뷰 생성 (검색해서 찾은 경우) +export const createActivityReview = async (activityId, reviewData) => { + try { + console.log('활동 리뷰 생성 요청:', { activityId, reviewData }); + + const requestData = { + title: reviewData.title, + activityPeriod: reviewData.activityPeriod, + content: reviewData.content, + awardImageUrl: reviewData.awardImageUrl || null, + imageUrls: reviewData.imageUrls || [] + }; + + console.log('전송할 데이터:', requestData); + + const response = await axiosInstance.post(`/reviews/${activityId}`, requestData); + + console.log('활동 리뷰 생성 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '리뷰 생성에 실패했습니다.'); + } + } catch (error) { + console.error('활동 리뷰 생성 실패:', error); + if (error.response) { + console.error('서버 응답:', error.response.data); + } + throw error; + } +}; + +// 새로운 활동과 함께 리뷰 생성 (직접 입력한 경우) +export const createNewActivityReview = async (reviewData) => { + try { + console.log('새 활동 리뷰 생성 요청:', reviewData); + + // 필수 필드 검증 + if (!reviewData.title?.trim()) throw new Error('제목은 필수입니다.'); + if (!reviewData.keyword) throw new Error('키워드는 필수입니다.'); + if (!reviewData.activityType) throw new Error('활동 유형은 필수입니다.'); + if (!reviewData.activityPeriod) throw new Error('활동 기간은 필수입니다.'); + if (!reviewData.activityName?.trim()) throw new Error('활동 이름은 필수입니다.'); + if (!reviewData.content?.trim()) throw new Error('내용은 필수입니다.'); + + const requestData = { + title: reviewData.title.trim(), + keyword: reviewData.keyword, + activityType: reviewData.activityType, + activityPeriod: reviewData.activityPeriod, + activityEndDate: reviewData.activityEndDate || null, + activityName: reviewData.activityName.trim(), + content: reviewData.content.trim(), + awardImageUrl: reviewData.awardImageUrl || null, + imageUrls: reviewData.imageUrls || [] + }; + + console.log('전송할 데이터:', requestData); + + const response = await axiosInstance.post('/reviews', requestData); + + console.log('새 활동 리뷰 생성 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '리뷰 생성에 실패했습니다.'); + } + } catch (error) { + console.error('새 활동 리뷰 생성 실패:', error); + if (error.response) { + console.error('서버 응답:', error.response.data); + console.error('상태 코드:', error.response.status); + } + throw error; + } +}; + +// 리뷰 게시판 조회 +export const getReviews = async (page = 0, keyword = null, activityType = null, sort = 'RECENT') => { + try { + const params = { + page, + sort: sort.toUpperCase() // 대문자로 변환 + }; + + if (keyword) params.keyword = keyword; + if (activityType) params.activityType = activityType; + + console.log('리뷰 조회 API 요청 파라미터:', params); + + const response = await axiosInstance.get('/reviews', { params }); + + if (response.data.isSuccess) { + // 응답 데이터의 키워드를 한국어로 변환 + const result = response.data.result; + result.content = result.content.map(review => ({ + ...review, + keyword: REVERSE_CATEGORY_MAP[review.keyword] || review.keyword + })); + return result; + } else { + throw new Error(response.data.message || '리뷰 조회에 실패했습니다.'); + } + } catch (error) { + console.error('리뷰 조회 API 호출 실패:', error); + throw error; + } +}; + +// 리뷰 게시판 특정 게시물 조회 +export const getReview = async (reviewId) => { + try { + const response = await axiosInstance.get(`/reviews/${reviewId}`); + + if (response.data.isSuccess) { + const result = response.data.result; + console.log('API 응답 데이터 (원본):', result); + console.log('ocrResult 필드:', result.ocrResult); + console.log('ocr_result 필드:', result.ocr_result); + console.log('awardOcrResult 필드:', result.awardOcrResult); + + // 키워드를 한국어로 변환 + result.keyword = REVERSE_CATEGORY_MAP[result.keyword] || result.keyword; + // 활동 유형을 한국어로 변환 + result.activityType = Object.entries(ACTIVITY_TYPE_MAP).find(([key, value]) => value === result.activityType)?.[0] || result.activityType; + // 활동 기간을 한국어로 변환 + result.activityPeriod = Object.entries(ACTIVITY_PERIOD_MAP).find(([key, value]) => value === result.activityPeriod)?.[0] || result.activityPeriod; + + console.log('변환 후 데이터:', result); + console.log('최종 ocrResult:', result.ocrResult); + console.log('최종 awardOcrResult:', result.awardOcrResult); + return result; + } else { + throw new Error(response.data.message || '리뷰 조회에 실패했습니다.'); + } + } catch (error) { + console.error('리뷰 상세 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 후기 게시판 좋아요/취소 +export const toggleReviewLike = async (reviewId) => { + try { + const response = await axiosInstance.post(`/reviews/${reviewId}/like`); + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '리뷰 좋아요 처리에 실패했습니다.'); + } + } catch (error) { + console.error('리뷰 좋아요 API 호출 실패:', error); + throw error; + } +}; + +// 특정 사용자의 리뷰 게시물 조회 +export const getUserReviews = async (userId, page = 0) => { + try { + console.log('사용자 리뷰 조회 요청:', { userId, page }); + + const response = await axiosInstance.get(`/profile/reviews/${userId}`, { + params: { page } + }); + + console.log('사용자 리뷰 조회 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '사용자 리뷰 조회에 실패했습니다.'); + } + } catch (error) { + console.error('사용자 리뷰 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 특정 사용자의 자유게시판 게시물 조회 +export const getUserPosts = async (userId, page = 0) => { + try { + console.log('사용자 게시물 조회 요청:', { userId, page }); + + const response = await axiosInstance.get(`/profile/posts/${userId}`, { + params: { page } + }); + + console.log('사용자 게시물 조회 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '사용자 게시물 조회에 실패했습니다.'); + } + } catch (error) { + console.error('사용자 게시물 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 내가 쓴 리뷰 게시물 조회 (본인 프로필용) +export const getMyReviews = async (page = 0) => { + try { + console.log('내 리뷰 조회 요청:', { page }); + + const response = await axiosInstance.get('/profile/reviews/mine', { + params: { page } + }); + + console.log('내 리뷰 조회 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '내 리뷰 조회에 실패했습니다.'); + } + } catch (error) { + console.error('내 리뷰 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 내가 쓴 자유 게시판 게시물 조회 (본인 프로필용) +export const getMyPosts = async (page = 0) => { + try { + console.log('내 게시물 조회 요청:', { page }); + + const response = await axiosInstance.get('/profile/posts/mine', { + params: { page } + }); + + console.log('내 게시물 조회 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '내 게시물 조회에 실패했습니다.'); + } + } catch (error) { + console.error('내 게시물 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 키워드별 내 활동 통계 조회 +export const getMyKeywordStats = async () => { + try { + console.log('키워드별 활동 통계 조회 요청'); + + const response = await axiosInstance.get('/profile/reviews/mine/keyword/count'); + + console.log('키워드별 활동 통계 조회 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '키워드별 활동 통계 조회에 실패했습니다.'); + } + } catch (error) { + console.error('키워드별 활동 통계 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 활동 종류별 내 활동 통계 조회 +export const getMyActivityTypeStats = async () => { + try { + console.log('활동 종류별 통계 조회 요청'); + + const response = await axiosInstance.get('/profile/reviews/mine/type/count'); + + console.log('활동 종류별 통계 조회 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '활동 종류별 통계 조회에 실패했습니다.'); + } + } catch (error) { + console.error('활동 종류별 통계 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 월별 내 활동 통계 조회 +export const getMyMonthlyStats = async () => { + try { + console.log('월별 활동 통계 조회 요청'); + + const response = await axiosInstance.get('/profile/reviews/mine/month/count'); + + console.log('월별 활동 통계 조회 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '월별 활동 통계 조회에 실패했습니다.'); + } + } catch (error) { + console.error('월별 활동 통계 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 특정 사용자의 키워드별 통계 조회 +export const getUserKeywordStats = async (userId) => { + try { + console.log('사용자 키워드별 활동 통계 조회 요청:', userId); + + const response = await axiosInstance.get(`/profile/reviews/${userId}/keyword/count`); + + console.log('사용자 키워드별 활동 통계 조회 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '키워드별 통계 조회에 실패했습니다.'); + } + } catch (error) { + console.error('사용자 키워드별 통계 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 특정 사용자의 활동 종류별 통계 조회 +export const getUserActivityTypeStats = async (userId) => { + try { + console.log('사용자 활동 종류별 통계 조회 요청:', userId); + + const response = await axiosInstance.get(`/profile/reviews/${userId}/type/count`); + + console.log('사용자 활동 종류별 통계 조회 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '활동 종료별 통계 조회에 실패했습니다.'); + } + } catch (error) { + console.error('사용자 활동 종류별 통계 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 특정 사용자의 월별 활동 통계 조회 +export const getUserMonthlyStats = async (userId) => { + try { + console.log('사용자 월별 활동 통계 조회 요청:', userId); + + const response = await axiosInstance.get(`/profile/reviews/${userId}/month/count`); + + console.log('사용자 월별 활동 통계 조회 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '월별 활동 통계 조회에 실패했습니다.'); + } + } catch (error) { + console.error('사용자 월별 활동 통계 조회 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 리뷰 게시판 검색 +export const searchReviews = async (keyword, page = 0) => { + try { + console.log('리뷰 검색 요청:', { keyword, page }); + + const response = await axiosInstance.get('/reviews/search', { + params: { + keyword, + page + } + }); + + console.log('리뷰 검색 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '리뷰 검색에 실패했습니다.'); + } + } catch (error) { + console.error('리뷰 검색 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 자유 게시판 검색 +export const searchPosts = async (keyword, page = 0) => { + try { + console.log('게시물 검색 요청:', { keyword, page }); + + const response = await axiosInstance.get('/posts/search', { + params: { + keyword, + page + } + }); + + console.log('게시물 검색 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '게시물 검색에 실패했습니다.'); + } + } catch (error) { + console.error('게시물 검색 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('요청 처리 중 오류가 발생했습니다.'); + } + } +}; diff --git a/src/api/axiosInstance.js b/src/api/axiosInstance.js index 27fd151..62de391 100644 --- a/src/api/axiosInstance.js +++ b/src/api/axiosInstance.js @@ -2,22 +2,25 @@ import axios from 'axios'; const getBaseURL = () => { const path = window.location.pathname; - // 로그인 관련 경로는 8080 포트 사용 + + // 환경변수에서 API URL 가져오기 + const apiBaseUrl = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080'; + const fallbackUrl = process.env.REACT_APP_FALLBACK_API_URL || 'http://localhost:8080'; + if ( path === '/' || path.startsWith('/interest') || path.startsWith('/oauth2') || path.startsWith('/login') ) { - return 'http://61.109.236.137:8080'; + return apiBaseUrl; } - // 나머지 API는 8000 포트 사용 - return 'http://61.109.236.137:8000'; + return fallbackUrl; }; const axiosInstance = axios.create({ - baseURL: getBaseURL(), - timeout: 10000, + baseURL: getBaseURL() + '/api', + timeout: 30000, // 30초로 증가 headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' @@ -35,8 +38,8 @@ const axiosInstance = axios.create({ }); axiosInstance.interceptors.request.use(config => { - config.baseURL = getBaseURL(); - const token = localStorage.getItem('token'); + config.baseURL = getBaseURL() + '/api'; + const token = localStorage.getItem('Token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -52,7 +55,7 @@ axiosInstance.interceptors.response.use( if (error.response) { console.error('서버 응답 오류:', error.response.data); if (error.response.status === 401) { - localStorage.removeItem('token'); + localStorage.removeItem('Token'); window.location.href = '/login'; } } else if (error.request) { diff --git a/src/api/userApi.js b/src/api/userApi.js index 059b88d..545199d 100644 --- a/src/api/userApi.js +++ b/src/api/userApi.js @@ -11,7 +11,7 @@ export const TIER_LABEL_MAP = { // 8080 포트의 axios 인스턴스 생성 const axiosInstance8080 = axios.create({ - baseURL: 'http://61.109.236.137:8080', + baseURL: 'http://k8s-trendis-gateway-55272f9955-9df8d864228042f1945c5a7d6a1bcde2.ke.kr-central-2.kakaocloud.com/', timeout: 10000, headers: { 'Content-Type': 'application/json', @@ -32,13 +32,83 @@ axiosInstance8080.interceptors.request.use(config => { return Promise.reject(error); }); +// 로그인 API 추가 +export const login = async (credentials) => { + try { + const response = await axiosInstance8080.post('/auth/login', credentials); + + console.log('로그인 응답:', response.data); + + if (response.data.isSuccess) { + const userData = response.data.result; + + // localStorage에 사용자 정보 저장 + localStorage.setItem('token', userData.token); + localStorage.setItem('userId', userData.userId); + localStorage.setItem('nickname', userData.nickname); + + console.log('저장된 userId:', localStorage.getItem('userId')); + + return userData; + } else { + throw new Error(response.data.message || '로그인에 실패했습니다.'); + } + } catch (error) { + console.error('로그인 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `로그인 실패 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('로그인 요청 처리 중 오류가 발생했습니다.'); + } + } +}; + +// 로그아웃 API 추가 +export const logout = async () => { + try { + // 서버에 로그아웃 요청 (필요한 경우) + // await axiosInstance8080.post('/auth/logout'); + + // localStorage 정리 + localStorage.removeItem('token'); + localStorage.removeItem('userId'); + localStorage.removeItem('nickname'); + + console.log('로그아웃 완료'); + } catch (error) { + console.error('로그아웃 처리 중 오류:', error); + // 에러가 발생해도 localStorage는 정리 + localStorage.removeItem('token'); + localStorage.removeItem('userId'); + localStorage.removeItem('nickname'); + } +}; + +// 현재 로그인한 사용자 정보 가져오기 +export const getCurrentUser = () => { + return { + userId: localStorage.getItem('userId'), + token: localStorage.getItem('token'), + nickname: localStorage.getItem('nickname') + }; +}; + +// 로그인 상태 확인 +export const isLoggedIn = () => { + const token = localStorage.getItem('token'); + const userId = localStorage.getItem('userId'); + return !!(token && userId); +}; + export const getUserProfile = async () => { try { const response = await axiosInstance8080.get('/users/profile'); console.log('프로필 조회 응답:', response.data); - // 응답 구조에 따라 조정 필요 if (response.data.isSuccess) { return response.data.result; } else { @@ -48,10 +118,8 @@ export const getUserProfile = async () => { console.error('프로필 조회 API 호출 실패:', error); if (error.response) { - // 서버 응답이 있는 경우 throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); } else if (error.request) { - // 네트워크 오류 throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); } else { throw new Error('요청 처리 중 오류가 발생했습니다.'); @@ -86,13 +154,47 @@ export const setUserProfile = async (userData) => { throw error; } }; + +// 사용자 프로필 수정 +export const updateUserProfile = async (userData) => { + try { + console.log('프로필 수정 API 요청 데이터:', userData); + + const requestData = { + nickname: userData.nickname, + keyword: userData.keyword, + profileUrl: userData.profileUrl + }; + + const response = await axiosInstance8080.patch('/users/profile/update', requestData); + + console.log('프로필 수정 서버 응답:', response.data); + + if (response.data.isSuccess) { + return response.data.result; + } else { + throw new Error(response.data.message || '프로필 수정에 실패했습니다.'); + } + } catch (error) { + console.error('프로필 수정 API 호출 실패:', error); + + if (error.response) { + throw new Error(error.response.data.message || `서버 오류 (${error.response.status})`); + } else if (error.request) { + throw new Error('서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'); + } else { + throw new Error('프로필 수정 요청 처리 중 오류가 발생했습니다.'); + } + } +}; + export const getRankingByTier = async (tierKey) => { try { const response = await axiosInstance8080.get(`/users/rankings/${tierKey}`); console.log(`[${tierKey}] 티어 랭킹 조회 결과:`, response.data); if (response.data.isSuccess) { - return response.data.result; // 유저 배열 반환 + return response.data.result; } else { throw new Error(response.data.message || '랭킹 조회 실패'); } @@ -102,3 +204,61 @@ export const getRankingByTier = async (tierKey) => { } }; +export const getPresignedUrl = async (imageName) => { + try { + const response = await axiosInstance8080.post('/users/presignedurls', { + imageName: imageName + }); + + if (response.data.isSuccess) { + return response.data.result.PresignedUrl; + } else { + throw new Error(response.data.message || 'PresignedUrl 발급에 실패했습니다.'); + } + } catch (error) { + console.error('PresignedUrl API 호출 실패:', error); + throw error; + } +}; + +// 프로필 이미지를 S3에 업로드 +export const uploadProfileImage = async (file) => { + try { + // 파일명 생성 + const timestamp = Date.now(); + const randomString = Math.random().toString(36).substring(2, 8); + const extension = file.name.split('.').pop(); + const imageName = `profile-${timestamp}-${randomString}.${extension}`; + + console.log('프로필 이미지 업로드 시작:', imageName); + + // Presigned URL 가져오기 + 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, + } + }); + + if (!response.ok) { + throw new Error(`이미지 업로드 실패: ${response.status}`); + } + + // 업로드된 이미지 URL 반환 (쿼리 파라미터 제거) + const imageUrl = presignedUrl.split('?')[0]; + + console.log('프로필 이미지 업로드 완료:', imageUrl); + + return imageUrl; + + } catch (error) { + console.error('프로필 이미지 업로드 실패:', error); + throw error; + } +}; diff --git a/src/assets/images/main/mainbanner.png b/src/assets/images/main/mainbanner.png new file mode 100644 index 0000000..b86f15b Binary files /dev/null and b/src/assets/images/main/mainbanner.png differ diff --git a/src/components/activity/ActivityCard.jsx b/src/components/activity/ActivityCard.jsx index c64a62c..5f136cb 100644 --- a/src/components/activity/ActivityCard.jsx +++ b/src/components/activity/ActivityCard.jsx @@ -9,7 +9,7 @@ export default function ActivityCard({ tags, image, date, - bookmarked, + bookmarked = false, onToggle, isClosed, siteUrl @@ -33,9 +33,10 @@ export default function ActivityCard({ { + onClick={(e) => { e.preventDefault(); - onToggle(); + e.stopPropagation(); + if (onToggle) onToggle(); }} /> diff --git a/src/components/activity/SmallActivityCard.jsx b/src/components/activity/SmallActivityCard.jsx new file mode 100644 index 0000000..835831e --- /dev/null +++ b/src/components/activity/SmallActivityCard.jsx @@ -0,0 +1,154 @@ +import React from 'react'; +import styled from 'styled-components'; +import BookmarkFilledButton from '../../assets/images/common/BookmarkFilledButton.png'; +import BookmarkButton from '../../assets/images/common/BookmarkButton.png'; + +export default function ActivityCard({ + title, + tags, + image, + date, + bookmarked = false, + onToggle, + isClosed, + siteUrl +}) { + return ( + { + if (!siteUrl) e.preventDefault(); + }} + > + + + + {isClosed && 마감} + { + e.preventDefault(); + e.stopPropagation(); + if (onToggle) onToggle(); + }} + /> + +

{title}

+ + {Array.isArray(tags) + ? tags.map((t, idx) => {t}) + : typeof tags === 'string' + ? tags.split(' ').map((t, idx) => {t}) + : null} + + {date} +
+
+ ); +} + +const CardLink = styled.a` + text-decoration: none; + color: inherit; + display: inline-block; + cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')}; + pointer-events: ${({ $disabled }) => ($disabled ? 'none' : 'auto')}; + + &:hover { + opacity: 0.9; + } +`; + +const Card = styled.div` + width: 280px; + height: 360px; + border: 2px solid #235BA9; + background-color: #fff; + text-align: left; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: visible; + padding: 0 16px; + cursor: pointer; + + .activity-title { + font-size: 19px; + font-weight: bold; + margin-top: 30px; + margin-bottom: 8px; + font-family: NotoSansCustom; + word-break: keep-all; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } +`; + +const ImageWrapper = styled.div` + width: 100%; + height: 180px; + position: relative; + justify-content: center; + align-items: center; + box-sizing: border-box; + overflow: visible; + left: -16px; +`; + +const IssueImage = styled.img` + width: 276px; + height: 180px; + object-fit: cover; + object-position: top; + display: block; +`; + +const BookmarkIcon = styled.img` + position: absolute; + bottom: -35px; + right: -16px; + width: 45px; + height: 45px; + cursor: pointer; + z-index: 10; +`; + +const Tags = styled.p` + font-size: 15px; + margin: 0; + margin-top: auto; + span { + color: #235BA9; + margin-right: 8px; + } +`; + +const DateText = styled.p` + font-size: 13px; + color: #808080; + margin-top: 6px; +`; + +const ClosedBadge = styled.div` + position: absolute; + top: 0px; + left: -1px; + background-color: #656565; + color: white; + font-size: 13px; + padding: 6px 16px; + z-index: 20; + font-family: 'NotoSansCustom'; + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/src/components/board/CommentSection.jsx b/src/components/board/CommentSection.jsx new file mode 100644 index 0000000..d3f419b --- /dev/null +++ b/src/components/board/CommentSection.jsx @@ -0,0 +1,243 @@ +import React, { useState } from "react"; +import styled from "styled-components"; +import PROFILE_IMG from "../../assets/images/profile/DefaultProfile.png"; + +export default function CommentSection({ + comments, + comment, + setComment, + setComments, + handleCommentSubmit, + handleEditComment, + handleDeleteComment, + editingCommentId, + setEditingCommentId +}) { + // 프로필 이미지 에러 상태 관리 + const [profileImageErrors, setProfileImageErrors] = useState({}); + + // 프로필 이미지 에러 처리 함수 + const handleProfileImageError = (commentId) => { + setProfileImageErrors(prev => ({ + ...prev, + [commentId]: true + })); + }; + + // 프로필 이미지 URL 결정 함수 + const getProfileImageSrc = (comment) => { + if (profileImageErrors[comment.commentId]) { + return PROFILE_IMG; // 에러 발생 시 기본 이미지 + } + + if (!comment.profileUrl || comment.profileUrl === "기본값" || comment.profileUrl === "") { + return PROFILE_IMG; // 프로필 URL이 없거나 기본값인 경우 + } + + return comment.profileUrl; // 유효한 프로필 URL + }; + + return ( + + 댓글 + + {comments.map((c) => ( + + + + handleProfileImageError(c.commentId)} + /> + {c.nickname} + + {c.content} + + + {c.userId === localStorage.getItem('userId') && ( + editingCommentId === c.commentId ? ( + 수정 중 + ) : ( + + handleEditComment(c.commentId)}> + 수정 + + + handleDeleteComment(c.commentId)}> + 삭제 + + + ) + )} + {c.createdAt?.slice(0, 10).replace(/-/g, ".")} + + + ))} + + + setComment(e.target.value)} + placeholder="댓글을 입력하세요." + /> + {editingCommentId ? ( + <> + 완료 + { + setEditingCommentId(null); + setComment(""); + }}> + 취소 + + + ) : ( + 등록 + )} + + + ); +} + +// 스타일드 컴포넌트들은 동일하게 유지 +const CommentSectionWrapper = styled.div` + margin-top: 24px; +`; + +const CommentTitle = styled.h3` + font-size: 18px; + font-weight: bold; + margin-bottom: 12px; +`; + +const CommentList = styled.ul` + list-style: none; + padding: 0; + margin-bottom: 16px; +`; + +const CommentItem = styled.li` + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #f0f0f0; +`; + +const CommentLeft = styled.div` + display: flex; + flex-direction: column; +`; + +const CommentAuthorBox = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 2px; +`; + +const CommentProfileImg = styled.img` + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; + border: 0.1px solid #c4c4c4; +`; + +const CommentAuthor = styled.span` + font-weight: 600; + color: #000; + font-size: 14px; +`; + +const CommentText = styled.span` + font-size: 15px; + color: #222; + margin-left: 32px; +`; + +const CommentRight = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +`; + +const CommentCreateAt = styled.span` + font-size: 12px; + color: #aaa; +`; + +const CommentActions = styled.div` + display: flex; + background: #fff; + border-radius: 6px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + margin-right: -8px; + margin-bottom: 3px; +`; + +const ActionDivider = styled.div` + width: 1px; + background: #e0e0e0; + margin: 4px 0; +`; + +const ActionBtn = styled.button` + background: none; + border: none; + padding: 4px 8px; + font-size: 12px; + color: #235ba9; + cursor: pointer; + flex: 1; + &:hover { + background: #f6f6f6; + } +`; + +const CommentForm = styled.form` + display: flex; + gap: 8px; +`; + +const CommentInput = styled.input` + flex: 1; + padding: 10px; + border: 1.5px solid #235ba9; + border-radius: 6px; + font-size: 15px; +`; + +const CommentButton = styled.button` + background: #235ba9; + color: #fff; + border: none; + border-radius: 6px; + padding: 0 18px; + font-size: 15px; + cursor: pointer; +`; + +const EditingText = styled.span` + color: #e67e22; + font-size: 13px; + font-weight: 600; + margin-right: 8px; +`; + +const CancelEditButton = styled.button` + background: #f5f5f5; + color: #666; + border: none; + border-radius: 6px; + padding: 0 12px; + font-size: 15px; + margin-left: 6px; + cursor: pointer; + height: 36px; + &:hover { + background: #e0e0e0; + } +`; diff --git a/src/components/board/AwardAlertModal.jsx b/src/components/board/NotAllAlertModal.jsx similarity index 95% rename from src/components/board/AwardAlertModal.jsx rename to src/components/board/NotAllAlertModal.jsx index 236d34e..f18ce5b 100644 --- a/src/components/board/AwardAlertModal.jsx +++ b/src/components/board/NotAllAlertModal.jsx @@ -8,7 +8,7 @@ export default function AwardAlertModal({ onClose, onResubmit }) { - 아쉽게도 제출하신 수상 기록이 검증되지 않아
+ 아쉽게도 제출하신 수상,활동 기록이 검증되지 않아
포인트 지급이 완료되지 않았습니다.
diff --git a/src/components/board/NotAwardModal.jsx b/src/components/board/NotAwardModal.jsx new file mode 100644 index 0000000..de8ac1d --- /dev/null +++ b/src/components/board/NotAwardModal.jsx @@ -0,0 +1,80 @@ +import React from "react"; +import styled from "styled-components"; +import warningIcon from "../../assets/images/board/ic_warning.png"; + +export default function NotPointAlertModal({ onClose, onResubmit }) { + return ( + + + + + 아쉽게도 제출하신 수상기록이 검증되지 않아
+ 포인트 지급이 완료되지 않았습니다. +
+ + 확인 + 다시 제출하기 + +
+
+ ); +} + +const AlertOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; +`; + +const AlertBox = styled.div` + background: white; + padding: 30px 24px; + border-radius: 12px; + text-align: center; + max-width: 400px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +`; + +const IconImage = styled.img` + width: 40px; + height: 40px; +`; + +const AlertMessage = styled.p` + font-size: 16px; + margin-bottom: 20px; + line-height: 1.5; +`; + +const ButtonGroup = styled.div` + display: flex; + gap: 10px; + justify-content: center; +`; + +const GrayButton = styled.button` + background-color: #c4c4c4; + color: white; + border: none; + padding: 8px 20px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; +`; + +const BlueButton = styled.button` + background-color: #235ba9; + color: white; + border: none; + padding: 8px 20px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; +`; \ No newline at end of file diff --git a/src/components/board/PointAlertModal.jsx b/src/components/board/PointAlertModal.jsx index b161d9e..bb333b2 100644 --- a/src/components/board/PointAlertModal.jsx +++ b/src/components/board/PointAlertModal.jsx @@ -8,7 +8,7 @@ export default function PointAlertModal({ onClose }) { - 제출해주신 자료는 정상적으로 확인되었습니다.
포인트는 매일 자정에 반영됩니다. + 제출해주신 자료는 정상적으로 확인되었습니다.
포인트와 랭킹은 6시간마다 반영됩니다.
확인
diff --git a/src/components/board/ReviewBoard/ReviewCard.jsx b/src/components/board/ReviewBoard/ReviewCard.jsx index 34b635e..1ad2cf8 100644 --- a/src/components/board/ReviewBoard/ReviewCard.jsx +++ b/src/components/board/ReviewBoard/ReviewCard.jsx @@ -1,30 +1,168 @@ -import React from "react"; +import React, { useEffect } from "react"; import styled from "styled-components"; -import useLike from "../../../hooks/useLike"; +import { useToggleReviewLike } from "../../../query/usePost"; +import { useReviewLikeStore } from "../../../store/reviewLikeStore"; import LikeButton from "../LikeButton"; import CategoryTag from "../../common/CategoryTag"; import { useNavigate } from "react-router-dom"; +import { useQueryClient } from '@tanstack/react-query'; -const ReviewCard = ({ id, category, image, title, content, date, writer, likeCount }) => { - const { liked, count, toggleLike } = useLike(likeCount, false); +const ReviewCard = ({ id, category, image, title, content, date, writer, likeCount, liked: initialLiked }) => { + const { likeMap, setLike, updateLike } = useReviewLikeStore(); + const likeState = likeMap[id] || { liked: initialLiked || false, likeCount: likeCount || 0 }; + const toggleReviewLikeMutation = useToggleReviewLike(); const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [imageError, setImageError] = React.useState(false); + + // HTML 태그 제거 함수 + const stripHtml = (html) => { + const tmp = document.createElement('DIV'); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ''; + }; + + // 초기 좋아요 상태 설정 + useEffect(() => { + if (id && typeof initialLiked !== 'undefined' && typeof likeCount !== 'undefined') { + console.log('ReviewCard 초기 좋아요 상태 설정:', { + id, + initialLiked, + likeCount, + 현재스토어상태: likeMap[id] + }); + + // 스토어에 해당 ID가 없을 때만 설정 (이미 클릭으로 변경된 상태 보존) + if (!likeMap[id]) { + setLike(id, initialLiked, likeCount); + } + } + }, [id, initialLiked, likeCount, setLike, likeMap]); const handleCardClick = () => { - navigate(`/board/detail/${id}`); + navigate(`/board/review/${id}`); }; + const handleImageError = () => { + setImageError(true); + }; + + // 후기 전용 좋아요 버튼 클릭 핸들러 + const handleLikeClick = async (e) => { + e.stopPropagation(); + + const currentState = likeState; + + console.log('ReviewCard 좋아요 클릭:', { + id, + currentState, + liked: currentState.liked, + likeCount: currentState.likeCount + }); + + try { + // 1. 즉시 UI 업데이트 (Optimistic Update) + const newLiked = !currentState.liked; + const newCount = currentState.likeCount + (newLiked ? 1 : -1); + + console.log('Optimistic Update:', { newLiked, newCount }); + + // Zustand 상태 즉시 업데이트 + updateLike(id, newLiked, newCount); + + // 2. 서버 요청 + const result = await toggleReviewLikeMutation.mutateAsync(id); + + // 3. 서버 응답으로 정확한 상태 동기화 + const serverLiked = result.liked ?? result.like ?? newLiked; + const serverCount = result.likeCount ?? newCount; + + console.log('서버 응답 동기화:', { + result, + serverLiked, + serverCount + }); + + updateLike(id, serverLiked, serverCount); + + // 4. React Query 캐시 업데이트 (상세 페이지와 동기화) + queryClient.setQueryData(['review', id], (oldData) => { + if (oldData?.result) { + return { + ...oldData, + result: { + ...oldData.result, + liked: serverLiked, + likeCount: serverCount + } + }; + } else if (oldData) { + // 상세 페이지 데이터가 직접 저장된 경우 + return { + ...oldData, + liked: serverLiked, + likeCount: serverCount + }; + } + return oldData; + }); + + // 5. 리뷰 리스트 캐시도 업데이트 + queryClient.setQueriesData( + { queryKey: ['reviews'] }, + (oldData) => { + if (oldData?.result?.content) { + return { + ...oldData, + result: { + ...oldData.result, + content: oldData.result.content.map(review => + review.id === id + ? { ...review, liked: serverLiked, likeCount: serverCount } + : review + ) + } + }; + } + return oldData; + } + ); + + } catch (error) { + // 실패 시 이전 상태로 롤백 + updateLike(id, currentState.liked, currentState.likeCount); + console.error('후기 좋아요 처리 실패:', error); + } + }; + + const plainContent = stripHtml(content); + + // 디버깅용 로그 + console.log('ReviewCard 렌더링:', { + id, + title, + image, + imageError, + hasImage: !!image + }); + return ( - {content} - {content.split(' ').length > 20 && ( - 더보기 > + {plainContent} + {plainContent.split(' ').length > 20 && ( + 더보기 > )} - e.stopPropagation()}> - + + @@ -34,7 +172,17 @@ const ReviewCard = ({ id, category, image, title, content, date, writer, likeCou - + {image && !imageError ? ( + + ) : ( + + 이미지 없음 + + )} {title} @@ -44,7 +192,7 @@ const ReviewCard = ({ id, category, image, title, content, date, writer, likeCou export default ReviewCard; -// 기존 스타일들 (CategoryTag 관련 스타일 제거) +// 스타일 컴포넌트들은 동일... const Card = styled.div` width: 353px; height: 453px; @@ -64,6 +212,8 @@ const ImageContainer = styled.div` position: relative; border-radius: 18px; overflow: hidden; + width: 100%; + height: 100%; &:hover div.hover-overlay { opacity: 1; @@ -71,8 +221,9 @@ const ImageContainer = styled.div` `; const StyledImage = styled.img` - width: 350px; - height: 450px; + width: 100%; + height: 100%; + object-fit: cover; display: block; border-radius: 20px; `; @@ -181,3 +332,19 @@ const HoverMore = styled.div` font-size: 16px; color: #fff; `; + +const NoImagePlaceholder = styled.div` + width: 100%; + height: 100%; + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + display: flex; + align-items: center; + justify-content: center; + border-radius: 20px; +`; + +const NoImageText = styled.div` + color: #666; + font-size: 16px; + font-weight: 500; +`; diff --git a/src/components/board/ToolBar.jsx b/src/components/board/ToolBar.jsx index 21ec1b3..16294ab 100644 --- a/src/components/board/ToolBar.jsx +++ b/src/components/board/ToolBar.jsx @@ -10,26 +10,74 @@ import { AlignLeft, AlignCenter, AlignRight, - Palette, } from "lucide-react"; +import { uploadMultipleImages } from "../../api/PostApi"; export default function Toolbar({ editor, onImageInsert }) { const fileInputRef = useRef(null); - const handleImageUpload = () => { + // 이벤트 기본 동작 방지 함수 + const handleToolbarAction = (callback) => (e) => { + e.preventDefault(); + e.stopPropagation(); + callback(); + }; + + // 이미지 업로드 + const handleImageUpload = (e) => { + e.preventDefault(); + e.stopPropagation(); fileInputRef.current?.click(); }; - const onFileChange = (e) => { - const file = e.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onload = () => { - onImageInsert(reader.result); - }; - reader.readAsDataURL(file); + // 파일 처리 + const onFileChange = async (e) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + // 현재 에디터에 있는 이미지 개수 확인 + let currentImageCount = 0; + editor.state.doc.descendants((node) => { + if (node.type.name === 'image') currentImageCount += 1; + }); + + // 총 5개까지만 허용 + const remainingSlots = 5 - currentImageCount; + + if (remainingSlots <= 0) { + alert('이미지는 최대 5개까지만 업로드할 수 있습니다.'); + e.target.value = ''; + return; + } + + // 선택한 파일이 남은 슬롯보다 많으면 제한 + const filesToUpload = files.slice(0, remainingSlots); + + if (files.length > remainingSlots) { + alert(`이미지는 최대 5개까지만 업로드할 수 있습니다. ${filesToUpload.length}개만 업로드됩니다.`); + } + + try { + console.log(`${filesToUpload.length}개 이미지 업로드 중...`); + + // S3에 이미지 업로드 + const uploadedUrls = await uploadMultipleImages(filesToUpload); + + // 에디터에 이미지들 순차적으로 삽입 + uploadedUrls.forEach((url, index) => { + setTimeout(() => { + onImageInsert(url); + }, index * 100); + }); + + console.log(`${filesToUpload.length}개 이미지 업로드 완료`); + + } catch (error) { + console.error('이미지 업로드 실패:', error); + alert('이미지 업로드에 실패했습니다: ' + error.message); + } finally { + e.target.value = ''; } - e.target.value = ''; }; const handleColorChange = (e) => { @@ -37,50 +85,103 @@ export default function Toolbar({ editor, onImageInsert }) { editor.chain().focus().setColor(color).run(); }; + // 현재 이미지 개수 표시용 함수 + const getCurrentImageCount = () => { + let count = 0; + editor?.state.doc.descendants((node) => { + if (node.type.name === 'image') count += 1; + }); + return count; + }; + return ( <> - editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")}> + editor.chain().focus().toggleBold().run())} + active={editor.isActive("bold")} + > - editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")}> + + editor.chain().focus().toggleItalic().run())} + active={editor.isActive("italic")} + > - editor.chain().focus().toggleUnderline().run()} active={editor.isActive("underline")}> + + editor.chain().focus().toggleUnderline().run())} + active={editor.isActive("underline")} + > - editor.chain().focus().setTextAlign("left").run()} active={editor.isActive({ textAlign: "left" })}> + editor.chain().focus().setTextAlign("left").run())} + active={editor.isActive({ textAlign: "left" })} + > - editor.chain().focus().setTextAlign("center").run()} active={editor.isActive({ textAlign: "center" })}> + + editor.chain().focus().setTextAlign("center").run())} + active={editor.isActive({ textAlign: "center" })} + > - editor.chain().focus().setTextAlign("right").run()} active={editor.isActive({ textAlign: "right" })}> + + editor.chain().focus().setTextAlign("right").run())} + active={editor.isActive({ textAlign: "right" })} + > - - - + + + + + {getCurrentImageCount()}/5 + - editor.chain().focus().undo().run()}> + editor.chain().focus().undo().run())} + > - editor.chain().focus().redo().run()}> + + editor.chain().focus().redo().run())} + > + {/* 이미지 업로드 (단일/여러 개 모두 지원) */} { + navigate(`/board/detail/${postId}`); + }; + return ( @@ -14,7 +20,7 @@ export default function FreePostList({ posts, currentPage, itemsPerPage }) { 날짜 - + @@ -22,7 +28,9 @@ export default function FreePostList({ posts, currentPage, itemsPerPage }) { handlePostClick(post.post_id)} + style={{ cursor: 'pointer' }} > {(currentPage - 1) * itemsPerPage + idx + 1} {post.post_title} diff --git a/src/components/board/reviewboard/ReviewCard.jsx b/src/components/board/reviewboard/ReviewCard.jsx index 34b635e..1ad2cf8 100644 --- a/src/components/board/reviewboard/ReviewCard.jsx +++ b/src/components/board/reviewboard/ReviewCard.jsx @@ -1,30 +1,168 @@ -import React from "react"; +import React, { useEffect } from "react"; import styled from "styled-components"; -import useLike from "../../../hooks/useLike"; +import { useToggleReviewLike } from "../../../query/usePost"; +import { useReviewLikeStore } from "../../../store/reviewLikeStore"; import LikeButton from "../LikeButton"; import CategoryTag from "../../common/CategoryTag"; import { useNavigate } from "react-router-dom"; +import { useQueryClient } from '@tanstack/react-query'; -const ReviewCard = ({ id, category, image, title, content, date, writer, likeCount }) => { - const { liked, count, toggleLike } = useLike(likeCount, false); +const ReviewCard = ({ id, category, image, title, content, date, writer, likeCount, liked: initialLiked }) => { + const { likeMap, setLike, updateLike } = useReviewLikeStore(); + const likeState = likeMap[id] || { liked: initialLiked || false, likeCount: likeCount || 0 }; + const toggleReviewLikeMutation = useToggleReviewLike(); const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [imageError, setImageError] = React.useState(false); + + // HTML 태그 제거 함수 + const stripHtml = (html) => { + const tmp = document.createElement('DIV'); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ''; + }; + + // 초기 좋아요 상태 설정 + useEffect(() => { + if (id && typeof initialLiked !== 'undefined' && typeof likeCount !== 'undefined') { + console.log('ReviewCard 초기 좋아요 상태 설정:', { + id, + initialLiked, + likeCount, + 현재스토어상태: likeMap[id] + }); + + // 스토어에 해당 ID가 없을 때만 설정 (이미 클릭으로 변경된 상태 보존) + if (!likeMap[id]) { + setLike(id, initialLiked, likeCount); + } + } + }, [id, initialLiked, likeCount, setLike, likeMap]); const handleCardClick = () => { - navigate(`/board/detail/${id}`); + navigate(`/board/review/${id}`); }; + const handleImageError = () => { + setImageError(true); + }; + + // 후기 전용 좋아요 버튼 클릭 핸들러 + const handleLikeClick = async (e) => { + e.stopPropagation(); + + const currentState = likeState; + + console.log('ReviewCard 좋아요 클릭:', { + id, + currentState, + liked: currentState.liked, + likeCount: currentState.likeCount + }); + + try { + // 1. 즉시 UI 업데이트 (Optimistic Update) + const newLiked = !currentState.liked; + const newCount = currentState.likeCount + (newLiked ? 1 : -1); + + console.log('Optimistic Update:', { newLiked, newCount }); + + // Zustand 상태 즉시 업데이트 + updateLike(id, newLiked, newCount); + + // 2. 서버 요청 + const result = await toggleReviewLikeMutation.mutateAsync(id); + + // 3. 서버 응답으로 정확한 상태 동기화 + const serverLiked = result.liked ?? result.like ?? newLiked; + const serverCount = result.likeCount ?? newCount; + + console.log('서버 응답 동기화:', { + result, + serverLiked, + serverCount + }); + + updateLike(id, serverLiked, serverCount); + + // 4. React Query 캐시 업데이트 (상세 페이지와 동기화) + queryClient.setQueryData(['review', id], (oldData) => { + if (oldData?.result) { + return { + ...oldData, + result: { + ...oldData.result, + liked: serverLiked, + likeCount: serverCount + } + }; + } else if (oldData) { + // 상세 페이지 데이터가 직접 저장된 경우 + return { + ...oldData, + liked: serverLiked, + likeCount: serverCount + }; + } + return oldData; + }); + + // 5. 리뷰 리스트 캐시도 업데이트 + queryClient.setQueriesData( + { queryKey: ['reviews'] }, + (oldData) => { + if (oldData?.result?.content) { + return { + ...oldData, + result: { + ...oldData.result, + content: oldData.result.content.map(review => + review.id === id + ? { ...review, liked: serverLiked, likeCount: serverCount } + : review + ) + } + }; + } + return oldData; + } + ); + + } catch (error) { + // 실패 시 이전 상태로 롤백 + updateLike(id, currentState.liked, currentState.likeCount); + console.error('후기 좋아요 처리 실패:', error); + } + }; + + const plainContent = stripHtml(content); + + // 디버깅용 로그 + console.log('ReviewCard 렌더링:', { + id, + title, + image, + imageError, + hasImage: !!image + }); + return ( - {content} - {content.split(' ').length > 20 && ( - 더보기 > + {plainContent} + {plainContent.split(' ').length > 20 && ( + 더보기 > )} - e.stopPropagation()}> - + + @@ -34,7 +172,17 @@ const ReviewCard = ({ id, category, image, title, content, date, writer, likeCou - + {image && !imageError ? ( + + ) : ( + + 이미지 없음 + + )} {title} @@ -44,7 +192,7 @@ const ReviewCard = ({ id, category, image, title, content, date, writer, likeCou export default ReviewCard; -// 기존 스타일들 (CategoryTag 관련 스타일 제거) +// 스타일 컴포넌트들은 동일... const Card = styled.div` width: 353px; height: 453px; @@ -64,6 +212,8 @@ const ImageContainer = styled.div` position: relative; border-radius: 18px; overflow: hidden; + width: 100%; + height: 100%; &:hover div.hover-overlay { opacity: 1; @@ -71,8 +221,9 @@ const ImageContainer = styled.div` `; const StyledImage = styled.img` - width: 350px; - height: 450px; + width: 100%; + height: 100%; + object-fit: cover; display: block; border-radius: 20px; `; @@ -181,3 +332,19 @@ const HoverMore = styled.div` font-size: 16px; color: #fff; `; + +const NoImagePlaceholder = styled.div` + width: 100%; + height: 100%; + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + display: flex; + align-items: center; + justify-content: center; + border-radius: 20px; +`; + +const NoImageText = styled.div` + color: #666; + font-size: 16px; + font-weight: 500; +`; diff --git a/src/components/chatbot/Chatbot.jsx b/src/components/chatbot/Chatbot.jsx index 7cc1f4b..4a2ffbb 100644 --- a/src/components/chatbot/Chatbot.jsx +++ b/src/components/chatbot/Chatbot.jsx @@ -7,67 +7,264 @@ import card1 from '../../assets/images/main/Card1Button.png'; import card2 from '../../assets/images/main/Card2Button.png'; import card3 from '../../assets/images/main/Card3Button.png'; import daily from '../../assets/images/main/DailyButton.png'; -import activityImage from '../../assets/images/issue/ic_IssueCardSample.png'; +import { askChatbot, resetChatbot, askChatbotHistoryRecommendation, resetChatbotHistoryRecommendation, askChatbotKeywordRecommendation, resetChatbotKeywordRecommendation, askChatbotOthers, resetChatbotOthers } from '../../api/ChatBotApi'; + +// 링크를 감지하고 클릭 가능하게 만드는 컴포넌트 +const LinkifiedText = ({ text }) => { + if (typeof text !== 'string') { + return text; + } + + // ** 제거 (Source 옆의 ** 등) + let processedText = text.replace(/\*\*/g, ''); + + // 마크다운 링크 형식 [텍스트](URL) 처리 + const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g; + processedText = processedText.replace(markdownLinkRegex, (match, linkText, url) => { + return `__MARKDOWN_LINK__${linkText}__SEPARATOR__${url}__END_LINK__`; + }); + + // 일반 URL 패턴 + const urlRegex = /(https?:\/\/[^\s<>"\[\]]+)/g; + + // 먼저 마크다운 링크를 분리 + const parts = processedText.split(/(__MARKDOWN_LINK__.*?__END_LINK__)/); + + return parts.map((part, index) => { + // 마크다운 링크 처리 + if (part.startsWith('__MARKDOWN_LINK__')) { + const [, linkText, url] = part.match(/__MARKDOWN_LINK__(.*?)__SEPARATOR__(.*?)__END_LINK__/); + return ( + + {linkText} + + ); + } + + // Source 굵게 처리 (간단한 방법) + const sourceRegex = /(Source\s*:?|출처\s*:?)/gi; + if (sourceRegex.test(part)) { + const sourceParts = part.split(/(Source\s*:?|출처\s*:?)/gi); + return sourceParts.map((sourcePart, sourceIndex) => { + if (/(Source\s*:?|출처\s*:?)/gi.test(sourcePart)) { + return ( + + {sourcePart} + + ); + } + + // 일반 URL 처리 + const urlParts = sourcePart.split(urlRegex); + return urlParts.map((urlPart, urlIndex) => { + if (urlRegex.test(urlPart)) { + return ( + + {urlPart} + + ); + } + return urlPart; + }); + }); + } + + // 일반 URL 처리 + const urlParts = part.split(urlRegex); + return urlParts.map((urlPart, urlIndex) => { + if (urlRegex.test(urlPart)) { + return ( + + {urlPart} + + ); + } + return urlPart; + }); + }); +}; export default function Chatbot() { const [showChatbot, setShowChatbot] = useState(false); const [chatMessages, setChatMessages] = useState([]); const [userInput, setUserInput] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectedApiMode, setSelectedApiMode] = useState(null); // 선택된 API 모드 const messageEndRef = useRef(null); - const handleCardClick = (option) => { - const newBotMessage = { - '글로벌 이슈': '요즘 뜨는 글로벌 이슈는 “관세 전쟁”, “경제 불확실성” 등이 있습니다.', - '관심사 기반': '당신이 좋아할 만한 활동은 공모전, 서포터즈 등이 있습니다.', - '분야 기반': '환경, 기술, 사회 분야에 맞춘 활동이 있어요.', - '일상 대화': '안녕하세요! 좋은 하루 보내고 계신가요?', - }[option]; + // 현재 로그인한 사용자 ID 가져오기 + const getUserId = () => { + // 1. localStorage에서 직접 userId 확인 + const storedUserId = localStorage.getItem('userId'); + if (storedUserId) { + return storedUserId; + } + + // 2. localStorage에서 user 객체 확인 + const storedUser = localStorage.getItem('user'); + if (storedUser) { + try { + const userObj = JSON.parse(storedUser); + if (userObj.id) return userObj.id; + if (userObj.userId) return userObj.userId; + } catch (error) { + console.error('사용자 정보 파싱 실패:', error); + } + } + + // 3. localStorage에서 userInfo 객체 확인 + const storedUserInfo = localStorage.getItem('userInfo'); + if (storedUserInfo) { + try { + const userInfoObj = JSON.parse(storedUserInfo); + if (userInfoObj.id) return userInfoObj.id; + if (userInfoObj.userId) return userInfoObj.userId; + } catch (error) { + console.error('사용자 정보 파싱 실패:', error); + } + } + + // 4. 로그인되지 않은 경우 null 반환 (또는 로그인 페이지로 리다이렉트) + console.warn('로그인된 사용자 ID를 찾을 수 없습니다.'); + return null; + }; + + const handleCardClick = async (option) => { + // 로그인 확인 + const userId = getUserId(); + if (!userId) { + setChatMessages((prev) => [ + ...prev, + { type: 'bot', text: '챗봇을 사용하려면 로그인이 필요합니다. \n로그인 후 다시 이용해주세요.' }, + ]); + return; + } + // API 모드 선택 + setSelectedApiMode(option); + + const modeMessages = { + '글로벌 이슈': '웹 검색 기반 글로벌 이슈 추천 모드가 선택되었습니다. \n궁금한 이슈나 뉴스를 물어보세요!', + '관심사 기반': '관심사 기반 활동 추천 모드가 선택되었습니다. \n어떤 활동을 찾고 계신가요?', + '분야 기반': '분야 기반 활동 추천 모드가 선택되었습니다. \n관심 분야를 알려주세요!', + '일상 대화': '일상 대화 모드가 선택되었습니다. \n편하게 대화해보세요!', + }; + + // 모드 선택 메시지 추가 setChatMessages((prev) => [ ...prev, - { type: 'user', text: `${option} 추천해줘` }, - { type: 'bot', text: newBotMessage }, + { type: 'bot', text: modeMessages[option] }, ]); + + // 선택된 모드에 따라 해당 챗봇 초기화 + try { + if (option === '글로벌 이슈') { + await resetChatbot(userId); + } else if (option === '관심사 기반') { + await resetChatbotHistoryRecommendation(userId); + } else if (option === '분야 기반') { + await resetChatbotKeywordRecommendation(userId); + } else if (option === '일상 대화') { + await resetChatbotOthers(userId); + } else { + // 기본값은 웹 검색 챗봇으로 초기화 + await resetChatbot(userId); + } + } catch (error) { + console.error('챗봇 모드 초기화 실패:', error); + } }; - const handleUserSubmit = () => { - if (isSubmitting) return; + const handleUserSubmit = async () => { + if (isSubmitting || isLoading) return; const trimmed = userInput.trim(); if (!trimmed) return; - setIsSubmitting(true); + // 로그인 확인 + const userId = getUserId(); + if (!userId) { + setChatMessages((prev) => [ + ...prev, + { type: 'user', text: trimmed }, + { type: 'bot', text: '챗봇을 사용하려면 로그인이 필요합니다. \n로그인 후 다시 이용해주세요.' }, + ]); + setUserInput(''); + return; + } - let botResponse; - - if (trimmed.includes('나에게 맞는 활동을 추천해줘')) { - botResponse = ( -
-

이런 활동은 어떠세요?
관심사에 맞는 활동들을 아래에서 확인해보세요!

- -

1. 청년 마음돌봄 서포터즈 모집

-

• 활동 내용 : 국민건강보험공단에서 정신건강에 관심 많은 대학생 모집

- 서포터즈 -
- -

2. HFN 환자 지원 기부 캠페인

-

• 활동 내용 : 환자들에게 필요한 지원을 위한 기부 활동

- 기부 캠페인 -
-
- ); - } else { - botResponse = '추천활동 4가지를 선택해주세요'; + // API 모드가 선택되지 않은 경우 선택 요청 + if (!selectedApiMode) { + setChatMessages((prev) => [ + ...prev, + { type: 'user', text: trimmed }, + { type: 'bot', text: '먼저 위의 카드 중 하나를 선택해주세요! \n선택하신 모드에 따라 적절한 답변을 드리겠습니다.' }, + ]); + setUserInput(''); + return; } + setIsSubmitting(true); + setIsLoading(true); + + // 사용자 메시지 추가 setChatMessages((prev) => [ ...prev, { type: 'user', text: trimmed }, - { type: 'bot', text: botResponse }, ]); setUserInput(''); - setTimeout(() => setIsSubmitting(false), 300); + + try { + let botResponse; + console.log('사용자 ID:', userId, '모드:', selectedApiMode); + + if (selectedApiMode === '글로벌 이슈') { + // 웹 검색 기반 질문 응답 API 사용 + botResponse = await askChatbot(userId, trimmed); + } else if (selectedApiMode === '관심사 기반') { + // 관심사 기반 활동 추천 API 사용 + botResponse = await askChatbotHistoryRecommendation(userId, trimmed); + } else if (selectedApiMode === '분야 기반') { + // 분야 기반(키워드) 활동 추천 API 사용 + botResponse = await askChatbotKeywordRecommendation(userId, trimmed); + } else if (selectedApiMode === '일상 대화') { + // 일상 대화(기타 질문) API 사용 + botResponse = await askChatbotOthers(userId, trimmed); + } else { + // 기본값은 웹 검색 API 사용 + botResponse = await askChatbot(userId, trimmed); + } + + setChatMessages((prev) => [ + ...prev, + { type: 'bot', text: botResponse }, + ]); + } catch (error) { + console.error('챗봇 API 호출 실패:', error); + setChatMessages((prev) => [ + ...prev, + { type: 'bot', text: '죄송합니다. 현재 서비스에 문제가 있어 응답할 수 없습니다. \n잠시 후 다시 시도해주세요.' }, + ]); + } finally { + setIsSubmitting(false); + setIsLoading(false); + } }; useEffect(() => { @@ -76,9 +273,39 @@ export default function Chatbot() { } }, [chatMessages]); + // 챗봇 열림/닫힘에 따라 배경 스크롤 제어 + useEffect(() => { + if (showChatbot) { + // 챗봇이 열릴 때 배경 스크롤 막기 + document.body.style.overflow = 'hidden'; + } else { + // 챗봇이 닫힐 때 배경 스크롤 복원 + document.body.style.overflow = 'unset'; + } + + // 컴포넌트 언마운트 시 스크롤 복원 + return () => { + document.body.style.overflow = 'unset'; + }; + }, [showChatbot]); + return ( <> - setShowChatbot((prev) => !prev)}> + { + setShowChatbot((prev) => { + if (!prev) { + // 챗봇을 열 때 대화 및 모드 초기화 + setChatMessages([]); + setSelectedApiMode(null); + // 로그인한 사용자가 있는 경우에만 웹 검색 기반 챗봇으로 초기화 + const userId = getUserId(); + if (userId) { + resetChatbot(userId).catch(console.error); + } + } + return !prev; + }); + }}> 챗봇 버튼 {showChatbot && ( - + <> + setShowChatbot(false)} /> + 챗봇

챗봇 Trendy

@@ -100,54 +329,96 @@ export default function Chatbot() { - handleCardClick('글로벌 이슈')}> + handleCardClick('글로벌 이슈')} + selected={selectedApiMode === '글로벌 이슈'} + > 글로벌 이슈 추천 - 요즘 뜨는 글로벌 이슈 추천 - “태풍관련 뉴스 알려줘” + 웹 검색 기반 글로벌 이슈 + "태풍관련 뉴스 알려줘" - handleCardClick('관심사 기반')}> + handleCardClick('관심사 기반')} + selected={selectedApiMode === '관심사 기반'} + > 관심사 활동 추천 관심사 기반 활동 추천 - “내가 좋아할 만한 활동 알려줘” + "내가 좋아할 만한 활동 알려줘" - handleCardClick('분야 기반')}> + handleCardClick('분야 기반')} + selected={selectedApiMode === '분야 기반'} + > 분야 활동 추천 분야 기반 활동 추천 - “환경 관련 활동 뭐 있을까?” + "환경 관련 활동 뭐 있을까?" - handleCardClick('일상 대화')}> + handleCardClick('일상 대화')} + selected={selectedApiMode === '일상 대화'} + > 일상 대화 일상 대화 - “안녕! 좋은 아침이야” + "안녕! 좋은 아침이야" {chatMessages.map((msg, i) => ( - {typeof msg.text === 'string' ? msg.text : msg.text} + ))} + + {isLoading && ( + + {selectedApiMode === '관심사 기반' + ? '개인화된 활동을 추천하고 있습니다...' + : selectedApiMode === '분야 기반' + ? '해당 분야의 활동을 찾고 있습니다...' + : '답변을 생성하고 있습니다...' + } + + )} +
setUserInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleUserSubmit()} + onKeyDown={(e) => e.key === 'Enter' && !isLoading && handleUserSubmit()} + disabled={isLoading} /> - + 전송 + )} ); } +const ChatbotOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.3); + z-index: 9999; +`; + const ChatbotButton = styled.button` position: fixed; bottom: 32px; @@ -234,13 +505,17 @@ const ChatInput = styled.input` outline: none; font-size: 16px; padding: 6px; + background-color: ${({ disabled }) => (disabled ? '#f5f5f5' : 'white')}; + color: ${({ disabled }) => (disabled ? '#999' : '#000')}; + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'text')}; `; const SendButton = styled.button` background: none; border: none; padding: 4px; - cursor: pointer; + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; + opacity: ${({ disabled }) => (disabled ? 0.5 : 1)}; img { width: 24px; @@ -257,14 +532,17 @@ const ChatBubble = styled.div` font-size: 14px; font-weight: 500; color: #000; - display: inline-block; /* 텍스트 크기에 맞는 블록 */ - width: fit-content; /* 내용에 맞춰 폭 조절 */ - max-width: 80%; /* 너무 길면 최대 80% 까지만 */ + display: block; + width: ${({ type }) => (type === 'bot' ? '100%' : 'fit-content')}; /* 봇은 전체, 사용자는 내용에 맞춤 */ + max-width: 95%; + box-sizing: border-box; box-shadow: ${({ type }) => type === 'user' ? '0px 4px 10px rgba(0, 0, 0, 0.1)' : 'none'}; border: ${({ type }) => (type === 'user' ? '2px solid #D9EAFF' : 'none')}; - word-break: keep-all; - white-space: pre-wrap; + word-break: break-word; + white-space: pre-wrap; + overflow-wrap: break-word; + line-height: 1.4; img { margin-top: 10px; @@ -306,15 +584,23 @@ const ScrollCardRow = styled.div` const CardItem = styled.div` width: 150px; height: 160px; - background: #f5f5f5; + background: ${({ selected }) => (selected ? '#e6f3ff' : '#f5f5f5')}; + border: ${({ selected }) => (selected ? '2px solid #235BA9' : '2px solid transparent')}; border-radius: 12px; text-align: center; padding: 12px 8px; box-sizing: border-box; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + + &:hover { + background: ${({ selected }) => (selected ? '#d6ebff' : '#e8e8e8')}; + } img { - width: 100px; - height: 100px; + width: 90px; + height: 90px; object-fit: contain; margin-bottom: 6px; } @@ -354,3 +640,34 @@ const ActivityBlock = styled.div` display: block; } `; + +const SelectedBadge = styled.div` + background-color: #235BA9; + color: white; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + margin-top: 4px; +`; + +const ChatLink = styled.a` + color: #235BA9; + text-decoration: underline; + cursor: pointer; + word-break: break-all; + + &:hover { + color: #1a4480; + text-decoration: none; + } + + &:visited { + color: #6b46c1; + } +`; + +const BoldText = styled.strong` + font-weight: 700; + color: #000; + font-size: inherit; +`; diff --git a/src/components/common/SearchBar.jsx b/src/components/common/SearchBar.jsx deleted file mode 100644 index 2d92156..0000000 --- a/src/components/common/SearchBar.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; - -const SearchBar = ({ value, onChange, onSearch, placeholder, inputRef }) => { - const handleKeyPress = (e) => { - if (e.key === 'Enter') { - onSearch(); - } - }; - - return ( - - - 검색 - - ); -}; - -const SearchContainer = styled.div` - display: flex; - align-items: center; - width: 600px; - height: 60px; - border: 2px solid #235BA9; - border-radius: 30px; - padding: 0 20px; -`; - -const SearchInput = styled.input` - flex: 1; - border: none; - outline: none; - font-size: 18px; - padding: 0 10px; - &::placeholder { - color: #999; - } -`; - -const SearchButton = styled.button` - width: 80px; - height: 40px; - background-color: #235BA9; - color: white; - border: none; - border-radius: 20px; - font-size: 16px; - cursor: pointer; - &:hover { - background-color: #1a4b8c; - } -`; - -export default SearchBar; \ No newline at end of file diff --git a/src/components/interest/InterestSelect.jsx b/src/components/interest/InterestSelect.jsx index 7d819f2..06820fe 100644 --- a/src/components/interest/InterestSelect.jsx +++ b/src/components/interest/InterestSelect.jsx @@ -9,6 +9,26 @@ import activeSociety from '../../assets/images/interestmodal/ic_SocietyInterest_ import activeEconomy from '../../assets/images/interestmodal/ic_EconomyInterest_active.png'; import activeTech from '../../assets/images/interestmodal/ic_TechInterest_active.png'; +// API 키워드와 UI ID 매핑 +const KEYWORD_TO_ID_MAP = { + 'Environment': 'environment', + 'PeopleAndSociety': 'society', + 'Economy': 'economy', + 'Technology': 'tech', + // 한국어도 지원 + '환경': 'environment', + '사람과 사회': 'society', + '경제': 'economy', + '기술': 'tech' +}; + +const ID_TO_KEYWORD_MAP = { + 'environment': 'Environment', + 'society': 'PeopleAndSociety', + 'economy': 'Economy', + 'tech': 'Technology' +}; + const interestItems = [ { id: 'environment', label: '환경', image: envCard, activeImage: activeEnv }, { id: 'society', label: '사람과 사회', image: societyCard, activeImage: activeSociety }, @@ -17,20 +37,29 @@ const interestItems = [ ]; export default function InterestSelect({ selected, onSelect }) { + // API 키워드를 UI ID로 변환 + const selectedId = KEYWORD_TO_ID_MAP[selected] || selected; + + const handleSelect = (id) => { + // UI ID를 API 키워드로 변환하여 전달 + const keyword = ID_TO_KEYWORD_MAP[id] || id; + onSelect(keyword); + }; + return ( {interestItems.map((item) => ( onSelect(item.id)} + selected={selectedId === item.id} + onClick={() => handleSelect(item.id)} > - {item.label} + {item.label} ))} diff --git a/src/components/issue/IssueCard.jsx b/src/components/issue/IssueCard.jsx index 3b7bdc2..49b5a30 100644 --- a/src/components/issue/IssueCard.jsx +++ b/src/components/issue/IssueCard.jsx @@ -20,7 +20,6 @@ export default function IssueCard({ title, tag, image, bookmarked, onToggle, onC ); } - const Card = styled.div` width: 330px; height: 430px; @@ -31,22 +30,22 @@ const Card = styled.div` display: flex; flex-direction: column; justify-content: flex-start; - overflow: visible; .issue-title { - font-size: 24px; + font-size: 23px; font-weight: bold; - margin-top: 40px; + margin-top: 50px; font-family: NotoSansCustom; padding: 0 20px; word-break: keep-all; - - height: 68px; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; + height: 68px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.2em; + max-height: 2.4em; } .issue-tag { diff --git a/src/components/my/ActivityTrendChart.jsx b/src/components/my/ActivityTrendChart.jsx index 4e22e86..372b1a5 100644 --- a/src/components/my/ActivityTrendChart.jsx +++ b/src/components/my/ActivityTrendChart.jsx @@ -1,5 +1,7 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import styled from 'styled-components'; +import { useMyMonthlyStats, useUserMonthlyStats } from '../../query/usePost'; +import CustomDropdown from '../common/CustomDropdown'; import { LineChart, @@ -11,32 +13,121 @@ import { ResponsiveContainer, } from 'recharts'; -const trendData = [ - { month: '1월', count: 11 }, - { month: '2월', count: 5 }, - { month: '3월', count: 13 }, - { month: '4월', count: 8 }, - { month: '5월', count: 15 }, - { month: '6월', count: 4 }, - { month: '7월', count: 12 }, - { month: '8월', count: 3 }, - { month: '9월', count: 4 }, - { month: '10월', count: 2 }, - { month: '11월', count: 9 }, - { month: '12월', count: 15 }, -]; - -const ActivityTrendChart = () => { - console.log('ActivityTrendChart data:', trendData); +// userId prop을 받아서 본인/다른 사용자 구분 +const ActivityTrendChart = ({ userId = null }) => { + // 조건부 API 호출 - userId가 있으면 특정 사용자, 없으면 내 통계 + const { data: myMonthlyStats, isLoading: myIsLoading, isError: myIsError } = useMyMonthlyStats({ enabled: !userId }); + const { data: userMonthlyStats, isLoading: userIsLoading, isError: userIsError } = useUserMonthlyStats(userId); + + // 실제 사용할 데이터 선택 + const monthlyStats = userId ? userMonthlyStats : myMonthlyStats; + const isLoading = userId ? userIsLoading : myIsLoading; + const isError = userId ? userIsError : myIsError; + + // 년도별 필터링을 위한 state + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear().toString()); + + // 사용 가능한 년도 목록 추출 (최근 3년만, 데이터 유무 관계없이 모두 표시) + const availableYears = useMemo(() => { + const currentYear = new Date().getFullYear(); + const recentThreeYears = [currentYear, currentYear - 1, currentYear - 2].map(year => year.toString()); + + // 데이터 유무와 관계없이 항상 최근 3년을 모두 표시 + return recentThreeYears; + }, []); + + // 첫 번째 데이터 로드 시 최신 년도로 자동 설정 + useMemo(() => { + if (availableYears.length > 0 && !availableYears.includes(selectedYear)) { + setSelectedYear(availableYears[0]); + } + }, [availableYears, selectedYear]); + + // API 데이터를 차트 형식으로 변환 + const chartData = useMemo(() => { + console.log('월별 통계 데이터:', monthlyStats); + console.log('선택된 년도:', selectedYear); + + if (!monthlyStats || monthlyStats.length === 0) { + // 데이터가 없을 때 12개월 빈 데이터 생성 + return Array.from({ length: 12 }, (_, index) => ({ + month: `${index + 1}월`, + count: 0 + })); + } + + // 선택된 년도의 데이터만 필터링 + const filteredData = monthlyStats.filter(item => item.year.toString() === selectedYear); + + // 전체 12개월 데이터 배열 생성 (1월~12월) + const allMonths = Array.from({ length: 12 }, (_, index) => { + const monthNumber = index + 1; + return { + month: `${monthNumber}월`, + count: 0 + }; + }); + + // API 데이터로 실제 값 업데이트 + filteredData.forEach(item => { + const monthIndex = item.month - 1; // API는 1-12, 배열은 0-11 + if (monthIndex >= 0 && monthIndex < 12) { + allMonths[monthIndex].count = item.count; + } + }); + + return allMonths; + }, [monthlyStats, selectedYear]); + + // 로딩 상태 처리 + if (isLoading) { + return ( + + + 활동 경과 + year.toString())} + selected={selectedYear} + onSelect={setSelectedYear} + /> + + 데이터를 불러오는 중... + + ); + } + + // 에러 상태 처리 + if (isError) { + return ( + + + 활동 경과 + year.toString())} + selected={selectedYear} + onSelect={setSelectedYear} + /> + + 데이터를 불러오는데 실패했습니다. + + ); + } + + console.log('ActivityTrendChart data:', chartData); return ( 활동 경과 + - + @@ -67,6 +158,9 @@ const ChartCard = styled.div` `; const ChartHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; margin-bottom: 24px; `; @@ -75,4 +169,22 @@ const ChartTitle = styled.h4` font-weight: 700; margin-bottom: 12px; color: #222; +`; + +const LoadingMessage = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 300px; + color: #666; + font-size: 16px; +`; + +const ErrorMessage = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 300px; + color: #e74c3c; + font-size: 16px; `; \ No newline at end of file diff --git a/src/components/my/ActivityTypeChart.jsx b/src/components/my/ActivityTypeChart.jsx index ced97d3..4cdb829 100644 --- a/src/components/my/ActivityTypeChart.jsx +++ b/src/components/my/ActivityTypeChart.jsx @@ -1,35 +1,137 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, CartesianGrid, LabelList } from 'recharts'; import styled from 'styled-components'; -import CustomDropdown from '../../components/common/CustomDropdown'; - -const dataByField = [ - { name: '전체', value: 15 }, - { name: '환경', value: 8 }, - { name: '사람과 사회', value: 2 }, - { name: '경제', value: 2 }, - { name: '기술', value: 3 }, -]; - -const dataByType = [ - { name: '전체', value: 15 }, - { name: '공모전', value: 8 }, - { name: '봉사활동', value: 2 }, - { name: '서포터즈', value: 5 }, - { name: '인턴십', value: 5 }, -]; +import CustomDropdown from '../../components/common/CustomDropdown'; +import { useMyKeywordStats, useMyActivityTypeStats, useUserKeywordStats, useUserActivityTypeStats } from '../../query/usePost'; + +// 키워드 매핑 +const KEYWORD_MAP = { + 'Environment': '환경', + 'PeopleAndSociety': '사람과 사회', + 'Economy': '경제', + 'Technology': '기술' +}; + +// 활동 유형 매핑 +const ACTIVITY_TYPE_MAP = { + 'CONTEST': '공모전', + 'VOLUNTEER': '봉사활동', + 'INTERNSHIP': '인턴십', + 'SUPPORTERS': '서포터즈' +}; const chartOptions = ['분야', '유형']; -export default function ActivityTypeSwitcherChart() { +export default function ActivityTypeSwitcherChart({ userId }) { const [mode, setMode] = useState('분야'); + + // 내 통계 데이터 가져오기 + const { data: myKeywordStats, isLoading: myKeywordLoading, isError: myKeywordError } = useMyKeywordStats(); + const { data: myActivityTypeStats, isLoading: myTypeLoading, isError: myTypeError } = useMyActivityTypeStats(); + + // 특정 사용자 통계 데이터 가져오기 + const { data: userKeywordStats, isLoading: userKeywordLoading, isError: userKeywordError } = useUserKeywordStats(userId); + const { data: userActivityTypeStats, isLoading: userTypeLoading, isError: userTypeError } = useUserActivityTypeStats(userId); + + // userId 유무에 따라 데이터 선택 + const keywordStats = userId ? userKeywordStats : myKeywordStats; + const activityTypeStats = userId ? userActivityTypeStats : myActivityTypeStats; + + // 분야별 데이터 처리 + const dataByField = useMemo(() => { + console.log('키워드 통계 데이터:', keywordStats); + + if (!keywordStats || keywordStats.length === 0) { + return [{ name: '전체', value: 0 }]; + } + + // API 데이터를 차트 형식으로 변환 + const fieldData = keywordStats.map(item => ({ + name: KEYWORD_MAP[item.keyword] || item.keyword, + value: item.count + })); + + // 전체 값 계산 + const totalValue = keywordStats.reduce((sum, item) => sum + item.count, 0); + + return [ + { name: '전체', value: totalValue }, + ...fieldData + ]; + }, [keywordStats]); + + // 유형별 데이터 처리 + const dataByType = useMemo(() => { + console.log('활동 유형 통계 데이터:', activityTypeStats); + + if (!activityTypeStats || activityTypeStats.length === 0) { + return [{ name: '전체', value: 0 }]; + } + + // API 데이터를 차트 형식으로 변환 + const typeData = activityTypeStats.map(item => ({ + name: ACTIVITY_TYPE_MAP[item.activityType] || item.activityType, + value: item.count + })); + + // 전체 값 계산 + const totalValue = activityTypeStats.reduce((sum, item) => sum + item.count, 0); + + return [ + { name: '전체', value: totalValue }, + ...typeData + ]; + }, [activityTypeStats]); const chartData = mode === '분야' ? dataByField : dataByType; const chartTitle = mode === '분야' ? '분야별 활동 기록' : '유형별 활동 기록'; + // 현재 모드와 userId에 따른 로딩/에러 상태 결정 + const isLoading = mode === '분야' + ? (userId ? userKeywordLoading : myKeywordLoading) + : (userId ? userTypeLoading : myTypeLoading); + + const isError = mode === '분야' + ? (userId ? userKeywordError : myKeywordError) + : (userId ? userTypeError : myTypeError); + + // 로딩 상태 처리 + if (isLoading) { + return ( + + + {chartTitle} + + + 데이터를 불러오는 중... + + ); + } + + // 에러 상태 처리 + if (isError) { + return ( + + + {chartTitle} + + + 데이터를 불러오는데 실패했습니다. + + ); + } + return ( @@ -42,7 +144,7 @@ export default function ActivityTypeSwitcherChart() { - + @@ -82,4 +184,22 @@ const ChartTitle = styled.h4` font-size: 20px; font-weight: 700; color: #222; +`; + +const LoadingMessage = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 300px; + color: #666; + font-size: 16px; +`; + +const ErrorMessage = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 300px; + color: #e74c3c; + font-size: 16px; `; \ No newline at end of file diff --git a/src/components/my/BookmarkList.jsx b/src/components/my/BookmarkList.jsx index a9031a5..fd590c4 100644 --- a/src/components/my/BookmarkList.jsx +++ b/src/components/my/BookmarkList.jsx @@ -1,44 +1,46 @@ -// src/components/mypage/BookmarkList.jsx - -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; -import usePagination from '../../hooks/usePagination'; import Pagination from '../common/Pagination'; import bookmarkIcon from '../../assets/images/common/BookmarkFilledButton.png'; import { useNavigate } from 'react-router-dom'; import activitiesApi from '../../api/ActivitiesApi'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useBookmarkedIssues, useToggleIssueBookmark } from '../../query/useIssues'; +import { formatDate } from '../../utils/formatDate'; // 쿼리 키 상수 정의 const QUERY_KEYS = { ACTIVITIES: 'activities', - BOOKMARKED_ACTIVITIES: 'bookmarkedActivities' + BOOKMARKED_ACTIVITIES: 'bookmarkedActivities', + BOOKMARKED_ISSUES: 'bookmarkedIssues' }; - export default function BookmarkList() { const [selectedType, setSelectedType] = useState('issue'); const [currentPage, setCurrentPage] = useState(0); const navigate = useNavigate(); const queryClient = useQueryClient(); - const { data: bookmarkedActivities, isLoading, error } = useQuery({ + // 북마크된 이슈 조회 + const { data: bookmarkedIssues, isLoading: issuesLoading, error: issuesError } = useBookmarkedIssues(currentPage); + + // 북마크된 활동 조회 + const { data: bookmarkedActivities, isLoading: activitiesLoading, error: activitiesError } = useQuery({ queryKey: [QUERY_KEYS.BOOKMARKED_ACTIVITIES, currentPage], queryFn: () => activitiesApi.getBookmarkedActivities(currentPage), enabled: selectedType === 'activity' }); - const toggleBookmarkMutation = useMutation({ + // 이슈 북마크 토글 + const toggleIssueBookmark = useToggleIssueBookmark(); + + // 활동 북마크 토글 + const toggleActivityBookmark = useMutation({ mutationFn: (activityId) => activitiesApi.toggleBookmark(activityId), onMutate: async (activityId) => { - // 진행 중인 모든 관련 쿼리 취소 - await queryClient.cancelQueries({ queryKey: [QUERY_KEYS.ACTIVITIES] }); await queryClient.cancelQueries({ queryKey: [QUERY_KEYS.BOOKMARKED_ACTIVITIES] }); - - // 이전 데이터 저장 const previousData = queryClient.getQueryData([QUERY_KEYS.BOOKMARKED_ACTIVITIES, currentPage]); - // 낙관적 업데이트 queryClient.setQueryData([QUERY_KEYS.BOOKMARKED_ACTIVITIES, currentPage], (old) => { if (!old) return old; return { @@ -51,36 +53,52 @@ export default function BookmarkList() { return { previousData }; }, onError: (err, activityId, context) => { - // 에러 발생 시 이전 데이터로 롤백 if (context?.previousData) { queryClient.setQueryData([QUERY_KEYS.BOOKMARKED_ACTIVITIES, currentPage], context.previousData); } }, onSettled: () => { - // 성공/실패 관계없이 모든 관련 쿼리 무효화 - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ACTIVITIES] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.BOOKMARKED_ACTIVITIES] }); } }); - const handleTitleClick = (activity) => { - if (selectedType === 'issue') { - navigate('/global-issue-detail', { - state: { - label: `#${activity.keyword}`, - title: activity.name, - }, - }); + const handleIssueClick = (issue) => { + navigate(`/global-issue/${issue.id}`, { + state: { + label: issue.category, + title: issue.title + } + }); + }; + + const handleActivityClick = (activity) => { + const url = activity.siteUrl; + if (url) { + try { + new URL(url); + window.open(url, '_blank', 'noopener,noreferrer'); + } catch (error) { + console.error('잘못된 URL입니다:', url); + alert('올바르지 않은 링크입니다.'); + } } else { - navigate(`/more-detail?query=${encodeURIComponent(activity.keyword)}`); + alert('해당 활동의 링크 정보가 없습니다.'); } }; - const handleBookmarkToggle = (e, activityId) => { + const handleBookmarkToggle = (e, id, type) => { e.stopPropagation(); - toggleBookmarkMutation.mutate(activityId); + if (type === 'issue') { + toggleIssueBookmark.mutate(id); + } else { + toggleActivityBookmark.mutate(id); + } }; + const isLoading = selectedType === 'issue' ? issuesLoading : activitiesLoading; + const error = selectedType === 'issue' ? issuesError : activitiesError; + const data = selectedType === 'issue' ? bookmarkedIssues : bookmarkedActivities; + return ( @@ -98,63 +116,81 @@ export default function BookmarkList() { - {selectedType === 'activity' ? ( - isLoading ? ( - 로딩 중... - ) : error ? ( - 데이터를 불러오는데 실패했습니다. - ) : bookmarkedActivities?.content.length === 0 ? ( - 북마크한 활동이 없습니다. - ) : ( - <> - -
- - - - - - - - - - - {bookmarkedActivities?.content.map((activity, idx) => ( - - + {isLoading ? ( + 로딩 중... + ) : error ? ( + 데이터를 불러오는데 실패했습니다. + ) : data?.content.length === 0 ? ( + 북마크한 {selectedType === 'issue' ? '이슈' : '활동'}가 없습니다. + ) : ( + <> + +
No.제목카테고리날짜북마크
{bookmarkedActivities.pageable.offset + idx + 1}
+ + + + + + {selectedType === 'activity' && } + {selectedType === 'issue' && } + + + + + {selectedType === 'issue' ? ( + data?.content.map((issue, idx) => ( + handleIssueClick(issue)}> + + + - + + )) + ) : ( + data?.content.map((activity, idx) => ( + handleActivityClick(activity)}> + + + + - ))} - -
No.제목카테고리마감 날짜날짜북마크
{data.pageable.offset + idx + 1}{issue.title} - handleTitleClick(activity)}> - {activity.name} - + + {issue.category} + {formatDate(issue.date)} - #{activity.keyword} + handleBookmarkToggle(e, issue.id, 'issue')} + /> {new Date(activity.startDate).toLocaleDateString()}
{data.pageable.offset + idx + 1}{activity.name} + + #{activity.keyword} + #{activity.activityType} + + {formatDate(activity.endDate)} handleBookmarkToggle(e, activity.activityId)} + onClick={(e) => handleBookmarkToggle(e, activity.activityId, 'activity')} />
- - - - setCurrentPage(page - 1)} - /> - - - ) - ) : ( - 글로벌 이슈 북마크는 준비 중입니다. + )) + )} + + + + + + setCurrentPage(page - 1)} + /> + + )} ); @@ -188,7 +224,6 @@ const TabButton = styled.button` transition: all 0.2s ease-in-out; `; -// 테이블 컨테이너: 폭 고정, 가운데 정렬, 상·하 테두리 const TableContainer = styled.div` width: 900px; max-width: 100%; @@ -198,7 +233,6 @@ const TableContainer = styled.div` overflow-x: auto; `; -// 테이블: 고정 레이아웃, 셀 크기·간격 고정 const Table = styled.table` width: 100%; border-collapse: collapse; @@ -233,34 +267,32 @@ const Table = styled.table` th:nth-child(4), td:nth-child(4) { width: 15%; } th:nth-child(5), td:nth-child(5) { width: 10%; } - tr:nth-child(even) { - background-color: #f7faff; - } -`; + tr { + transition: background-color 0.2s ease; + cursor: pointer; -const TitleLink = styled.span` - color: #000; - font-weight: 500; - cursor: pointer; + &:hover { + background-color: #f0f5ff; + } + } - &:hover { - opacity: 0.8; + tr:nth-child(even) { + background-color: #f7faff; } `; -const CategoryTag = styled.div` - display: inline-block; - padding: 4px 12px; - border-radius: 999px; - font-size: 13px; - color: #34a853; - background: #f6faff; - border: 1px solid #34a853; +const CategoryContainer = styled.div` + display: flex; + justify-content: center; `; const ActivityTag = styled.span` color: #235ba9; font-weight: 500; + font-size: 13px; + padding: 2px 6px; + border-radius: 12px; + display: inline-block; `; const EmptyMessage = styled.div` @@ -271,15 +303,20 @@ const EmptyMessage = styled.div` `; const BookmarkIcon = styled.img` - width: 30px; - height: 30px; + width: 24px; + height: 24px; + cursor: pointer; + transition: transform 0.2s; + + &:hover { + transform: scale(1.1); + } `; const PaginationWrapper = styled.div` - margin-top: 24px; display: flex; justify-content: center; - width: 100%; + margin-top: 20px; `; const LoadingMessage = styled.div` @@ -294,4 +331,4 @@ const ErrorMessage = styled.div` text-align: center; color: #ff4d4f; font-size: 15px; -`; \ No newline at end of file +`; diff --git a/src/components/my/MyPostList.jsx b/src/components/my/MyPostList.jsx index 9b918f0..a0ff9be 100644 --- a/src/components/my/MyPostList.jsx +++ b/src/components/my/MyPostList.jsx @@ -1,35 +1,152 @@ // src/components/mypage/MyPostsList.jsx -import React, { useEffect } from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; -import usePagination from '../../hooks/usePagination'; +import { useNavigate } from 'react-router-dom'; import Pagination from '../common/Pagination'; +import { useUserReviews, useUserPosts, useMyReviews, useMyPosts } from '../../query/usePost'; -const dummyPosts = Array.from({ length: 21 }, (_, i) => ({ - id: i + 1, - board: i % 2 === 0 ? '후기 게시판' : '자유 게시판', - title: `관광 공공 데이터 공모전 나갈 사람? (${i + 1})`, - date: '2025.04.14', -})); +const MyPostsList = ({ userId }) => { + const navigate = useNavigate(); + const [currentPage, setCurrentPage] = useState(1); + + // userId가 없으면 내 마이페이지 (내 게시물 조회) + // userId가 있으면 다른 사용자 페이지 (사용자별 리뷰와 게시물 조회) + const isMyPage = !userId; + + // API 페이지는 0부터 시작하므로 currentPage - 1 + // 내 리뷰 조회 (내 마이페이지에서만 실행) + const { + data: myReviewsData, + isLoading: myReviewsLoading, + isError: myReviewsError, + error: myReviewsErrorMessage + } = useMyReviews(currentPage - 1, { enabled: isMyPage }); + + // 사용자별 리뷰 조회 (다른 사용자 페이지에서만 실행) + const { + data: userReviewsData, + isLoading: userReviewsLoading, + isError: userReviewsError, + error: userReviewsErrorMessage + } = useUserReviews(userId, currentPage - 1, { enabled: !isMyPage && !!userId }); + + // 사용자별 자유게시판 게시물 조회 (다른 사용자 페이지에서만 실행) + const { + data: userPostsData, + isLoading: userPostsLoading, + isError: userPostsError, + error: userPostsErrorMessage + } = useUserPosts(userId, currentPage - 1, { enabled: !isMyPage && !!userId }); + + // 내 자유게시판 게시물 조회 (내 마이페이지에서만 실행) + const { + data: postsData, + isLoading: postsLoading, + isError: postsError, + error: postsErrorMessage + } = useMyPosts(currentPage - 1, { enabled: isMyPage }); + + // 데이터 통합 처리 + let posts = []; + let isLoading = false; + let isError = false; + let error = null; + let totalPages = 0; + + if (isMyPage) { + // 내 마이페이지: 리뷰와 게시물 데이터를 합치기 + const reviewPosts = (myReviewsData?.content || []).map(post => ({ + ...post, + boardType: '후기 게시판' + })); + + const freePosts = (postsData?.content || []).map(post => ({ + ...post, + boardType: '자유 게시판' + })); + + // 두 데이터를 합치고 날짜순으로 정렬 + posts = [...reviewPosts, ...freePosts].sort((a, b) => + new Date(b.updatedAt) - new Date(a.updatedAt) + ); + + isLoading = myReviewsLoading || postsLoading; + isError = myReviewsError || postsError; + error = myReviewsErrorMessage || postsErrorMessage; + + // 페이지네이션을 위해 수동으로 페이지 처리 (간단히 전체 데이터 기준) + totalPages = Math.max(myReviewsData?.totalPages || 0, postsData?.totalPages || 0); + } else { + // 다른 사용자 페이지: 리뷰와 자유게시판 게시물 통합 + const userReviewPosts = (userReviewsData?.content || []).map(post => ({ + ...post, + boardType: '후기 게시판' + })); + + const userFreePosts = (userPostsData?.content || []).map(post => ({ + ...post, + boardType: '자유 게시판' + })); + + // 두 데이터를 합치고 날짜순으로 정렬 + posts = [...userReviewPosts, ...userFreePosts].sort((a, b) => + new Date(b.updatedAt) - new Date(a.updatedAt) + ); + + isLoading = userReviewsLoading || userPostsLoading; + isError = userReviewsError || userPostsError; + error = userReviewsErrorMessage || userPostsErrorMessage; + totalPages = Math.max(userReviewsData?.totalPages || 0, userPostsData?.totalPages || 0); + } + + const handlePageChange = (page) => { + setCurrentPage(page); + }; + + const handlePostClick = (post) => { + const path = post.boardType === '후기 게시판' + ? `/board/review/${post.id}` + : `/board/detail/${post.id}`; + navigate(path); + }; -const MyPostsList = () => { - const itemsPerPage = 10; + // 날짜 포맷팅 함수 + const formatDate = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).replace(/\./g, '.').replace(/\s/g, ''); + }; - const { - currentPage, - goToPage, - totalPages, - currentData: paginatedPosts, - } = usePagination(dummyPosts, itemsPerPage); + if (isLoading) { + return ( + + 게시물을 불러오는 중... + + ); + } - useEffect(() => { - goToPage(1); // 1페이지로 초기화 - }, []); + if (isError) { + return ( + + 게시물을 불러오는데 실패했습니다: {error?.message} + + ); + } return ( - {(paginatedPosts ?? []).length === 0 ? ( - 작성한 글이 없습니다. + {posts.length === 0 ? ( + + {isMyPage + ? '작성한 게시물이 없습니다.' + : '작성한 리뷰가 없습니다.' + } + ) : ( <> @@ -42,27 +159,33 @@ const MyPostsList = () => { - {paginatedPosts.map((post, idx) => ( - - + {posts.map((post, idx) => ( + + - - + + ))}
{(currentPage - 1) * itemsPerPage + idx + 1}
{(currentPage - 1) * 10 + idx + 1} - - {post.board} + + {post.boardType} {post.title}{post.date} + handlePostClick(post)}> + {post.title} + + {formatDate(post.updatedAt)}
- - - + {totalPages > 1 && ( + + + + )} )}
@@ -129,9 +252,23 @@ const BoardTag = styled.div` const EmptyMessage = styled.div` padding: 60px; - text-align: center; color: #888; font-size: 15px; + margin-right : 350px; +`; + +const LoadingMessage = styled.div` + padding: 60px; + text-align: center; + color: #666; + font-size: 15px; +`; + +const ErrorMessage = styled.div` + padding: 60px; + text-align: center; + color: #e74c3c; + font-size: 15px; `; const PaginationWrapper = styled.div` @@ -140,4 +277,13 @@ const PaginationWrapper = styled.div` justify-content: center; width: 100%; max-width: 1000px; -`; \ No newline at end of file +`; + +const PostTitle = styled.div` + cursor: pointer; + &:hover { + color: #235ba9; + text-decoration: underline; + } +`; + diff --git a/src/components/search/ActivitySearchInput.jsx b/src/components/search/ActivitySearchInput.jsx index 2ecdd8e..878c2bd 100644 --- a/src/components/search/ActivitySearchInput.jsx +++ b/src/components/search/ActivitySearchInput.jsx @@ -1,30 +1,50 @@ -// ActivitySearchInput.jsx import React, { useState, useEffect, useRef } from "react"; import styled from "styled-components"; +import { searchActivities } from "../../api/MainSearchApi"; +import { useQuery } from '@tanstack/react-query'; +import { formatDate } from '../../utils/formatDate'; -const DUMMY_ACTIVITIES = [ - "잠자기 공모전", - "프론트 공모전", - "밥먹기 공모전", - "어라 어라 너 하 러 공모전", -]; - -export default function ActivitySearchInput({ value, onChange }) { - const [query, setQuery] = useState(value); - const [filtered, setFiltered] = useState([]); +export default function ActivitySearchInput({ + value, + onChange, + onActivitySelect, + placeholder = "후기를 작성할 대외활동을 검색하세요", + allowClosed = true // 후기 게시판에서는 마감된 활동도 허용 +}) { + const [query, setQuery] = useState(value || ""); const [isFocused, setIsFocused] = useState(false); + const [debouncedQuery, setDebouncedQuery] = useState(""); const wrapperRef = useRef(); + // 검색어 디바운싱 (300ms) useEffect(() => { - setFiltered( - query - ? DUMMY_ACTIVITIES.filter((item) => - item.toLowerCase().includes(query.toLowerCase()) - ) - : [] - ); + const timer = setTimeout(() => { + setDebouncedQuery(query); + }, 300); + + return () => clearTimeout(timer); }, [query]); + // 활동 검색 데이터 가져오기 + const { + data: searchResult, + isLoading, + isError + } = useQuery({ + queryKey: ['activities', 'search', debouncedQuery], + queryFn: () => searchActivities({ + keyword: debouncedQuery, + page: 0, + size: 8 // 검색 결과 8개로 제한 + }), + enabled: isFocused && debouncedQuery.length > 1, + staleTime: 1000 * 60 * 3, // 3분 + cacheTime: 1000 * 60 * 5, // 5분 + }); + + const activities = searchResult?.isSuccess ? searchResult.result.content : []; + + // 외부 클릭 감지 useEffect(() => { const handleClickOutside = (e) => { if (wrapperRef.current && !wrapperRef.current.contains(e.target)) { @@ -35,43 +55,128 @@ export default function ActivitySearchInput({ value, onChange }) { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + // 외부에서 value가 변경될 때 query 업데이트 + useEffect(() => { + if (value !== query) { + setQuery(value || ""); + } + }, [value]); + + const handleInputChange = (e) => { + const newValue = e.target.value; + setQuery(newValue); + onChange(newValue); + + // 입력값이 비어있으면 선택된 활동 초기화 + if (newValue === "" && onActivitySelect) { + onActivitySelect(null); + } + }; + + const handleActivitySelect = (activity) => { + setQuery(activity.name); + onChange(activity.name); + if (onActivitySelect) { + onActivitySelect(activity); + } + setIsFocused(false); + }; + + const handleInputFocus = () => { + setIsFocused(true); + }; + + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + setIsFocused(false); + } + }; + + // 활동 마감 여부 확인 + const isActivityClosed = (endDate) => { + if (!endDate) return false; + const now = new Date(); + const end = new Date(endDate); + return end < now; + }; + + // 검색 결과가 있는지 확인 + const hasResults = activities && activities.length > 0; + const showNoResults = isFocused && debouncedQuery.length > 1 && !isLoading && !hasResults && !isError; + const showResults = isFocused && debouncedQuery.length > 1 && (hasResults || isLoading || showNoResults || isError); + return ( { - setQuery(e.target.value); - onChange(""); // 초기화 - }} - onFocus={() => setIsFocused(true)} + placeholder={placeholder} + onChange={handleInputChange} + onFocus={handleInputFocus} + onKeyDown={handleKeyDown} + autoComplete="off" /> - {isFocused && filtered.length > 0 && ( + + {showResults && ( - {filtered.map((item, idx) => ( - { - setQuery(item); - onChange(item); - setIsFocused(false); - }} - > - {item} + {isLoading && ( + + 검색 중... - ))} - - )} - {isFocused && query && filtered.length === 0 && ( - - 검색 결과가 없습니다 + )} + + {isError && ( + + 검색 중 오류가 발생했습니다 + + )} + + {hasResults && !isLoading && + activities.map((activity) => { + const isClosed = isActivityClosed(activity.endDate); + return ( + handleActivitySelect(activity)} + disabled={false} // 후기 게시판에서는 모든 활동 선택 가능 + > + + + {activity.name} + + + + {activity.activityType} + + {activity.keyword} + + {formatDate(activity.startDate)} ~ {formatDate(activity.endDate)} + + + + {isClosed ? ( + 완료 + ) : ( + 진행중 + )} + + ); + }) + } + + {showNoResults && ( + + + "{debouncedQuery}"에 대한 검색 결과가 없습니다 + + + )} )} ); } -// Styled components +// 스타일드 컴포넌트들 const Wrapper = styled.div` position: relative; width: 100%; @@ -84,8 +189,15 @@ const Input = styled.input` border-radius: 6px; width: 100%; height: 22px; + transition: border-color 0.2s; - &::placeholder { + &:focus { + outline: none; + border-color: #1a4480; + box-shadow: 0 0 0 2px rgba(35, 91, 169, 0.1); + } + + &::placeholder { color: #aaa; font-size: 14px; } @@ -97,19 +209,119 @@ const ResultList = styled.ul` width: 100%; background: white; border: 1px solid #dcdcdc; - box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); - z-index: 10; + border-top: none; + border-radius: 0 0 6px 6px; + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; margin: 0; padding: 0; list-style: none; + max-height: 300px; + overflow-y: auto; `; const ResultItem = styled.li` padding: 12px; - cursor: pointer; + cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'}; font-size: 14px; + border-bottom: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + align-items: center; + opacity: ${props => props.disabled ? 0.6 : 1}; &:hover { - background-color: #f2f8ff; + background-color: ${props => props.disabled ? 'transparent' : '#f2f8ff'}; + } + + &:last-child { + border-bottom: none; } `; + +const ActivityInfo = styled.div` + flex: 1; + min-width: 0; +`; + + +const ActivityName = styled.div` + font-weight: 600; + color: ${props => props.$closed ? '#666' : '#333'}; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; // 후기 게시판에서는 줄 긋지 않음 +`; + +const ActivityMeta = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +`; + +const ActivityTag = styled.span` + display: inline-block; + padding: 2px 6px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + color: white; + background: ${props => { + switch(props.type) { + case '공모전': return '#e74c3c'; + case '봉사활동': return '#27ae60'; + case '인턴십': return '#3498db'; + case '서포터즈': return '#9b59b6'; + default: return '#95a5a6'; + } + }}; +`; + +const ActivityKeyword = styled.span` + font-size: 11px; + color: #666; + background: #f8f9fa; + padding: 2px 6px; + border-radius: 8px; +`; + +const ActivityDate = styled.span` + font-size: 11px; + color: #888; +`; + +// 완료된 활동을 위한 배지 (긍정적인 의미) +const CompletedBadge = styled.span` + background: #27ae60; + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +`; + +// 진행중인 활동을 위한 배지 +const OngoingBadge = styled.span` + background: #3498db; + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +`; + +const LoadingText = styled.span` + color: #666; + font-style: italic; +`; + +const ErrorText = styled.span` + color: #e74c3c; +`; + +const NoResultText = styled.span` + color: #888; +`; diff --git a/src/pages/ActivityPage.jsx b/src/pages/ActivityPage.jsx index fbc067b..7302809 100644 --- a/src/pages/ActivityPage.jsx +++ b/src/pages/ActivityPage.jsx @@ -10,7 +10,7 @@ import CustomDropdown from '../components/common/CustomDropdown'; import { useActivityStore } from '../store/activityStore'; import { useActivities, useToggleBookmark } from '../query/useActivities'; -const fieldOptions = ["전체", "경제", "환경", "사람과사회", "기술"]; +const fieldOptions = ["전체", "경제", "환경", "사람과 사회", "기술"]; const typeOptions = ["전체", "공모전", "봉사활동", "인턴십", "서포터즈"]; export default function ActivityPage() { @@ -103,7 +103,7 @@ export default function ActivityPage() { bookmarked={activity.bookmarked} onToggle={() => handleToggleBookmark(activity.id)} isClosed={activity.isClosed} - siteUrl={activity.siteUrl} + siteUrl={activity.siteUrl || 'https://naver.com'} /> ))} {Array.from({ length: 4 - (activities.length % 4) }).map((_, idx) => ( diff --git a/src/pages/RankingPage.jsx b/src/pages/RankingPage.jsx index a543252..ecad6e2 100644 --- a/src/pages/RankingPage.jsx +++ b/src/pages/RankingPage.jsx @@ -201,20 +201,18 @@ const RankNumber = styled.span` `; const ProfileWrapper = styled.div` - width: 48px; - height: 48px; background: white; border-radius: 50%; display: flex; justify-content: center; align-items: center; border: ${({ isCurrentUser }) => - isCurrentUser ? '2px solid #235BA9' : 'none'}; + isCurrentUser ? '2px solid #235BA9' : '0.1px solid #C4C4C4'}; `; const RankIcon = styled.img` - width: 40px; - height: 40px; + width: 48px; + height: 48px; border-radius: 50%; object-fit: cover; `; @@ -240,8 +238,8 @@ const TopRankWrapper = styled.div` `; const TopRankIcon = styled.img` - width: 100px; - height: 100px; + width: 10%; + height: 10%; margin-bottom: 8px; `; diff --git a/src/pages/board/BoardDetailPage.jsx b/src/pages/board/BoardDetailPage.jsx index 7af3875..5596024 100644 --- a/src/pages/board/BoardDetailPage.jsx +++ b/src/pages/board/BoardDetailPage.jsx @@ -1,50 +1,224 @@ -// src/pages/BoardDetailPage.jsx - import React, { useState, useEffect, useRef } from "react"; import styled from "styled-components"; import Footer from "../../layout/Footer"; import BoardNav from "../../layout/board/BoardNav"; -import useLike from "../../hooks/useLike"; import PROFILE_IMG from "../../assets/images/profile/DefaultProfile.png"; -import SAMPLE_AWARD_IMG from "../../assets/images/board/SampleReviewImg.png"; import ArrowDownIcon from "../../assets/images/common/ic_ArrowDown.png"; - -// 더미 데이터 예시 -const post = { - boardType: "후기 게시판", - title: "국제수면산업박람회 아이디어 공모전 후기!", - tags: ["#환경", "#공모전"], - author: "나", - createAt: "2025.03.25", - content: `국제수면산업 박람회에 참가해서 영예의 대상을 수상했어요! -새롭고 흥미로운 아이디어를 나눌 수 있어서 정말 즐거운 경험이었습니다. - -좋은 사람들과 함께한 뜻깊은 시간이었고, 서로의 아이디어를 존중하며 소통할 수 있었던 현장이었어요. -다양한 분야의 전문가들과 인사이트를 주고받으며, 수면산업의 무한한 가능성을 다시 느낄 수 있었고요. - -이번 수상을 통해 저희의 아이디어가 의미 있는 방향으로 나아가고 있다는 확신도 얻게 되었어요. - -앞으로도 더 많은 사람들의 삶에 긍정적인 영향을 줄 수 있도록, 꾸준히 연구하고 도전해 나가겠습니다! - -다시 한 번, 함께해주신 모든 분들께 감사드려요.✨`, - image: SAMPLE_AWARD_IMG, - likeCount: 3, - isVerified: true, - comments: [ - { id: 1, author: "JUDY", content: "역시 새벽형 주디답네요 👍", createAt: "2025.04.21" }, - { id: 2, author: "JUDY", content: "감사합니다!", createAt: "2025.04.21" }, - { id: 3, author: "나", content: "내가 쓴 댓글!", createAt: "2025.04.22" }, - ], -}; +import { useParams, useNavigate } from "react-router-dom"; +import { usePost, useTogglePostLike, useReview, useToggleReviewLike } from "../../query/usePost"; +import { deletePost, deleteReview, getComments, postComment, updateComment, deleteComment } from "../../api/PostApi"; +import { useQueryClient } from '@tanstack/react-query'; +import CommentSection from '../../components/board/CommentSection'; +import { useReviewLikeStore } from "../../store/reviewLikeStore"; export default function BoardDetailPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); const [comment, setComment] = useState(""); - const [comments, setComments] = useState(post.comments); - const { liked, count: likeCount, toggleLike } = useLike(post.likeCount); + const [comments, setComments] = useState([]); const [showComments, setShowComments] = useState(true); const [showPostMenu, setShowPostMenu] = useState(false); + const [profileImageError, setProfileImageError] = useState(false); const menuRef = useRef(null); + // 게시물 데이터 가져오기 (자유게시판/리뷰게시판 구분) + const isReview = window.location.pathname.includes('/board/review/'); + const { + data: postData, + isLoading: isPostLoading, + isError: isPostError, + error: postError + } = usePost(isReview ? null : id); + + const { + data: reviewData, + isLoading: isReviewLoading, + isError: isReviewError, + error: reviewError + } = useReview(isReview ? id : null); + + const isLoading = isReview ? isReviewLoading : isPostLoading; + const isError = isReview ? isReviewError : isPostError; + const error = isReview ? reviewError : postError; + const data = isReview ? reviewData : postData?.result; + + // 좋아요 토글 훅 + const togglePostLikeMutation = useTogglePostLike(); + const toggleReviewLikeMutation = useToggleReviewLike(); + + // Zustand 스토어에서 후기 좋아요 상태 관리 함수들 가져오기 + const { likeMap, setLike, updateLike } = useReviewLikeStore(); + + // 자유 게시판용 localStorage 기반 좋아요 상태 관리 + const [liked, setLiked] = useState(false); + const [likeCount, setLikeCount] = useState(0); + + // 후기 게시판용 Zustand 기반 좋아요 상태 + const reviewLikeState = isReview ? (likeMap[id] || { liked: false, likeCount: 0 }) : null; + + // 자유 게시판: localStorage에서 하트 상태 불러오기 + useEffect(() => { + if (!isReview && id) { + const savedLikeState = localStorage.getItem(`heart-${id}`); + if (savedLikeState) { + setLiked(JSON.parse(savedLikeState)); + } + } + }, [id, isReview]); + + // 자유 게시판: 하트 상태 변경 시 localStorage에 저장 + useEffect(() => { + if (!isReview && id) { + localStorage.setItem(`heart-${id}`, JSON.stringify(liked)); + } + }, [liked, id, isReview]); + + // 게시물 데이터가 로드되면 좋아요 상태 설정 + useEffect(() => { + if (data && id) { + if (isReview) { + // 후기 게시판: Zustand 스토어에 초기 상태 설정 (이미 스토어에 있는 상태가 우선) + const existingState = likeMap[id]; + if (!existingState) { + console.log('후기 상세페이지 초기 좋아요 상태 설정:', { + id, + liked: data.liked || false, + likeCount: data.likeCount || 0 + }); + setLike(id, data.liked || false, data.likeCount || 0); + } + } else { + // 자유 게시판: 좋아요 개수만 설정 + setLikeCount(data.likeCount || 0); + } + } + }, [data, isReview, id, setLike, likeMap]); + + // 프로필 이미지 에러 처리 함수 + const handleProfileImageError = () => { + setProfileImageError(true); + }; + + // 프로필 이미지 URL 결정 함수 + const getProfileImageSrc = (post) => { + if (profileImageError) { + return PROFILE_IMG; + } + + if (!post.profileUrl || post.profileUrl === "기본값" || post.profileUrl === "") { + return PROFILE_IMG; + } + + return post.profileUrl; + }; + + const handleToggleLike = async () => { + if (isReview) { + // 후기 게시판: Zustand 기반 좋아요 처리 + const currentState = reviewLikeState; + + console.log('상세페이지 후기 좋아요 클릭:', { + id, + currentState, + liked: currentState?.liked, + likeCount: currentState?.likeCount + }); + + try { + // 1. 즉시 UI 업데이트 (Optimistic Update) + const newLiked = !currentState.liked; + const newCount = currentState.likeCount + (newLiked ? 1 : -1); + + console.log('상세페이지 Optimistic Update:', { newLiked, newCount }); + updateLike(id, newLiked, newCount); + + // 2. 서버 요청 + const result = await toggleReviewLikeMutation.mutateAsync(id); + + // 3. 서버 응답으로 정확한 상태 동기화 + const serverLiked = result.liked ?? result.like ?? newLiked; + const serverCount = result.likeCount ?? newCount; + + console.log('상세페이지 서버 응답 동기화:', { + result, + serverLiked, + serverCount + }); + + updateLike(id, serverLiked, serverCount); + + // 4. React Query 캐시 업데이트 (상세 페이지와 동기화) + queryClient.setQueryData(['review', id], (oldData) => { + if (oldData?.result) { + return { + ...oldData, + result: { + ...oldData.result, + liked: serverLiked, + likeCount: serverCount + } + }; + } else if (oldData) { + // 상세 페이지 데이터가 직접 저장된 경우 + return { + ...oldData, + liked: serverLiked, + likeCount: serverCount + }; + } + return oldData; + }); + + // 5. 리뷰 리스트 캐시도 업데이트 + queryClient.setQueriesData( + { queryKey: ['reviews'] }, + (oldData) => { + if (oldData?.result?.content) { + return { + ...oldData, + result: { + ...oldData.result, + content: oldData.result.content.map(review => + review.id === id + ? { ...review, liked: serverLiked, likeCount: serverCount } + : review + ) + } + }; + } + return oldData; + } + ); + + } catch (error) { + // 실패 시 이전 상태로 롤백 + updateLike(id, currentState.liked, currentState.likeCount); + console.error('후기 좋아요 처리 실패:', error); + } + } else { + // 자유 게시판: localStorage 기반 좋아요 처리 (기존 방식 유지) + const currentState = { liked, likeCount }; + + // 하트 상태만 즉시 토글 + setLiked(!liked); + + try { + // 서버 요청 + const result = await togglePostLikeMutation.mutateAsync(id); + + // 서버에서 받은 좋아요 수로 업데이트 (하트 상태는 유지) + if (result && typeof result.likeCount !== 'undefined') { + setLikeCount(result.likeCount); + } + } catch (error) { + // 실패 시 하트 상태만 되돌리기 + setLiked(currentState.liked); + console.error('자유게시판 좋아요 처리 실패:', error); + } + } + }; + useEffect(() => { window.scrollTo({ top: 0, left: 0 }); const handleClickOutside = (event) => { @@ -53,45 +227,140 @@ export default function BoardDetailPage() { } }; document.addEventListener("mousedown", handleClickOutside); + + // 댓글 불러오기 + const fetchComments = async () => { + try { + const commentList = await getComments(id); + setComments(commentList); + } catch (e) { + setComments([]); + } + }; + if (id) fetchComments(); + return () => { document.removeEventListener("mousedown", handleClickOutside); }; - }, []); + }, [id]); + + // 댓글 수정 상태 관리 + const [editingCommentId, setEditingCommentId] = useState(null); - const handleCommentSubmit = (e) => { + // 댓글 수정 핸들러 + const handleEditComment = async (commentId) => { + if (editingCommentId && editingCommentId !== commentId) return; + const target = comments.find((c) => c.commentId === commentId); + if (target) { + setEditingCommentId(commentId); + setComment(target.content); + } + }; + + // 댓글 폼 제출 시(수정/등록 분기) + const handleCommentSubmit = async (e) => { e.preventDefault(); if (!comment.trim()) return; - setComments([ - ...comments, - { - id: Date.now(), - author: "나", - content: comment, - createAt: "2025.04.21", - }, - ]); - setComment(""); + try { + if (editingCommentId) { + await updateComment(editingCommentId, comment); + setEditingCommentId(null); + } else { + await postComment(id, comment); + } + const commentList = await getComments(id); + setComments(commentList); + setComment(""); + } catch (error) { + alert(error.message || '댓글 처리에 실패했습니다.'); + } }; - const handleDeleteComment = (id) => { - setComments(comments.filter((c) => c.id !== id)); + const handleDeleteComment = async (commentId) => { + if (!window.confirm('댓글을 삭제하시겠습니까?')) return; + try { + await deleteComment(commentId); + const commentList = await getComments(id); + setComments(commentList); + } catch (error) { + alert(error.message || '댓글 삭제에 실패했습니다.'); + } }; - const handleEditComment = (id) => { - const target = comments.find((c) => c.id === id); - if (target) { - setComment(target.content); - setComments(comments.filter((c) => c.id !== id)); + // 게시물 삭제 함수 + const handleDeletePost = async () => { + if (window.confirm('정말로 이 게시물을 삭제하시겠습니까?')) { + try { + if (isReview) { + await deleteReview(id); + } else { + await deletePost(id); + } + + queryClient.invalidateQueries(['posts']); + queryClient.invalidateQueries(['post', id]); + if (isReview) { + queryClient.invalidateQueries(['reviews']); + queryClient.invalidateQueries(['review', id]); + } + + alert('게시물이 삭제되었습니다.'); + navigate(isReview ? '/board/review' : '/board/free'); + } catch (error) { + console.error('삭제 실패:', error); + alert('게시물 삭제에 실패했습니다.'); + } } }; + // 로딩 중일 때 + if (isLoading) { + return ( + <> + + + 게시물을 불러오는 중... + +