From e565b10b777527d8112ce5a6ec6da9f1a628954d Mon Sep 17 00:00:00 2001 From: xihxxn Date: Mon, 1 Jun 2026 00:49:52 +0900 Subject: [PATCH 01/17] =?UTF-8?q?cors=20=EC=A3=BC=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/Piroin/project/global/config/CorsConfig.java | 5 +++++ .../example/Piroin/project/global/config/SecurityConfig.java | 2 ++ 2 files changed, 7 insertions(+) diff --git a/backend/src/main/java/com/example/Piroin/project/global/config/CorsConfig.java b/backend/src/main/java/com/example/Piroin/project/global/config/CorsConfig.java index ef95a6b..e56e761 100644 --- a/backend/src/main/java/com/example/Piroin/project/global/config/CorsConfig.java +++ b/backend/src/main/java/com/example/Piroin/project/global/config/CorsConfig.java @@ -14,12 +14,17 @@ public class CorsConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); + // 모든 출처(도메인) 허용 - Vercel 프론트엔드 등 다양한 도메인에서 요청 가능 config.setAllowedOriginPatterns(List.of("*")); + // preflight(OPTIONS) 포함 모든 HTTP 메서드 허용 config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + // Authorization 등 모든 요청 헤더 허용 config.setAllowedHeaders(List.of("*")); + // 쿠키/인증 정보 포함 요청 허용 config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + // 모든 경로에 위 CORS 설정 적용 source.registerCorsConfiguration("/**", config); return source; } diff --git a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java index b05f584..bbcd7cd 100644 --- a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java @@ -27,6 +27,8 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + // CorsConfig에서 등록한 CORS 설정을 Spring Security 필터 체인에 적용 + // 이 설정이 없으면 preflight(OPTIONS) 요청이 Security 단에서 차단되어 405 반환 .cors(cors -> cors.configurationSource(corsConfigurationSource)) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> From 667c684345b2ce0a6cd39c2a3183438bcbe98c95 Mon Sep 17 00:00:00 2001 From: xihxxn Date: Mon, 1 Jun 2026 02:48:50 +0900 Subject: [PATCH 02/17] connect api.piroin.com subdomain for HTTPS --- frontend/vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/vercel.json b/frontend/vercel.json index 246390d..d134295 100644 --- a/frontend/vercel.json +++ b/frontend/vercel.json @@ -2,7 +2,7 @@ "rewrites": [ { "source": "/api/:path*", - "destination": "http://13.209.73.127:8080/api/:path*" + "destination": "https://api.piroin.com/api/:path*" } ] } From 85da7076c9f33a4feb0ed4f0be74617f482f1c4d Mon Sep 17 00:00:00 2001 From: xihxxn Date: Mon, 1 Jun 2026 13:42:22 +0900 Subject: [PATCH 03/17] =?UTF-8?q?[Security]=20ADMIN=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EC=A0=9C=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/global/config/SecurityConfig.java | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java index bbcd7cd..aa250a8 100644 --- a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java @@ -35,18 +35,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - // 로그인 페이지는 로그인 안 된 상태에서 접근 가능 + // 로그인 .requestMatchers("/api/auth/login").permitAll() - // curriculum: GET은 로그인한 누구나, POST/PATCH/DELETE는 ADMIN만 -> 이중 보안 느낌 - .requestMatchers(HttpMethod.GET, "/api/curriculums").authenticated() - .requestMatchers(HttpMethod.POST, "/api/curriculums").hasRole("ADMIN") - .requestMatchers(HttpMethod.PATCH, "/api/curriculums/{sessionDate}").hasRole("ADMIN") - .requestMatchers(HttpMethod.DELETE, "/api/curriculums/{sessionDate}").hasRole("ADMIN") - - // understanding check: 생성은 ADMIN만 가능 - .requestMatchers(HttpMethod.POST, "/api/sessions/{sessionId}/understanding-checks").hasRole("ADMIN") - // Swagger .requestMatchers( "/swagger-ui/**", @@ -57,10 +48,25 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // Actuator health check .requestMatchers("/actuator/health").permitAll() - // 다른 도메인 권한 설정 필요 시 위 패턴 참고해서 추가 - // 단, 추가하지 않아도 무방함 - // 이유 1. anyRequest().authenticated()로 비로그인 접근 차단 - // 이유 2. 프론트에서 ADMIN 전용 버튼/기능을 UI 단에서 숨김 처리 + // ADMIN 전용 엔드포인트 + .requestMatchers("/api/admin/**").hasRole("ADMIN") + + .requestMatchers(HttpMethod.POST, "/api/curriculums").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/curriculums/{sessionDate}").hasRole("ADMIN") + .requestMatchers(HttpMethod.DELETE, "/api/curriculums/{sessionDate}").hasRole("ADMIN") + + .requestMatchers(HttpMethod.POST, "/api/assignments/create").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/assignments/modify/{assignmentId}").hasRole("ADMIN") + .requestMatchers(HttpMethod.DELETE, "/api/assignments/{assignmentId}").hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/assignments/{week}/view").hasRole("ADMIN") + + .requestMatchers(HttpMethod.GET, "/api/deposit/{userId}/deposit/view").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/deposit/{userId}/deposit/defence").hasRole("ADMIN") + + .requestMatchers(HttpMethod.POST, "/api/sessions/{sessionId}/understanding-checks").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/questions/{questionId}/status").hasRole("ADMIN") + + // 나머지는 로그인한 사용자면 접근 가능 .anyRequest().authenticated() ) From cf8e9dba8760716c1a6722ee63b22113cb70eb64 Mon Sep 17 00:00:00 2001 From: xihxxn Date: Mon, 1 Jun 2026 15:35:10 +0900 Subject: [PATCH 04/17] =?UTF-8?q?[Security]=20formLogin/httpBasic=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B0=8F=20ADMIN=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C=EC=96=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/Piroin/project/global/config/SecurityConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java index aa250a8..083a9e9 100644 --- a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java @@ -31,6 +31,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 이 설정이 없으면 preflight(OPTIONS) 요청이 Security 단에서 차단되어 405 반환 .cors(cors -> cors.configurationSource(corsConfigurationSource)) .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth From 409695a34948c2cc7918c3030c524536bf3a5e08 Mon Sep 17 00:00:00 2001 From: plumbestie Date: Mon, 1 Jun 2026 23:23:11 +0900 Subject: [PATCH 05/17] =?UTF-8?q?[Feat]=20Header=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Header.js | 86 +++++++++-- frontend/src/components/Header.module.css | 165 +++++++++++++++++++++- 2 files changed, 234 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index d2c815c..ce10952 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -1,23 +1,81 @@ +import { useState, useEffect, useCallback } from 'react'; import { NavLink } from 'react-router-dom'; import styles from './Header.module.css'; function Header({ type }) { + const [menuOpen, setMenuOpen] = useState(false); + + const closeMenu = useCallback(() => setMenuOpen(false), []); + + useEffect(() => { + const mq = window.matchMedia('(min-width: 1025px)'); + const handler = (e) => { if (e.matches) closeMenu(); }; + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, [closeMenu]); + + useEffect(() => { + document.body.style.overflow = menuOpen ? 'hidden' : ''; + return () => { document.body.style.overflow = ''; }; + }, [menuOpen]); + + const handleLogout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('role'); + window.location.href = '/login'; + }; + + const themeClass = type === 'dark' ? styles.dark : styles.light; + return ( -
- PIROIN -
+ ); } diff --git a/frontend/src/components/Header.module.css b/frontend/src/components/Header.module.css index fb275f0..9b3c98e 100644 --- a/frontend/src/components/Header.module.css +++ b/frontend/src/components/Header.module.css @@ -10,7 +10,7 @@ --header-bg: var(--black); --header-color: var(--white); --logo-color: var(--main); - border-bottom: none; /* 추가 */ + border-bottom: none; } .header { @@ -21,7 +21,6 @@ align-items: center; padding: 0 80px; box-sizing: border-box; - position: relative; position: sticky; top: 0; z-index: 100; @@ -33,8 +32,10 @@ font-size: 2.8rem; font-weight: 800; text-decoration: none; + flex-shrink: 0; } +/* ── 데스크탑 nav ── */ .nav { display: flex; gap: 6rem; @@ -51,6 +52,7 @@ font-size: 1.4rem; font-weight: 500; text-decoration: none; + white-space: nowrap; } .nav a:hover { @@ -72,6 +74,163 @@ font-weight: 500; cursor: pointer; opacity: 0.7; + flex-shrink: 0; + white-space: nowrap; } -.logoutBtn:hover { opacity: 1; transition: all ease-in-out 0.2s; } \ No newline at end of file +.logoutBtn:hover { + opacity: 1; + transition: all ease-in-out 0.2s; +} + +/* ── 햄버거 버튼 ── */ +.hamburger { + display: none; + flex-direction: column; + justify-content: center; + gap: 5px; + margin-left: auto; + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + z-index: 200; +} + +.hamburger span { + display: block; + width: 24px; + height: 2px; + background: var(--header-color); + border-radius: 2px; + transition: transform 0.3s ease, opacity 0.3s ease, width 0.3s ease; + transform-origin: center; +} + +.hamburgerOpen span:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} +.hamburgerOpen span:nth-child(2) { + opacity: 0; + width: 0; +} +.hamburgerOpen span:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +/* ── 오버레이 (항상 DOM에 존재, visibility로 제어) ── */ +.overlay { + position: fixed; + inset: 0; + z-index: 140; + opacity: 0; + visibility: hidden; + transition: opacity 0.35s ease, visibility 0.35s ease; + backdrop-filter: blur(2px); +} + +.overlayVisible { + opacity: 1; + visibility: visible; +} + +/* ── 드로어 (항상 DOM에 존재, transform으로 제어) ── */ +.drawer { + position: fixed; + top: 0; + right: 0; + width: 260px; + height: 100vh; + z-index: 150; + display: flex; + flex-direction: column; + padding: 90px 36px 40px; + box-sizing: border-box; + transform: translateX(100%); + visibility: hidden; + transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), + visibility 0.35s ease; + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); +} + +.drawerOpen { + transform: translateX(0); + visibility: visible; +} + +.drawer a { + color: var(--header-color); + font-family: var(--font-main); + font-size: 1.2rem; + font-weight: 500; + text-decoration: none; + padding: 16px 0; + border-bottom: 1px solid rgba(128, 128, 128, 0.15); + transition: color 0.2s ease; +} + +.drawer a:last-of-type { + border-bottom: none; +} + +.drawer a:hover { + color: var(--logo-color); +} + +.drawerLogoutBtn { + margin-top: auto; + background: transparent; + border: 1px solid rgba(128, 128, 128, 0.3); + border-radius: 8px; + color: var(--dark); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + opacity: 1; + padding: 10px 0; + transition: opacity 0.2s ease; +} + +.drawerLogoutBtn:hover { + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; +} + +/* ── 반응형: 1024px 이하에서 햄버거로 전환 ── */ +@media (max-width: 1024px) { + .header { + padding: 0 24px; + } + + .nav, + .logoutBtn { + display: none; + } + + .hamburger { + display: flex; + } +} + +/* ── 드로어 닫기 버튼 ── */ +.drawerCloseBtn { + position: absolute; + top: 20px; + right: 20px; + background: transparent; + border: none; + color: var(--header-color); + font-size: 1.8rem; + cursor: pointer; + opacity: 0.6; + line-height: 1; + padding: 4px 8px; + transition: opacity 0.2s ease; +} + +.drawerCloseBtn:hover { + opacity: 1; +} + \ No newline at end of file From 1e6b6df5b13c0af73b138a4c4e0c49563efe602b Mon Sep 17 00:00:00 2001 From: plumbestie Date: Mon, 1 Jun 2026 23:44:20 +0900 Subject: [PATCH 06/17] =?UTF-8?q?[Feat]=20Curriculum=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../curriculum/CurriculumPage.module.css | 196 +++++++++++++----- 1 file changed, 147 insertions(+), 49 deletions(-) diff --git a/frontend/src/pages/curriculum/CurriculumPage.module.css b/frontend/src/pages/curriculum/CurriculumPage.module.css index c899b66..4b1f2a3 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.module.css +++ b/frontend/src/pages/curriculum/CurriculumPage.module.css @@ -31,7 +31,7 @@ .weekTitle { font-family: var(--font-main); font-size: 1.6rem; - font-weight: 700; + font-weight: 650; color: var(--black); } @@ -63,7 +63,7 @@ gap: 20px; flex-wrap: wrap; align-items: flex-start; - justify-content: flex-start; + justify-content: flex-start; } /* 세션 카드 */ @@ -71,10 +71,15 @@ background: var(--white); border: 1px solid #eee; border-radius: 20px; - padding: 30px; + padding: 30px 30px 24px 30px; min-width: 200px; width: calc(28% - 14px); + height: 380px; + display: flex; + flex-direction: column; box-shadow: 0 1px 4px rgba(0,0,0,0.06); + box-sizing: border-box; + overflow: hidden; } .cardHeader { @@ -82,13 +87,14 @@ align-items: center; justify-content: space-between; cursor: pointer; - margin-bottom: 0; + margin-bottom: 0; } .cardHeaderLeft { display: flex; align-items: center; gap: 10px; + flex-wrap: wrap; } .cardTitle { @@ -114,12 +120,15 @@ display: flex; flex-direction: column; gap: 12px; + overflow-y: auto; + flex: 1; + padding-bottom: 4px; } .divider { - border: none; + border: none; border-top: 1px solid var(--gray200); - margin: 20px 0 20px 0; + margin: 20px 0; } /* 세션 정보 */ @@ -144,13 +153,17 @@ } .sessionTitleRow { - margin: 5px 0; + display: flex; + align-items: center; + gap: 8px; + margin: 5px 0; } .sessionIcon { width: 18px; height: 18px; object-fit: contain; + flex-shrink: 0; filter: brightness(0) saturate(100%) invert(44%) sepia(98%) saturate(500%) hue-rotate(90deg) brightness(95%) contrast(110%); } @@ -160,6 +173,11 @@ font-weight: 550; color: var(--black); padding: 5px 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; } .sessionHost { @@ -167,12 +185,16 @@ font-size: 0.9rem; color: var(--gray600); margin-left: auto; + flex-shrink: 0; + white-space: nowrap; } .sessionDetailRow { - display: flex; - justify-content: space-between; - padding: 3px 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 3px 0 3px 26px; } .sessionLink { @@ -180,12 +202,16 @@ font-size: 0.9rem; color: var(--black); text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; } .sessionLink:hover { - color: var(--dark); - transition: all ease-in-out 0.2s; - cursor: pointer; + color: var(--dark); + transition: all ease-in-out 0.2s; + cursor: pointer; } .sessionLinkName { @@ -206,6 +232,24 @@ color: var(--black); } +.sessionDetailLabel { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); + min-width: 50px; + flex-shrink: 0; +} + +.sessionDetailVal { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; +} + /* 과제 */ .assignmentRow { display: flex; @@ -214,6 +258,7 @@ gap: 8px; padding-top: 4px; margin-left: 22px; + flex-wrap: wrap; } .assignmentLabel { @@ -249,9 +294,9 @@ } .editBtn:hover { - background: var(--dark); - color: var(--white); - transition: all ease-in-out 0.2s; + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; } .deleteBtn { @@ -266,9 +311,9 @@ } .deleteBtn:hover { - background: var(--dark); - color: var(--white); - transition: all ease-in-out 0.2s; + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; } /* 세션 생성/수정 폼 */ @@ -289,9 +334,11 @@ border-radius: 16px; padding: 40px; width: 560px; + max-width: 100%; display: flex; flex-direction: column; gap: 16px; + box-sizing: border-box; } .formTitle { @@ -313,6 +360,7 @@ align-items: center; gap: 8px; margin-top: 8px; + flex-wrap: wrap; } .amLabel { @@ -333,6 +381,7 @@ display: flex; gap: 6px; margin-left: auto; + flex-wrap: wrap; } .statusBtn { @@ -347,9 +396,9 @@ } .statusBtn:hover { - background: var(--dark); - color: var(--white); - transition: all ease-in-out 0.2s; + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; } .statusActive { @@ -412,10 +461,10 @@ text-align: center; } -/* 추가 스타일 */ .toggleIcon { width: 14px; height: 14px; + flex-shrink: 0; transition: transform 0.3s ease; filter: brightness(0) saturate(100%) invert(44%) sepia(60%) saturate(1693%) hue-rotate(89deg) brightness(107%) contrast(95%); } @@ -424,34 +473,83 @@ transform: rotate(180deg); } -.sessionTitleRow { - display: flex; - align-items: center; - gap: 8px; -} - -.sessionDetailRow { - display: flex; - align-items: center; - gap: 8px; - padding-left: 26px; -} - -.sessionDetailLabel { - font-family: var(--font-main); - font-size: 0.9rem; - color: var(--black); - min-width: 50px; -} - -.sessionDetailVal { - font-family: var(--font-main); - font-size: 0.9rem; - color: var(--black); -} - .formRow2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; +} + +/* ── 태블릿: 카드 2열 ── */ +@media (max-width: 1100px) { + .container { + padding: 32px 32px; + } + + .sessionCard { + width: calc(50% - 10px); + } +} + +/* ── 모바일: 카드 1열, 폼 풀스크린 ── */ +@media (max-width: 640px) { + .container { + padding: 24px 16px; + } + + .sessionCard { + width: 100%; + min-width: unset; + padding: 20px; + height: auto; + overflow: visible; + } + + .cardBody { + overflow-y: visible; + flex: none; + } + + .cardTitle { + font-size: 1.1rem; + } + + .cardDate { + margin-left: 0; + } + + /* 폼 */ + .formOverlay { + padding: 0; + align-items: flex-start; + } + + .formCard { + width: 100%; + min-height: 100vh; + border-radius: 0; + padding: 28px 20px; + } + + .formGrid { + grid-template-columns: 1fr; + } + + .formRow2 { + grid-template-columns: 1fr; + } + + .saveFormBtn { + width: 100%; + } + + .statusBtns { + margin-left: 0; + margin-top: 6px; + width: 100%; + } + + .formSectionTitle { + flex-direction: column; + align-items: flex-start; + } } \ No newline at end of file From abbae7839d64676e5e77bed4c75208744d88473d Mon Sep 17 00:00:00 2001 From: plumbestie Date: Mon, 1 Jun 2026 23:48:27 +0900 Subject: [PATCH 07/17] =?UTF-8?q?[Feat]=20Login=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/login/LoginPage.js | 17 ++++++++++------- frontend/src/pages/login/LoginPage.module.css | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/login/LoginPage.js b/frontend/src/pages/login/LoginPage.js index 7fbd50d..6e7840d 100644 --- a/frontend/src/pages/login/LoginPage.js +++ b/frontend/src/pages/login/LoginPage.js @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { authFetch } from '../../utils/Api'; import styles from './LoginPage.module.css'; function LoginPage() { @@ -8,12 +7,10 @@ function LoginPage() { const [focused, setFocused] = useState(''); const [form, setForm] = useState({ name: '', password: '' }); - const handleChange = (e) => { setForm({ ...form, [e.target.name]: e.target.value }); }; - const handleLogin = async () => { try { const response = await fetch('/api/auth/login', { @@ -27,9 +24,8 @@ function LoginPage() { localStorage.setItem('token', data.token); localStorage.setItem('role', data.role); localStorage.setItem('name', data.name); - navigate('/sessions'); // 로그인 성공 시 이동할 페이지 + navigate('/sessions'); } else { - const errData = await response.json(); alert('이름 또는 비밀번호가 올바르지 않습니다.'); } } catch (error) { @@ -37,13 +33,18 @@ function LoginPage() { } }; - useEffect(() => { + // 엔터키 로그인 + const handleKeyDown = (e) => { + if (e.key === 'Enter') handleLogin(); + }; + + useEffect(() => { document.title = "로그인 | PIROIN"; }, []); return (
-

PIROIN

+

navigate('/')}>PIROIN

setFocused('name')} onBlur={() => setFocused('')} @@ -61,6 +63,7 @@ function LoginPage() { placeholder="비밀번호" value={form.password} onChange={handleChange} + onKeyDown={handleKeyDown} className={`${styles.input} ${focused === 'pw' ? styles.inputFocused : ''}`} onFocus={() => setFocused('pw')} onBlur={() => setFocused('')} diff --git a/frontend/src/pages/login/LoginPage.module.css b/frontend/src/pages/login/LoginPage.module.css index 3a146cd..bf710c8 100644 --- a/frontend/src/pages/login/LoginPage.module.css +++ b/frontend/src/pages/login/LoginPage.module.css @@ -13,6 +13,8 @@ font-size: 56px; font-weight: 900; margin-bottom: 48px; + cursor: pointer; + text-decoration: none; } .form { @@ -30,6 +32,7 @@ font-size: 16px; outline: none; transition: border 0.2s; + box-sizing: border-box; } .inputFocused { @@ -51,4 +54,18 @@ .button:hover { background-color: var(--main); transition: all 0.2s ease; +} + +/* ── 모바일 ── */ +@media (max-width: 480px) { + .title { + font-size: 40px; + margin-bottom: 32px; + } + + .form { + width: 100%; + padding: 0 24px; + box-sizing: border-box; + } } \ No newline at end of file From 923701ae15be650c4f49e25808429356c149f4cb Mon Sep 17 00:00:00 2001 From: plumbestie Date: Mon, 1 Jun 2026 23:51:55 +0900 Subject: [PATCH 08/17] =?UTF-8?q?[Feat]=20Onboarding=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/OnboardingPage.module.css | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/frontend/src/pages/OnboardingPage.module.css b/frontend/src/pages/OnboardingPage.module.css index 890b008..f25dab2 100644 --- a/frontend/src/pages/OnboardingPage.module.css +++ b/frontend/src/pages/OnboardingPage.module.css @@ -47,4 +47,34 @@ font-size: 18px; margin: 4px 0; font-weight: 550; + text-align: center; +} + +/* ── 모바일 ── */ +@media (max-width: 480px) { + .title { + font-size: 60px; + margin-bottom: 32px; + } + + .logoWrap { + width: 200px; + height: 200px; + margin-bottom: 32px; + } + + .logoWrap img { + width: 200px; + height: 200px; + } + + .circle { + width: 86px; + height: 86px; + } + + .sub { + font-size: 14px; + padding: 0 24px; + } } \ No newline at end of file From b73821f8607a3e99e4167d65405eef144cdf5505 Mon Sep 17 00:00:00 2001 From: plumbestie Date: Tue, 2 Jun 2026 01:04:06 +0900 Subject: [PATCH 09/17] =?UTF-8?q?[Feat]=20PiroCheck(=EB=A9=94=EC=9D=B8,=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D,=20=EA=B3=BC=EC=A0=9C)=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/curriculum/CurriculumPage.js | 17 +- .../curriculum/CurriculumPage.module.css | 3 +- .../pages/pirocheck/PIroCheckMain.module.css | 11 +- .../pages/pirocheck/assignment/Assignment.js | 37 +++-- .../assignment/Assignment.module.css | 156 ++++++++++++++++-- .../pages/pirocheck/attendance/Attendance.js | 74 ++++++--- .../attendance/Attendance.module.css | 60 ++++++- 7 files changed, 292 insertions(+), 66 deletions(-) diff --git a/frontend/src/pages/curriculum/CurriculumPage.js b/frontend/src/pages/curriculum/CurriculumPage.js index d369939..e65f1ce 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.js +++ b/frontend/src/pages/curriculum/CurriculumPage.js @@ -12,6 +12,15 @@ const DAY_LABEL = { TUESDAY: '화요일', THURSDAY: '목요일', SATURDAY: '토 const STATUS_OPTIONS = ['BEFORE', 'ONGOING', 'AFTER']; const STATUS_LABEL = { BEFORE: '세션 전', ONGOING: '세션 중', AFTER: '세션 후' }; +// sessionDate(yyyy-mm-dd)에서 요일 계산 +function getWeekDayFromDate(dateStr) { + if (!dateStr) return ''; + const [year, month, day] = dateStr.split('-').map(Number); + const date = new Date(year, month - 1, day); + const map = { 2: '화요일', 4: '목요일', 6: '토요일' }; + return map[date.getDay()] || ''; +} + // ── 세션 정보 렌더 (공통) ───────────────────────────── function SessionInfo({ session, isAdmin }) { const icon = session.dayPart === 'AM' ? AmImg : PmImg; @@ -47,7 +56,7 @@ function MemberSessionCard({ day }) { const [isOpen, setIsOpen] = useState(false); const amSession = day.sessions?.find(s => s.dayPart === 'AM'); const pmSession = day.sessions?.find(s => s.dayPart === 'PM'); - const weekDay = DAY_LABEL[day.dayOfWeek] || ''; + const weekDay = getWeekDayFromDate(day.sessionDate) || DAY_LABEL[day.dayOfWeek] || ''; return (
@@ -56,7 +65,7 @@ function MemberSessionCard({ day }) { {day.week}주차 {weekDay} 세션 {day.sessionDate}
- toggle + toggle

@@ -81,10 +90,10 @@ function MemberSessionCard({ day }) { // ── 운영진용 세션 카드 ──────────────────────────────── function AdminSessionCard({ day, onEdit, onDelete }) { - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useState(false); const amSession = day.sessions?.find(s => s.dayPart === 'AM'); const pmSession = day.sessions?.find(s => s.dayPart === 'PM'); - const weekDay = DAY_LABEL[day.dayOfWeek] || ''; + const weekDay = getWeekDayFromDate(day.sessionDate) || DAY_LABEL[day.dayOfWeek] || ''; return (
diff --git a/frontend/src/pages/curriculum/CurriculumPage.module.css b/frontend/src/pages/curriculum/CurriculumPage.module.css index 4b1f2a3..57633ed 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.module.css +++ b/frontend/src/pages/curriculum/CurriculumPage.module.css @@ -74,12 +74,13 @@ padding: 30px 30px 24px 30px; min-width: 200px; width: calc(28% - 14px); - height: 380px; + max-height: 380px; display: flex; flex-direction: column; box-shadow: 0 1px 4px rgba(0,0,0,0.06); box-sizing: border-box; overflow: hidden; + transition: max-height 0.3s ease; } .cardHeader { diff --git a/frontend/src/pages/pirocheck/PIroCheckMain.module.css b/frontend/src/pages/pirocheck/PIroCheckMain.module.css index 9a0663a..856186b 100644 --- a/frontend/src/pages/pirocheck/PIroCheckMain.module.css +++ b/frontend/src/pages/pirocheck/PIroCheckMain.module.css @@ -4,7 +4,7 @@ align-items: center; justify-content: center; gap: 20px; - height: calc(100vh - 100px); + height: calc(100vh - 100px); background: var(--black); } @@ -25,4 +25,13 @@ .menuBtn:hover { background: var(--dark); color: var(--white); +} + +/* ── 모바일 ── */ +@media (max-width: 480px) { + .menuBtn { + width: calc(100% - 48px); + font-size: 1.5rem; + padding: 20px 0; + } } \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/assignment/Assignment.js b/frontend/src/pages/pirocheck/assignment/Assignment.js index 81a0f13..27786c7 100644 --- a/frontend/src/pages/pirocheck/assignment/Assignment.js +++ b/frontend/src/pages/pirocheck/assignment/Assignment.js @@ -56,13 +56,14 @@ function AssignmentModal({ item, onClose, onSave }) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: form.title, week: form.week, day: form.day }), }); - onSave(); + onSave(form.title); onClose(); }; return (
+ logo
ASSIGNMENT
@@ -113,21 +114,21 @@ function WeekBlock({ weekData, role, onEdit, onDelete }) { {dayMap[session.day]} {session.sessionDate && {session.sessionDate}}
- {role === 'ADMIN' && ( -
- - -
- )}
{session.items.map((item, k) => (
{item.title} {role === 'MEMBER' && } + {role === 'ADMIN' && ( +
+ + +
+ )}
))} {j < grouped.length - 1 &&
} @@ -156,6 +157,18 @@ function Assignment() { setWeeks(results); }; + // 수정 시 로컬 state만 업데이트 (순서 유지) + const handleEditSave = (updatedItem) => { + setWeeks(prev => prev.map(w => ({ + ...w, + assignments: w.assignments.map(a => + a.assignmentId === updatedItem.assignmentId + ? { ...a, ...updatedItem } + : a + ) + }))); + }; + useEffect(() => { fetchAll(); }, []); const handleDelete = async (assignmentId) => { @@ -186,7 +199,7 @@ function Assignment() { setModalItem(undefined)} - onSave={fetchAll} + onSave={modalItem ? (updated) => handleEditSave({ ...modalItem, title: updated }) : fetchAll} /> )}
diff --git a/frontend/src/pages/pirocheck/assignment/Assignment.module.css b/frontend/src/pages/pirocheck/assignment/Assignment.module.css index 5ca20b9..8621bcf 100644 --- a/frontend/src/pages/pirocheck/assignment/Assignment.module.css +++ b/frontend/src/pages/pirocheck/assignment/Assignment.module.css @@ -36,6 +36,7 @@ border-radius: 16px; margin-bottom: 20px; overflow: hidden; + box-sizing: border-box; } .weekHeader { @@ -153,6 +154,7 @@ .statusIcon { width: 20px; height: 20px; + flex-shrink: 0; } .divider { @@ -195,25 +197,44 @@ } .modal { + position: relative; background: #3a3a3a; border-radius: 20px; padding: 40px 60px; - width: 420px; + width: clamp(360px, 40vw, 520px); display: flex; flex-direction: column; align-items: center; - gap: 16px; + gap: 16px; + box-sizing: border-box; } +/* X 닫기 버튼 */ +.modalCloseBtn { + position: absolute; + top: 16px; + right: 20px; + background: transparent; + border: none; + color: #aaa; + font-size: 1.4rem; + cursor: pointer; + line-height: 1; + padding: 4px; + transition: color 0.2s; +} + +.modalCloseBtn:hover { color: var(--white); } + .modalLogo { - width: 200px; - height: 200px; + width: 160px; + height: 160px; object-fit: contain; } .modalTitle { - font-family: var(--font-main); - font-size: 3rem; + font-family: var(--font-title); + font-size: 2.6rem; font-weight: 800; color: var(--main); letter-spacing: 0; @@ -223,36 +244,33 @@ display: flex; align-items: center; gap: 12px; - width: 85%; + width: 100%; margin-top: 10px; } .select { - padding: 10px 36px 10px 20px; + padding: 10px 36px 10px 20px; background-color: var(--pale); - -webkit-appearance: none; -moz-appearance: none; appearance: none; - background-image: url("data:image/svg+xml;utf8,"); background-repeat: no-repeat; background-position: right 16px center; - border: none; border-radius: 8px; font-family: var(--font-main); font-size: 1rem; cursor: pointer; flex: 1; + min-width: 0; } -.select::-ms-expand { - display: none; -} +.select::-ms-expand { display: none; } + .modalInput { - width: 85%; - padding: 12px 20px; + width: 100%; + padding: 12px 20px; background: var(--pale); border: none; border-radius: 8px; @@ -265,6 +283,7 @@ color: var(--white); font-family: var(--font-main); font-size: 1.2rem; + white-space: nowrap; } .saveBtn { @@ -284,4 +303,109 @@ .saveBtn:hover { background: var(--main); color: var(--black); +} + +/* ── 모바일 ── */ +@media (max-width: 640px) { + .container { + padding: 40px 24px; + } + + .title { + font-size: 3rem; + margin-bottom: 28px; + text-align: center; + word-break: keep-all; + line-height: 1.2; + } + + .mockBanner { + width: 100%; + } + + .weekBlock { + width: 100%; + } + + .weekHeader { + padding: 20px; + } + + .weekBody { + padding: 0 20px 20px; + } + + /* 모달 */ + .modalOverlay { + align-items: center; + padding: 24px; + box-sizing: border-box; + } + + .modal { + width: 100%; + max-width: 100%; + border-radius: 20px; + padding: 40px 24px 40px; + } + + .modalLogo { + width: 120px; + height: 120px; + } + + .modalTitle { + font-size: 2rem; + } + + .modalRow { + width: 100%; + } + + .modalInput { + width: 100%; + } + + .addBtn { + bottom: 24px; + right: 20px; + } + + .assignmentTitle { + font-size: 0.9rem; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 8px; + } + + .sessionTitle { + font-size: 0.85rem; + } + + .dayLabel { + font-size: 1.1rem; + } +} +/* ── 태블릿 ── */ +@media (min-width: 641px) and (max-width: 1024px) { + .container { + padding: 48px 32px; + } + + .weekBlock { + width: 100%; + max-width: 720px; + } + + .mockBanner { + width: 100%; + max-width: 720px; + } + + .modal { + width: clamp(400px, 70vw, 560px); + } } \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/attendance/Attendance.js b/frontend/src/pages/pirocheck/attendance/Attendance.js index fe8f350..032bbba 100644 --- a/frontend/src/pages/pirocheck/attendance/Attendance.js +++ b/frontend/src/pages/pirocheck/attendance/Attendance.js @@ -15,9 +15,19 @@ function cloverForSlot(status) { return 미정; } -function historyIcon(slots) { +// 세션 1회 출석 결과 → 코인/화남 아이콘 +// status: true(출석성공) / false(결석) / null(미정) +// successCount: 해당 세션에서 출석 성공한 횟수 (AM/PM 등 슬롯 수) +function sessionIcon(slot) { + if (slot.status === true) return 출석; + if (slot.status === false) return 결석; + return 미정; +} + +// 주차 전체 슬롯(3개) → 코인 합산 +function weekCoinIcon(slots) { const successCount = slots.filter(s => s.status === true).length; - if (successCount === 3) return 3회 출석; + if (successCount >= 3) return 3회 출석; if (successCount === 2) return 2회 출석; if (successCount === 1) return 1회 출석; return 결석; @@ -90,29 +100,34 @@ function MemberView() { const [history, setHistory] = useState([]); useEffect(() => { - // 1~5주차 기본값 세팅 - const defaultHistory = [1, 2, 3, 4, 5].map(week => ({ - week, - slots: [ - { status: false }, - { status: false }, - { status: false }, - ] - })); - - authFetch('/api/attendance/user') - .then(r => r.json()) - .then(data => { - const apiData = data.data || []; - // API 데이터로 해당 주차 덮어씌우기 - const merged = defaultHistory.map(def => { - const found = apiData.find(d => d.week === def.week); - return found || def; - }); - setHistory(merged); - }) - .catch(() => setHistory(defaultHistory)); -}, []); + const defaultHistory = [1, 2, 3, 4, 5].map(week => ({ + week, + slots: [ + { status: null }, + { status: null }, + { status: null }, + ] + })); + + // 오늘 출석 현황 초기 fetch (새로고침 시 유지) + const today = new Date().toISOString().split('T')[0]; + authFetch(`/api/attendance/user/date?date=${today}`) + .then(r => r.json()) + .then(d => setTodaySlots(d.data || [])) + .catch(() => {}); + + authFetch('/api/attendance/user') + .then(r => r.json()) + .then(data => { + const apiData = data.data || []; + const merged = defaultHistory.map(def => { + const found = apiData.find(d => d.week === def.week); + return found || def; + }); + setHistory(merged); + }) + .catch(() => setHistory(defaultHistory)); + }, []); const handleSubmit = async () => { if (!inputCode.trim()) return; @@ -133,7 +148,7 @@ function MemberView() { } else if (result.statusCode === 'INVALID_CODE') { setMessage('출석 코드를 확인해주세요.'); } else { - setMessage(result.message); + setMessage(result.message); } setInputCode(''); @@ -159,18 +174,23 @@ function MemberView() { {message &&
{message}
} + {/* 오늘 출석 현황 클로버 */}
{displaySlots.map((slot, i) => (
{cloverForSlot(slot.status)}
))}
+ {/* 주차별 출석 히스토리 */}
{history.map((row, i) => (
{row.week}주차
- {historyIcon(row.slots)} + {/* 슬롯 3개 각각 코인/화남으로 표시 */} + {row.slots.map((slot, j) => ( +
{sessionIcon(slot)}
+ ))}
))} diff --git a/frontend/src/pages/pirocheck/attendance/Attendance.module.css b/frontend/src/pages/pirocheck/attendance/Attendance.module.css index 7e72e9a..6f542a0 100644 --- a/frontend/src/pages/pirocheck/attendance/Attendance.module.css +++ b/frontend/src/pages/pirocheck/attendance/Attendance.module.css @@ -3,7 +3,7 @@ flex-direction: column; align-items: center; padding: 60px 20px; - min-height: calc(80vh - 100px); + min-height: calc(80vh - 100px); background: var(--black); justify-content: center; } @@ -14,7 +14,7 @@ font-weight: 800; color: var(--main); margin-bottom: 50px; - letter-spacing: 0.05em; + text-align: center; } /* ── ADMIN ── */ @@ -60,7 +60,7 @@ transition: opacity 0.2s; } -.createBtn:hover { background: var(--main); transition: all ease-in-out 0.2s;} +.createBtn:hover { background: var(--main); transition: all ease-in-out 0.2s; } .manageLink { color: var(--white); @@ -78,11 +78,13 @@ display: flex; align-items: center; background: var(--gray600); - border-radius: 10px; + border-radius: 10px; overflow: hidden; width: 480px; + max-width: calc(100vw - 56px); margin-bottom: 16px; padding: 3px 3px 3px 20px; + box-sizing: border-box; } .codeInput { @@ -101,7 +103,7 @@ padding: 10px 20px; background: var(--dark); border: none; - border-radius: 10px; + border-radius: 10px; color: var(--white); font-family: var(--font-main); font-size: 1rem; @@ -164,4 +166,52 @@ display: flex; flex-direction: column; gap: 16px; +} + +/* ── 모바일 ── */ +@media (max-width: 480px) { + .container { + padding: 48px 28px; + } + + .title { + font-size: 2.8rem; + margin-bottom: 36px; + word-break: keep-all; + line-height: 1.2; + } + + /* ADMIN */ + .code { + width: 68px; + height: 84px; + font-size: 2.2rem; + } + + .codebox { + gap: 10px; + } + + .createBtn { + width: calc(100vw - 56px); + max-width: 320px; + } + + /* MEMBER */ + .cloverSvg { + width: 63px; + height: 63px; + } + + .cloverRow { + gap: 28px; + margin-top: 28px; + margin-bottom: 28px; + } + + .historyBox { + width: calc(100vw - 56px); + padding: 24px 28px; + box-sizing: border-box; + } } \ No newline at end of file From 6e07aedd199a5d0b650ab34a062f210ae4ab326d Mon Sep 17 00:00:00 2001 From: kkw610 Date: Tue, 2 Jun 2026 15:02:37 +0900 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20resolveParentComment()=EC=97=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C/=EC=86=8C=EC=86=8D=20=EC=A7=88=EB=AC=B8/2dep?= =?UTF-8?q?th=20=EC=B4=88=EA=B3=BC=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/service/QuestionService.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java index a1aca36..1f0fa78 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java @@ -132,7 +132,7 @@ public QuestionResDTO.CommentCreateRes createComment( Question question = findQuestion(questionId); // 1. 대댓글 여부 확인: parentCommentId가 있으면 부모 댓글 조회 - QuestionComment parentComment = resolveParentComment(request.getParentCommentId()); + QuestionComment parentComment = resolveParentComment(request.getParentCommentId(), question); // 2. 댓글 엔티티 생성 및 저장 LocalDateTime now = LocalDateTime.now(); @@ -168,12 +168,27 @@ public QuestionResDTO.CommentCreateRes createComment( } // parentCommentId가 있으면 해당 댓글 조회, 없으면 null 반환 - private QuestionComment resolveParentComment(Long parentCommentId) { + private QuestionComment resolveParentComment(Long parentCommentId, Question question) { if (parentCommentId == null) { return null; } - return questionCommentRepository.findById(parentCommentId) + QuestionComment parent = questionCommentRepository.findById(parentCommentId) .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "부모 댓글을 찾을 수 없습니다.")); + + // 삭제된 댓글에는 대댓글을 달 수 없음 + if (parent.getDeletedAt() != null) { + throw new QuestionException(HttpStatus.BAD_REQUEST, "삭제된 댓글에는 대댓글을 달 수 없습니다."); + } + // 다른 질문의 댓글을 부모로 붙이는 것 방지 + if (!parent.getQuestion().getId().equals(question.getId())) { + throw new QuestionException(HttpStatus.BAD_REQUEST, "다른 질문의 댓글에는 대댓글을 달 수 없습니다."); + } + // 대댓글에 또 대댓글을 다는 것 방지 (2depth 제한) + if (parent.getParentComment() != null) { + throw new QuestionException(HttpStatus.BAD_REQUEST, "대댓글에는 대댓글을 달 수 없습니다."); + } + + return parent; } /* From 78656bddf74c3540194d668599f3e22f054f1be9 Mon Sep 17 00:00:00 2001 From: kkw610 Date: Tue, 2 Jun 2026 15:09:56 +0900 Subject: [PATCH 11/17] =?UTF-8?q?fix:=20V6=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20-=20question=5Flike,=20understand?= =?UTF-8?q?ing=5Fresponse=20=EC=9C=A0=EB=8B=88=ED=81=AC=20=EC=A0=9C?= =?UTF-8?q?=EC=95=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nique_constraints_like_and_understanding_response.sql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V6__add_unique_constraints_like_and_understanding_response.sql diff --git a/backend/src/main/resources/db/migration/V6__add_unique_constraints_like_and_understanding_response.sql b/backend/src/main/resources/db/migration/V6__add_unique_constraints_like_and_understanding_response.sql new file mode 100644 index 0000000..6d711d8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__add_unique_constraints_like_and_understanding_response.sql @@ -0,0 +1,9 @@ +-- QuestionLike: 같은 유저가 같은 질문에 좋아요를 중복으로 누르는 것을 DB 레벨에서 차단 +ALTER TABLE question_like + ADD CONSTRAINT uq_question_like_question_user + UNIQUE (question_id, user_id); + +-- UnderstandingResponse: 같은 유저가 같은 이해도 체크에 중복 응답하는 것을 DB 레벨에서 차단 +ALTER TABLE understanding_response + ADD CONSTRAINT uq_understanding_response_check_user + UNIQUE (check_id, user_id); \ No newline at end of file From 8a6cd8c90c5215cefd50fd86ae30885bd006b0a8 Mon Sep 17 00:00:00 2001 From: kkw610 Date: Tue, 2 Jun 2026 15:23:03 +0900 Subject: [PATCH 12/17] =?UTF-8?q?refactor:=20=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20N+1=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EB=8B=A8=EA=B1=B4=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/QuestionLikeRepository.java | 11 +++++ .../question/service/QuestionService.java | 42 +++++++++++++------ 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java index 9fb38f3..2d3cb3a 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java @@ -4,7 +4,10 @@ import com.example.Piroin.project.domain.question.entity.QuestionLike; import com.example.Piroin.project.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface QuestionLikeRepository extends JpaRepository { @@ -19,4 +22,12 @@ public interface QuestionLikeRepository extends JpaRepository findLikedQuestionIdsByQuestionIdsAndUser( + @Param("questionIds") List questionIds, + @Param("user") User user + ); } \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java index 1f0fa78..af58000 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java @@ -27,7 +27,9 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.Set; import java.util.Map; +import java.util.HashSet; import java.util.stream.Collectors; @Service @@ -589,7 +591,7 @@ private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse( private QuestionResDTO.QuestionGroupsResponse getQuestionGroups(StudySession session, User loginUser) { List questions = questionRepository.findBySessionAndDeletedAtIsNull(session); - QuestionSummaryContext summaryContext = getQuestionSummaryContext(questions); + QuestionSummaryContext summaryContext = getQuestionSummaryContext(questions, loginUser); List popularQuestions = questions.stream() .filter(q -> !q.getIsResolved() && q.getLikeCount() >= POPULAR_LIKE_THRESHOLD) @@ -616,7 +618,7 @@ private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse ( User loginUser ) { Long questionId = question.getId(); - boolean isLiked = questionLikeRepository.existsByQuestionAndUser(question, loginUser); + boolean isLiked = summaryContext.likedQuestionIds().contains(questionId); boolean isMine = question.getUser().getId().equals(loginUser.getId()); return new QuestionResDTO.QuestionSummaryResponse( questionId, question.getContent(), question.getImageUrl(), @@ -632,9 +634,9 @@ private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse ( ); } - private QuestionSummaryContext getQuestionSummaryContext(List questions) { + private QuestionSummaryContext getQuestionSummaryContext(List questions, User loginUser) { if (questions.isEmpty()) { - return new QuestionSummaryContext(Map.of(), Map.of()); + return new QuestionSummaryContext(Map.of(), Map.of(), Set.of()); } List questionIds = questions.stream() @@ -651,14 +653,17 @@ private QuestionSummaryContext getQuestionSummaryContext(List question questionCommentRepository.findPreviewCommentsByQuestionIds(questionIds) .forEach(row -> { Question question = questionsById.get(row.getQuestionId()); - if (question == null) { - return; - } + if (question == null) return; previewComments.computeIfAbsent(row.getQuestionId(), key -> new ArrayList<>()) .add(toPreviewCommentResponse(question, row)); }); - return new QuestionSummaryContext(commentCounts, previewComments); + // 좋아요 여부를 질문마다 조회하는 대신 한 번에 배치 조회한다. + Set likedQuestionIds = new HashSet<>( + questionLikeRepository.findLikedQuestionIdsByQuestionIdsAndUser(questionIds, loginUser) + ); + + return new QuestionSummaryContext(commentCounts, previewComments, likedQuestionIds); } private QuestionResDTO.PreviewCommentResponse toPreviewCommentResponse( @@ -692,16 +697,26 @@ private String getPreviewDisplayName(Question question, QuestionCommentRepositor private void publishCommentCreatedEventAfterCommit(Question question) { Long sessionId = question.getSession().getId(); Long questionId = question.getId(); - QuestionSummaryContext summaryContext = getQuestionSummaryContext(List.of(question)); - // 프론트가 전체 목록을 다시 조회하지 않고 해당 질문만 갱신할 수 있는 최소 데이터만 보낸다. + // 이벤트 발행용이라 좋아요 여부가 필요 없으므로 댓글 수/미리보기만 직접 조회 + List questionIds = List.of(questionId); + + Map commentCounts = new HashMap<>(); + questionCommentRepository.countByQuestionIds(questionIds) + .forEach(row -> commentCounts.put(row.getQuestionId(), Math.toIntExact(row.getCommentCount()))); + + Map> previewComments = new HashMap<>(); + questionCommentRepository.findPreviewCommentsByQuestionIds(questionIds) + .forEach(row -> previewComments.computeIfAbsent(row.getQuestionId(), key -> new ArrayList<>()) + .add(toPreviewCommentResponse(question, row))); + QuestionResDTO.CommentCreatedEvent event = new QuestionResDTO.CommentCreatedEvent( "COMMENT_CREATED", sessionId, questionId, question.getIsResolved(), - summaryContext.commentCounts().getOrDefault(questionId, 0), - summaryContext.previewComments().getOrDefault(questionId, List.of()) + commentCounts.getOrDefault(questionId, 0), + previewComments.getOrDefault(questionId, List.of()) ); publishAfterCommit(() -> questionEventService.publishCommentCreated(sessionId, event)); @@ -774,7 +789,8 @@ public void afterCommit() { private record QuestionSummaryContext( Map commentCounts, - Map> previewComments + Map> previewComments, + Set likedQuestionIds ) { } } From 74ae712544a197f8f48845066824eb50e2132961 Mon Sep 17 00:00:00 2001 From: lilyyang0077 Date: Tue, 2 Jun 2026 15:34:33 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=84=B8=EC=A7=80=20=EB=9D=84=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/pirocheck/attendance/Attendance.js | 16 +++++++++++++++- .../pirocheck/attendance/Attendance.module.css | 8 ++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/pirocheck/attendance/Attendance.js b/frontend/src/pages/pirocheck/attendance/Attendance.js index fe8f350..02afa83 100644 --- a/frontend/src/pages/pirocheck/attendance/Attendance.js +++ b/frontend/src/pages/pirocheck/attendance/Attendance.js @@ -27,6 +27,7 @@ function historyIcon(slots) { function AdminView() { const [code, setCode] = useState(null); const [hasCode, setHasCode] = useState(false); + const [message, setMessage] = useState(''); useEffect(() => { const fetchActiveCode = async () => { @@ -48,7 +49,13 @@ function AdminView() { const res = await authFetch('/api/admin/attendance/start', { method: 'POST' }); const data = await res.json(); setCode(data.code); - setHasCode(true); + if (data.isSuccess) { + setCode(data.result.code); + setHasCode(true); + setMessage(''); + } else { + setMessage(data.message); + } }; const handleExpire = async () => { @@ -67,6 +74,13 @@ function AdminView() {
))}
+ + {message && ( +
+ {message} +
+ )} +