From ea9ce98b7093481746146bb4d5829c4f35f60d34 Mon Sep 17 00:00:00 2001 From: chohs4164 Date: Fri, 21 Nov 2025 19:13:46 +0900 Subject: [PATCH 01/52] chore: restore frontend CI/CD workflow --- .github/workflows/frontend-deploy.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/workflows/frontend-deploy.yml diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml new file mode 100644 index 0000000..e69de29 From 2b8ab9c8b8f0dabfad24e4824746ea1c64395731 Mon Sep 17 00:00:00 2001 From: chohs4164 Date: Fri, 21 Nov 2025 19:15:10 +0900 Subject: [PATCH 02/52] chore: restore frontend CI/CD workflow --- .github/workflows/frontend-deploy.yml | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml index e69de29..476b7ca 100644 --- a/.github/workflows/frontend-deploy.yml +++ b/.github/workflows/frontend-deploy.yml @@ -0,0 +1,58 @@ +name: CI/CD Frontend StudySpot + +on: + push: + branches: + - main + - test + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Docker Hub login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set image tag based on branch + run: | + if [ "${GITHUB_REF_NAME}" == "main" ]; then + echo "IMAGE_TAG=latest" >> $GITHUB_ENV + else + echo "IMAGE_TAG=test" >> $GITHUB_ENV + fi + + - name: Build Docker image + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:${{ env.IMAGE_TAG }} . + + - name: Push Docker image + run: | + docker push ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:${{ env.IMAGE_TAG }} + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: Deploy to server via SSH + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.SERVER_IP }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + run: | + IMAGE_TAG=${{ env.IMAGE_TAG }} + echo "Deploying branch with IMAGE_TAG=$IMAGE_TAG" + cd ~/studyspot + if [ "$IMAGE_TAG" = "test" ]; then + SERVICE_NAME="frontend-test" + else + SERVICE_NAME="frontend" + fi + docker compose pull $SERVICE_NAME + docker compose up -d $SERVICE_NAME \ No newline at end of file From 8b2585b194cd60ff4a8d618325c489e1442e4dc0 Mon Sep 17 00:00:00 2001 From: chohs4164 Date: Fri, 21 Nov 2025 19:28:07 +0900 Subject: [PATCH 03/52] feat: add Dockerfile for frontend CI/CD --- Dockerfile | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 From cc8a909e9d6f1751636b5eaf94db1563d83ce353 Mon Sep 17 00:00:00 2001 From: chohs4164 Date: Fri, 21 Nov 2025 19:30:22 +0900 Subject: [PATCH 04/52] feat: add Dockerfile for frontend CI/CD --- Dockerfile | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ece6d73 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# -------------------------- +# 1) Build Stage +# -------------------------- +FROM node:20-alpine AS builder +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm install + +COPY . . +RUN npm run build + +# -------------------------- +# 2) Production Stage (Nginx) +# -------------------------- +FROM nginx:alpine +COPY --from=builder /app/build /usr/share/nginx/html + +# React Router 대응용 설정 +RUN echo 'server { \ + listen 80; \ + server_name localhost; \ + root /usr/share/nginx/html; \ + index index.html; \ + location / { \ + try_files $uri /index.html; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] From e8bdf367d77deddf36f70e7531d29a3d1b2e66c1 Mon Sep 17 00:00:00 2001 From: chohs4164 Date: Fri, 21 Nov 2025 19:44:41 +0900 Subject: [PATCH 05/52] Restore CI/CD workflow from backup --- .github/workflows/frontend-deploy-main.yml | 35 +++++++++++++ .github/workflows/frontend-deploy-test.yml | 35 +++++++++++++ .github/workflows/frontend-deploy.yml | 58 ---------------------- Dockerfile | 20 ++++---- 4 files changed, 80 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/frontend-deploy-main.yml create mode 100644 .github/workflows/frontend-deploy-test.yml delete mode 100644 .github/workflows/frontend-deploy.yml diff --git a/.github/workflows/frontend-deploy-main.yml b/.github/workflows/frontend-deploy-main.yml new file mode 100644 index 0000000..d3989d9 --- /dev/null +++ b/.github/workflows/frontend-deploy-main.yml @@ -0,0 +1,35 @@ +name: CI/CD Frontend StudySpot (main) + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Docker Hub login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:latest . + docker push ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:latest + + - name: Deploy to server via SSH + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.SERVER_IP }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + script: | + cd ~/studyspot + docker compose pull frontend + docker compose up -d frontend \ No newline at end of file diff --git a/.github/workflows/frontend-deploy-test.yml b/.github/workflows/frontend-deploy-test.yml new file mode 100644 index 0000000..472e89d --- /dev/null +++ b/.github/workflows/frontend-deploy-test.yml @@ -0,0 +1,35 @@ +name: CI/CD Frontend StudySpot (test) + +on: + push: + branches: + - test + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Docker Hub login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:test . + docker push ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:test + + - name: Deploy to server via SSH + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.SERVER_IP }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + script: | + cd ~/studyspot + docker compose pull frontend-test + docker compose up -d frontend-test \ No newline at end of file diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml deleted file mode 100644 index 476b7ca..0000000 --- a/.github/workflows/frontend-deploy.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: CI/CD Frontend StudySpot - -on: - push: - branches: - - main - - test - -jobs: - build-and-push: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Docker Hub login - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Set image tag based on branch - run: | - if [ "${GITHUB_REF_NAME}" == "main" ]; then - echo "IMAGE_TAG=latest" >> $GITHUB_ENV - else - echo "IMAGE_TAG=test" >> $GITHUB_ENV - fi - - - name: Build Docker image - run: | - docker build -t ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:${{ env.IMAGE_TAG }} . - - - name: Push Docker image - run: | - docker push ${{ secrets.DOCKER_USERNAME }}/studyspot-frontend:${{ env.IMAGE_TAG }} - - deploy: - needs: build-and-push - runs-on: ubuntu-latest - steps: - - name: Deploy to server via SSH - uses: appleboy/ssh-action@v0.1.7 - with: - host: ${{ secrets.SERVER_IP }} - username: ${{ secrets.SERVER_USER }} - key: ${{ secrets.SERVER_SSH_KEY }} - run: | - IMAGE_TAG=${{ env.IMAGE_TAG }} - echo "Deploying branch with IMAGE_TAG=$IMAGE_TAG" - cd ~/studyspot - if [ "$IMAGE_TAG" = "test" ]; then - SERVICE_NAME="frontend-test" - else - SERVICE_NAME="frontend" - fi - docker compose pull $SERVICE_NAME - docker compose up -d $SERVICE_NAME \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ece6d73..3884a34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,22 @@ -# -------------------------- -# 1) Build Stage -# -------------------------- +# 1단계: React 앱 빌드 FROM node:20-alpine AS builder -WORKDIR /app +WORKDIR /app COPY package.json package-lock.json ./ -RUN npm install + +# lock 파일 불일치 문제 우회 +RUN npm install --legacy-peer-deps COPY . . RUN npm run build -# -------------------------- -# 2) Production Stage (Nginx) -# -------------------------- +# 2단계: Nginx로 정적 파일 제공 FROM nginx:alpine + +# React 빌드 결과물을 복사 COPY --from=builder /app/build /usr/share/nginx/html -# React Router 대응용 설정 +# SPA 라우팅 지원 RUN echo 'server { \ listen 80; \ server_name localhost; \ @@ -28,4 +28,4 @@ RUN echo 'server { \ }' > /etc/nginx/conf.d/default.conf EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file From a8ac278c002cd36c851c8e4eca5461850bc1148d Mon Sep 17 00:00:00 2001 From: Taenggu99 Date: Fri, 21 Nov 2025 20:36:29 +0900 Subject: [PATCH 06/52] =?UTF-8?q?feat:=20=EB=8B=A4=EB=A5=B8=20api=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=A0=95=EB=B3=B4=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=B4=20=EB=8F=99=EC=9D=BC=ED=95=9C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20#7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/utils/normalizeCafe.ts | 66 +++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/components/utils/normalizeCafe.ts diff --git a/src/components/utils/normalizeCafe.ts b/src/components/utils/normalizeCafe.ts new file mode 100644 index 0000000..eec1700 --- /dev/null +++ b/src/components/utils/normalizeCafe.ts @@ -0,0 +1,66 @@ +// 서버에서 받은 데이터(c: any)를 CafeCardData 형태로 변환 +export const normalizeCafe = (c: any) => { + return { + id: c.id, + name: c.name, + + // 카테고리 (없으면 기본값) + category: c.category || "기타", + + // 목적(purpose는 추천카페 API에는 없음 → 기본 []) + purpose: c.purpose || [], + + // 이미지 처리 + // 추천카페 API → imageUrl + // 상세페이지 API → imageList: [{ imageUrl, sequence }] + imageList: c.imageList + ? c.imageList.map((img: any, idx: number) => ({ + imageUrl: img.imageUrl, + index: img.sequence ?? idx, + })) + : c.imageUrl + ? [{ imageUrl: c.imageUrl, index: 0 }] + : [], + + // 위치 처리 (추천카페는 address, 상세페이지는 location [lat, lng]) + location: Array.isArray(c.location) + ? c.location.map((l: any) => String(l)) + : c.address + ? [c.address] + : [], + + // 평점 + rating: c.averageStarRating ?? c.rating ?? 0, + + startingTime: c.startingTime, + closingTime: c.closingTime, + + // 태그 + // 추천카페 → tags: ["콘센트 많음", "주차 가능"] + // 상세페이지 → tags: { powerOutletLevel: "...", ... } + tags: Array.isArray(c.tags) + ? c.tags // 추천카페 + : c.tags + ? Object.values(c.tags) // 상세페이지 + : [], + + // 상세페이지용 값들 추가 (추천카페에는 없지만 있어도 문제X) + phoneNumber: c.phoneNumber || "", + limitTime: c.limitTime ?? null, + menuList: c.menuList || [], + }; +}; + + +export interface CafeCardData { + id: number; + name: string; + category: string; + purpose: string[]; + imageList: { imageUrl: string; index: number }[]; + location?: string[]; + rating?: number; + startingTime?: string; + closingTime?: string; + tags?: string[]; +} \ No newline at end of file From 7140c4a37de303d10e9a5c390641b4ff2c9b6257 Mon Sep 17 00:00:00 2001 From: Taenggu99 Date: Fri, 21 Nov 2025 21:41:23 +0900 Subject: [PATCH 07/52] =?UTF-8?q?feat:=20=EA=B8=B0=ED=83=80=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db.json | 60 +++++++++++++++++++ server.js | 105 +++++++++++++++++++++++++++++++++ src/App.tsx | 2 + src/components/ui/card.tsx | 14 +++++ src/pages/CafeDetail.tsx | 117 +++++++++++++++++++++++++++++++++++++ 5 files changed, 298 insertions(+) create mode 100644 db.json create mode 100644 server.js create mode 100644 src/components/ui/card.tsx create mode 100644 src/pages/CafeDetail.tsx diff --git a/db.json b/db.json new file mode 100644 index 0000000..36927bf --- /dev/null +++ b/db.json @@ -0,0 +1,60 @@ +{ + + "tags": [ + { "id": 1, "category": "☕️ 공간종류", "items": ["카페", "스터디카페", "독서실"] }, + { "id": 2, "category": "💡 조명 밝기", "items": ["어두움", "중간", "밝음"] }, + { "id": 3, "category": "👂 소음정도", "items": ["조용함", "적당한소음", "시끌벅적"] } + ], + + + "cafes": [ + { + "id": 1, + "name": "카페 블루보틀", + "location": "서울 강남", + "tags": ["카페", "밝음", "조용함"], + "thumbnail": "https://via.placeholder.com/80", + "rating": 4.7, + "hours": "09:00~22:00" + }, + { + "id": 2, + "name": "카페 루시드", + "location": "서울 홍대", + "tags": ["독서실", "밝음", "시간제한 없음"], + "thumbnail": "https://via.placeholder.com/80", + "rating": 4.5, + "hours": "08:00~23:00" + }, + { + + "id" : 3, + "name" : "성수동 스타벅스", + "averageStarRating" : 4.5, + "isWork" : true, + "startingTime" : "10:00", + "closingTime" : "22:00", + "category" : "카페", + "tags" : ["콘센트", "애견동반"], + "imageUrl": "https:...", + "address" : "서울 마포구 신수동" + + } + + ], + "categories": [ + { "id": 1, "name": "카페" }, + { "id": 2, "name": "스터디카페" }, + { "id": 3, "name": "독서실" } + ], + "reviews": [ + { + "id": 1, + "cafeId": 1, + "rating": 5, + "comment": "좋아요!", + "author": "홍길동", + "createdAt": "2025-11-13T10:00:00Z" + } + ] +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..3b8d0b8 --- /dev/null +++ b/server.js @@ -0,0 +1,105 @@ +const express = require("express"); +const cors = require("cors"); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +const PORT = 4000; + +// Mock 데이터 +const TAG_KEYWORDS = { + 카페: ["카페", "커피", "디저트", "케이크", "쿠키", "노트북"], + 스터디카페: ["스터디카페", "공부", "집중"], + 독서실: ["독서실", "책", "혼공", "조용한"], + "조명 어두움": ["조명 어두움", "어두움", "어두운", "컴컴"], + "조명 중간": ["조명 중간", "적당한 조명", "중간"], + "조명 밝음": ["조명 밝음", "밝은 조명", "햇빛"], + 조용함: ["조용함", "조용한", "차분", "집중", "혼자", "시험", "조용", "집중"], + 적당한소음: ["적당한소음", "보통"], + 시끌벅적: ["시끌벅적", "북적", "떠들썩"], +}; + +const CAFES = [ + { + id: 1, + name: "카페 블루보틀", + location: "서울 강남", + tags: ["카페", "밝음", "조용함"], + thumbnail: "https://via.placeholder.com/80", + rating: 4.7, + hours: "09:00~22:00", + }, + { + id: 2, + name: "카페 루시드", + location: "서울 홍대", + tags: ["독서실", "밝음", "시간제한 없음"], + thumbnail: "https://via.placeholder.com/80", + rating: 4.5, + hours: "08:00~23:00", + }, + { + id: 3, + name: "스터디카페 집중존", + location: "서울 신촌", + tags: ["스터디카페", "조용함", "적당한소음"], + thumbnail: "https://via.placeholder.com/80", + rating: 4.3, + hours: "09:00~23:00", + }, +]; + +// ---------------------- +// 1. AI 추천 태그 API +// ---------------------- +app.get("/api/ai-tags", (req, res) => { + const { query } = req.query; + + if (!query) return res.json([]); + + const lower = query.toLowerCase(); + const matchedTags = []; + + Object.entries(TAG_KEYWORDS).forEach(([tag, keywords]) => { + for (const kw of keywords) { + if (lower.includes(kw.toLowerCase())) { + matchedTags.push(tag); + break; + } + } + }); + + res.json([...new Set(matchedTags)]); +}); + +// 추가: 태그 기반 카페 검색 +app.get("/api/ai-search", (req, res) => { + const query = req.query.query?.trim(); + if (!query) return res.status(400).json({ message: "검색어가 없습니다." }); + + const results = mockData.cafes.filter((cafe) => + cafe.tags.some((tag) => tag.includes(query)) + ); + + res.json({ cafes: results }); +}); +// ---------------------- +// 2. 카페 검색 API +// ---------------------- +app.get("/api/search-cafes", (req, res) => { + const { tags } = req.query; // tags는 "카페,조용함" 같은 CSV 문자열 + if (!tags) return res.json([]); + + const tagArray = tags.split(","); + const filtered = CAFES.filter((cafe) => + tagArray.every((t) => cafe.tags.includes(t)) + ); + + res.json(filtered); +}); + +// ---------------------- +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); diff --git a/src/App.tsx b/src/App.tsx index 027e7c4..230f4ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import AiSearchPage from "./pages/search/AiSearchPage"; import TagSearchPage from "./pages/search/TagSearchPage"; import Home from "./pages/Home"; // 홈 페이지 import CafeDetailPage from "./pages/cafe/CafeDetailPage"; +import StudySpotRec from "./pages/recommend/StudySpotRec"; function App() { return ( @@ -15,6 +16,7 @@ function App() { } /> {/* }/> */} } /> + } /> diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..e78f327 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +interface CardProps { + children: React.ReactNode; + className?: string; +} + +export function Card({ children, className }: CardProps) { + return
{children}
; +} + +export function CardContent({ children, className }: CardProps) { + return
{children}
; +} diff --git a/src/pages/CafeDetail.tsx b/src/pages/CafeDetail.tsx new file mode 100644 index 0000000..f0fd5cb --- /dev/null +++ b/src/pages/CafeDetail.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { getCafeDetail, getCafeReviews, postCafeReview } from "../api/cafeApi"; +import "../styles/CafeDetail.css"; + +interface Cafe { + id: number; + name: string; + location: string; + tags: string[]; + thumbnail?: string; +} + +interface Review { + id: number; + rating: number; + comment: string; + author: string; + createdAt?: string; +} + +const CafeDetail: React.FC = () => { + const { cafeId } = useParams<{ cafeId: string }>(); + const [cafe, setCafe] = useState(null); + const [reviews, setReviews] = useState([]); + const [newRating, setNewRating] = useState(0); + const [newComment, setNewComment] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!cafeId) return; + + const fetchData = async () => { + try { + const [cafeData, reviewData] = await Promise.all([ + getCafeDetail(cafeId!), // string 그대로 전달, !로 undefined 제외 + getCafeReviews(cafeId!), + ]); + + setCafe(cafeData); + setReviews( + reviewData.map((r: any) => ({ ...r, author: r.author ? r.author : "익명" })) + ); + } catch (err) { + console.error("카페 상세정보 불러오기 실패:", err); + } finally { + setLoading(false); + } +}; + + + fetchData(); + }, [cafeId]); + + const handleReviewSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!cafeId) return; + + try { + await postCafeReview(cafeId!, { rating: newRating, comment: newComment }); + + const updatedReviews = await getCafeReviews(cafeId!); + setReviews( + updatedReviews.map((r: any) => ({ ...r, author: r.author ? r.author : "익명" })) + ); + + setNewRating(0); + setNewComment(""); + } catch (err) { + console.error("리뷰 등록 실패:", err); + } +}; + + + if (loading) return
로딩 중...
; + if (!cafe) return
카페 정보를 불러오지 못했습니다.
; + + return ( +
+

{cafe.name}

+ {cafe.thumbnail && {cafe.name}} +

{cafe.location}

+
{cafe.tags.map((tag, i) => #{tag})}
+ +

리뷰

+
    + {reviews.map((review) => ( +
  • + ⭐ {review.rating} - {review.comment} - {review.author} +
  • + ))} +
+ +
+

리뷰 남기기

+ +