diff --git a/src/main/java/org/ezcode/codetest/application/community/dto/response/DiscussionResponse.java b/src/main/java/org/ezcode/codetest/application/community/dto/response/DiscussionResponse.java index aa69b8aa..48ae091a 100644 --- a/src/main/java/org/ezcode/codetest/application/community/dto/response/DiscussionResponse.java +++ b/src/main/java/org/ezcode/codetest/application/community/dto/response/DiscussionResponse.java @@ -39,7 +39,10 @@ public record DiscussionResponse( Long replyCount, @Schema(description = "현재 사용자의 추천 상태 (UP, DOWN, NONE)", example = "UP") - VoteType voteStatus + VoteType voteStatus, + + @Schema(description = "로그인한 유저의 해당 토론글 작성 여부", example = "true") + boolean isAuthor ) { @@ -53,7 +56,8 @@ public static DiscussionResponse fromEntity(Discussion discussion) { null, null, null, - null + null, + false ); } @@ -67,7 +71,8 @@ public static DiscussionResponse from(DiscussionQueryResult result) { result.getUpvoteCount(), result.getDownvoteCount(), result.getReplyCount(), - result.getVoteStatus() + result.getVoteStatus(), + result.isAuthor() ); } } diff --git a/src/main/java/org/ezcode/codetest/application/community/dto/response/ReplyResponse.java b/src/main/java/org/ezcode/codetest/application/community/dto/response/ReplyResponse.java index 6e1eb3b7..394630f6 100644 --- a/src/main/java/org/ezcode/codetest/application/community/dto/response/ReplyResponse.java +++ b/src/main/java/org/ezcode/codetest/application/community/dto/response/ReplyResponse.java @@ -42,8 +42,10 @@ public record ReplyResponse( Long childReplyCount, @Schema(description = "현재 사용자의 추천 상태 (UP, DOWN, NONE)", example = "UP") - VoteType voteStatus + VoteType voteStatus, + @Schema(description = "로그인한 유저의 해당 토론글 작성 여부", example = "true") + boolean isAuthor ) { public static ReplyResponse fromEntity(Reply reply) { @@ -62,7 +64,8 @@ public static ReplyResponse fromEntity(Reply reply) { null, null, null, - null + null, + false ); } @@ -78,7 +81,8 @@ public static ReplyResponse from(ReplyQueryResult result) { result.getUpvoteCount(), result.getDownvoteCount(), result.getChildReplyCount(), - result.getVoteStatus() + result.getVoteStatus(), + result.isAuthor() ); } } diff --git a/src/main/java/org/ezcode/codetest/common/security/config/SecurityConfig.java b/src/main/java/org/ezcode/codetest/common/security/config/SecurityConfig.java index d448df25..c0a80af4 100644 --- a/src/main/java/org/ezcode/codetest/common/security/config/SecurityConfig.java +++ b/src/main/java/org/ezcode/codetest/common/security/config/SecurityConfig.java @@ -8,12 +8,10 @@ import org.ezcode.codetest.common.security.util.ExceptionHandlingFilter; import org.ezcode.codetest.common.security.util.JwtFilter; import org.ezcode.codetest.common.security.util.JwtUtil; -import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpMethod; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -38,13 +36,12 @@ @RequiredArgsConstructor @EnableMethodSecurity(prePostEnabled = true) public class SecurityConfig { + private final JwtUtil jwtUtil; private final RedisTemplate redisTemplate; private final CustomOAuth2UserService customOAuth2UserService; //OAuth2.0 서비스 private final CustomSuccessHandler customSuccessHandler; - - @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { JwtFilter jwtFilter = new JwtFilter(jwtUtil, redisTemplate); @@ -81,6 +78,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti SecurityPath.PUBLIC_PATH).permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") //어드민 권한 필요 (문제 생성, 관리 등) .requestMatchers(HttpMethod.GET, + "/api/languages", "/api/problems", "/api/problems/{problemId}", "/api/problems/*/discussions", diff --git a/src/main/java/org/ezcode/codetest/domain/community/dto/DiscussionQueryResult.java b/src/main/java/org/ezcode/codetest/domain/community/dto/DiscussionQueryResult.java index 5525736d..bbc7cec3 100644 --- a/src/main/java/org/ezcode/codetest/domain/community/dto/DiscussionQueryResult.java +++ b/src/main/java/org/ezcode/codetest/domain/community/dto/DiscussionQueryResult.java @@ -30,6 +30,8 @@ public class DiscussionQueryResult { private final VoteType voteStatus; + private final boolean isAuthor; + @QueryProjection public DiscussionQueryResult( Long discussionId, @@ -40,7 +42,7 @@ public DiscussionQueryResult( Long upvoteCount, Long downvoteCount, Long replyCount, - VoteType voteType + VoteType voteType, boolean isAuthor ) { this.discussionId = discussionId; @@ -59,5 +61,7 @@ public DiscussionQueryResult( } else { this.voteStatus = VoteType.DOWN; } + + this.isAuthor = isAuthor; } } diff --git a/src/main/java/org/ezcode/codetest/domain/community/dto/ReplyQueryResult.java b/src/main/java/org/ezcode/codetest/domain/community/dto/ReplyQueryResult.java index 05cac7d1..6eed233c 100644 --- a/src/main/java/org/ezcode/codetest/domain/community/dto/ReplyQueryResult.java +++ b/src/main/java/org/ezcode/codetest/domain/community/dto/ReplyQueryResult.java @@ -32,6 +32,8 @@ public class ReplyQueryResult { private final VoteType voteStatus; + private final boolean isAuthor; + @QueryProjection public ReplyQueryResult( Long replyId, @@ -43,7 +45,8 @@ public ReplyQueryResult( Long upvoteCount, Long downvoteCount, Long childReplyCount, - VoteType voteType + VoteType voteType, + boolean isAuthor ) { this.replyId = replyId; @@ -63,5 +66,7 @@ public ReplyQueryResult( } else { this.voteStatus = VoteType.DOWN; } + + this.isAuthor = isAuthor; } } diff --git a/src/main/java/org/ezcode/codetest/domain/community/exception/CommunityExceptionCode.java b/src/main/java/org/ezcode/codetest/domain/community/exception/CommunityExceptionCode.java index b19b1196..fd48b70b 100644 --- a/src/main/java/org/ezcode/codetest/domain/community/exception/CommunityExceptionCode.java +++ b/src/main/java/org/ezcode/codetest/domain/community/exception/CommunityExceptionCode.java @@ -15,7 +15,8 @@ public enum CommunityExceptionCode implements ResponseCode { REPLY_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 댓글이 존재하지 않습니다."), REPLY_DISCUSSION_MISMATCH(false, HttpStatus.BAD_REQUEST, "해당 댓글이 요청된 토론글에 속하지 않습니다."), - + REPLY_PARENT_ALREADY_EXISTS(false, HttpStatus.BAD_REQUEST, "이미 부모 댓글이 있는 댓글에는 대댓글을 작성할 수 없습니다."), + USER_NOT_AUTHOR(false, HttpStatus.FORBIDDEN, "작성자만 수정/삭제할 수 있습니다."), ; diff --git a/src/main/java/org/ezcode/codetest/domain/community/service/ReplyDomainService.java b/src/main/java/org/ezcode/codetest/domain/community/service/ReplyDomainService.java index 0d166970..01abd06a 100644 --- a/src/main/java/org/ezcode/codetest/domain/community/service/ReplyDomainService.java +++ b/src/main/java/org/ezcode/codetest/domain/community/service/ReplyDomainService.java @@ -29,6 +29,7 @@ public Reply createReply(Discussion discussion, User user, Long parentReplyId, S if (parentReplyId != null) { parentReply = getReplyById(parentReplyId); validateDiscussionMatches(parentReply, discussion); + checkNoExistingParentForReply(parentReply); } Reply reply = Reply.builder() @@ -96,6 +97,7 @@ public void validateDiscussionMatches(Reply reply, Discussion discussion) { if (!reply.isDiscussionMatches(discussion.getId())) { throw new CommunityException(CommunityExceptionCode.REPLY_DISCUSSION_MISMATCH); } + } public void validateIsAuthor(Reply reply, Long userId) { @@ -105,6 +107,13 @@ public void validateIsAuthor(Reply reply, Long userId) { } } + public void checkNoExistingParentForReply(Reply parentReply) { + + if (parentReply.getParent() != null) { + throw new CommunityException(CommunityExceptionCode.REPLY_PARENT_ALREADY_EXISTS); + } + } + public NotificationCreateEvent createReplyNotification(User target, Reply reply) { Long parentReplyId = reply.getParentReplyId(); diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/community/discussion/DiscussionQueryRepositoryImpl.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/community/discussion/DiscussionQueryRepositoryImpl.java index 43f4c187..c86e4fb7 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/community/discussion/DiscussionQueryRepositoryImpl.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/community/discussion/DiscussionQueryRepositoryImpl.java @@ -19,6 +19,7 @@ import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.NumberExpression; @@ -40,11 +41,15 @@ public List findDiscussionIdsByProblemId(Long problemId, String sortBy, Pa NumberExpression bestScore = upvoteCount.subtract(downvoteCount); + long offset = pageable.getOffset(); + int pageNumber = pageable.getPageNumber(); + int pageSize = pageable.getPageSize(); + return jpaQueryFactory .select(discussion.id) .from(discussion) .leftJoin(discussionVote).on(discussionVote.discussion.eq(discussion)) - .where(discussion.problem.id.eq(problemId)) + .where(discussion.problem.id.eq(problemId).and(discussion.isDeleted.isFalse())) .groupBy(discussion.id) .orderBy(getOrderSpecifier(sortBy, bestScore, upvoteCount)) .offset(pageable.getOffset()) @@ -65,12 +70,15 @@ public List findDiscussionsByIds(List discussionIds Expression replyCount = reply.id.countDistinct(); Expression userVoteType; + BooleanExpression isAuthor; if (currentUserId != null) { userVoteType = new CaseBuilder() .when(discussionVote.voter.id.eq(currentUserId)).then(discussionVote.voteType) .otherwise(Expressions.nullExpression(VoteType.class)).max(); + isAuthor = discussion.user.id.eq(currentUserId); } else { userVoteType = Expressions.nullExpression(VoteType.class); + isAuthor = Expressions.FALSE; } return jpaQueryFactory @@ -88,7 +96,8 @@ public List findDiscussionsByIds(List discussionIds upvoteCount, downvoteCount, replyCount, - userVoteType + userVoteType, + isAuthor )) .from(discussion) .join(discussion.user, user) @@ -114,7 +123,7 @@ public Long countByProblemId(Long problemId) { Long count = jpaQueryFactory .select(discussion.count()) .from(discussion) - .where(discussion.problem.id.eq(problemId)) + .where(discussion.problem.id.eq(problemId).and(discussion.isDeleted.isFalse())) .fetchOne(); return count != null ? count : 0L; diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/community/reply/ReplyQueryRepositoryImpl.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/community/reply/ReplyQueryRepositoryImpl.java index 50038023..d573d429 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/community/reply/ReplyQueryRepositoryImpl.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/community/reply/ReplyQueryRepositoryImpl.java @@ -66,12 +66,15 @@ private List findRepliesByCondition(BooleanExpression conditio Expression childReplyCount = childReply.id.count(); Expression userVoteType; + BooleanExpression isAuthor; if (currentUserId != null) { userVoteType = new CaseBuilder() .when(replyVote.voter.id.eq(currentUserId)).then(replyVote.voteType) .otherwise(Expressions.nullExpression(VoteType.class)).max(); + isAuthor = reply.user.id.eq(currentUserId); } else { userVoteType = Expressions.nullExpression(VoteType.class); + isAuthor = Expressions.FALSE; } return jpaQueryFactory @@ -90,13 +93,14 @@ private List findRepliesByCondition(BooleanExpression conditio upvoteCount, downvoteCount, childReplyCount, - userVoteType + userVoteType, + isAuthor )) .from(reply) .join(reply.user, user) .leftJoin(replyVote).on(replyVote.reply.eq(reply)) .leftJoin(childReply).on(childReply.parent.eq(reply)) - .where(condition) + .where(condition.and(reply.isDeleted.isFalse())) .groupBy(reply.id) .orderBy(reply.id.asc()) .offset(pageable.getOffset()) @@ -109,7 +113,7 @@ public Long countByDiscussionId(Long discussionId) { return jpaQueryFactory .select(reply.count()) .from(reply) - .where(reply.discussion.id.eq(discussionId).and(reply.parent.isNull())) + .where(reply.discussion.id.eq(discussionId).and(reply.parent.isNull()).and(reply.isDeleted.isFalse())) .fetchOne(); } @@ -118,7 +122,7 @@ public Long countByParentId(Long parentId) { return jpaQueryFactory .select(reply.count()) .from(reply) - .where(reply.parent.id.eq(parentId)) + .where(reply.parent.id.eq(parentId).and(reply.isDeleted.isFalse())) .fetchOne(); } diff --git a/src/main/java/org/ezcode/codetest/presentation/view/ViewController.java b/src/main/java/org/ezcode/codetest/presentation/view/ViewController.java index 02e064b3..db0e338d 100644 --- a/src/main/java/org/ezcode/codetest/presentation/view/ViewController.java +++ b/src/main/java/org/ezcode/codetest/presentation/view/ViewController.java @@ -50,6 +50,11 @@ public String getGamePage() { return "game-page"; } + @GetMapping("/test/notifications") + public String getNotificationPage() { + return "test-notifications"; + } + @GetMapping("/ezlogin") public String loginPage(){ return "login-page"; diff --git a/src/main/resources/static/html/header.html b/src/main/resources/static/html/header.html index 6bd1efcf..a9b08139 100644 --- a/src/main/resources/static/html/header.html +++ b/src/main/resources/static/html/header.html @@ -6,26 +6,36 @@ /* 반투명 뒤배경 */ .chat-overlay { display: none; - position: fixed; top: 0; left: 0; - width: 100%; height: 100%; - background: rgba(0,0,0,0.4); + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.4); z-index: 9998; } + .chat-overlay.open { display: block; } /* 우측 슬라이드 패널 */ .chat-drawer { - position: fixed; top: 0; right: 0; bottom: 0; - width: 90%; max-width: 800px; + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 90%; + max-width: 800px; background: #1c1e2b; transform: translateX(100%); transition: transform 0.3s ease-out; z-index: 9999; - display: flex; flex-direction: column; - box-shadow: -2px 0 8px rgba(0,0,0,0.3); + display: flex; + flex-direction: column; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3); } + .chat-drawer.open { transform: translateX(0); } @@ -57,16 +67,93 @@ margin-left: 12px; transition: fill 0.3s; } + #joypadIcon:hover { fill: #0f0; } + + #notificationBtn.has-unread svg { + filter: drop-shadow(0 0 4px #f00); + } + + #notificationBtn.has-unread::after { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + background: #f00; + border-radius: 50%; + position: absolute; + top: 2px; + right: 2px; + } + + #notificationBtn { + position: relative; + } + + #notificationModal { + position: absolute; + top: 48px; /* 헤더 아래에 위치 */ + right: 24px; + width: 340px; + max-height: 420px; + background: #181a23; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0,0,0,0.25); + z-index: 10001; + display: none; + padding: 0 0 8px 0; + overflow: hidden; + border: 1px solid #222; + } + + .notification-list { + max-height: 340px; + overflow-y: auto; + padding: 12px 0 0 0; + } + + .notification-item { + padding: 12px 20px 8px 20px; + border-bottom: 1px solid #23242c; + font-size: 15px; + color: #eee; + background: none; + } + .notification-item.unread .message { + color: #00d084; + font-weight: 600; + } + .notification-item:last-child { + border-bottom: none; + } + .notification-item .time { + font-size: 12px; + color: #aaa; + margin-top: 2px; + } + #notificationMoreBtn { + width: 100%; + background: none; + border: none; + color: #aaa; + font-size: 14px; + padding: 10px 0 6px 0; + cursor: pointer; + border-top: 1px solid #23242c; + border-radius: 0 0 16px 16px; + transition: background 0.15s; + } + #notificationMoreBtn:hover { + background: #23242c; + color: #00d084; + }
-
+
-
@@ -133,13 +216,12 @@
- - × - + × + @@ -149,8 +231,8 @@ - // 알람 클릭 이벤트 (필요시 추가 기능 구현) - document.getElementById('notificationBtn').addEventListener('click', () => { - alert('알림 기능은 곧 제공됩니다.'); - }); - \ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/static/js/headerNotification.js b/src/main/resources/static/js/headerNotification.js new file mode 100644 index 00000000..d37be2db --- /dev/null +++ b/src/main/resources/static/js/headerNotification.js @@ -0,0 +1,92 @@ +// 헤더 알림 웹소켓 및 알림 모달 관련 코드 + +let headerStompClient; +let notifications = []; // 알림 목록 (최초 1페이지) +let unreadCount = 0; // 읽지 않은 알림 개수 + +window.connectNotificationWS = function() { + const tokenOnly = (sessionStorage.getItem('accessToken') || '').replace(/^Bearer /, ''); + if (!tokenOnly) return; + const socket = new SockJS(`/ws?token=${encodeURIComponent(tokenOnly)}`); + headerStompClient = Stomp.over(socket); + + headerStompClient.connect({}, () => { + // 실시간 알림 이벤트 구독 + headerStompClient.subscribe('/user/queue/notification', msg => { + const notif = JSON.parse(msg.body); + notifications.unshift(notif); // 새 알림 맨 앞에 추가 + if (!notif.isRead) unreadCount++; + updateNotificationUI(); + }); + + // 알림 목록 구독 (최초 1회) + headerStompClient.subscribe('/user/queue/notifications', msg => { + const data = JSON.parse(msg.body); + notifications = data.content || []; + unreadCount = notifications.filter(n => !n.isRead).length; + updateNotificationUI(); + }); + + // 최초 알림 목록 요청 (REST API) + fetch('/api/notifications?page=0', { + headers: { 'Authorization': sessionStorage.getItem('accessToken') } + }); + }, err => console.error('[STOMP] 연결 오류:', err)); +} + +function updateNotificationUI() { + const btn = document.getElementById('notificationBtn'); + if (!btn) return; + // 빨간 뱃지 표시 + if (unreadCount > 0) { + btn.classList.add('has-unread'); + } else { + btn.classList.remove('has-unread'); + } + // 알림 모달이 열려 있다면 목록도 갱신 + renderNotificationModal(); +} + +function renderNotificationModal() { + const listEl = document.getElementById('notificationList'); + if (!listEl) return; + if (!notifications.length) { + listEl.innerHTML = '
알림이 없습니다.
'; + return; + } + listEl.innerHTML = notifications.map(n => ` +
+
${n.message}
+
${formatTime(n.createdAt)}
+
+ `).join(''); +} + +function formatTime(iso) { + // 간단한 시간 포맷 (예: 10분 전, 하루 전 등) + const d = new Date(iso); + const now = new Date(); + const diff = (now - d) / 1000; + if (diff < 60) return '방금 전'; + if (diff < 3600) return Math.floor(diff/60) + '분 전'; + if (diff < 86400) return Math.floor(diff/3600) + '시간 전'; + return d.toLocaleDateString(); +} + +window.bindHeaderNotificationEvents = function() { + const notifBtn = document.getElementById('notificationBtn'); + if (notifBtn) { + notifBtn.addEventListener('click', () => { + const modal = document.getElementById('notificationModal'); + if (!modal) return; + modal.style.display = (modal.style.display === 'block') ? 'none' : 'block'; + renderNotificationModal(); + }); + } + const moreBtn = document.getElementById('notificationMoreBtn'); + if (moreBtn) { + moreBtn.addEventListener('click', () => { + window.location.href = '/test/notifications'; + }); + } +} diff --git a/src/main/resources/static/js/headerUtils.js b/src/main/resources/static/js/headerUtils.js index b0f40f90..9c2bc3f9 100644 --- a/src/main/resources/static/js/headerUtils.js +++ b/src/main/resources/static/js/headerUtils.js @@ -6,6 +6,10 @@ function loadHeaderWithAuthCheck() { // 헤더 삽입 document.getElementById('header-placeholder').innerHTML = data; + // header가 삽입된 후 connectNotificationWS가 있으면 실행 + if (window.connectNotificationWS) window.connectNotificationWS(); + if (window.bindHeaderNotificationEvents) window.bindHeaderNotificationEvents(); + // 삽입 이후에 요소들 찾아야 함 const accessToken = sessionStorage.getItem("accessToken"); diff --git a/src/main/resources/templates/search-page.html b/src/main/resources/templates/search-page.html index 2709fd46..1c08f425 100644 --- a/src/main/resources/templates/search-page.html +++ b/src/main/resources/templates/search-page.html @@ -5,6 +5,8 @@ EzCode 문제 검색 + + + + + + +
+
+
알림 전체 보기
+
+ +
+ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/test-problems.html b/src/main/resources/templates/test-problems.html index 69871308..45d32e17 100644 --- a/src/main/resources/templates/test-problems.html +++ b/src/main/resources/templates/test-problems.html @@ -1,11 +1,16 @@ + - + EzCode 문제 리스트 - + + + + + -
-
-
-
문제 제목
-
문제 설명이 여기에 표시됩니다.
-
    -
  • 시간 제한: -- ms
  • -
  • 메모리 제한: -- KB
  • -
  • 카테고리: --
  • -
-
- 문제 이미지 -
-
-
-
-

코드 제출

-
-
- - + + +
+
+
- -
-
-
- - +
-
-

채점 결과

-
-
+
+
+

코드 제출

+
+
+ + +
+
+ +
+
+
+ + +
+
+

채점 결과

+
+
+
+
여기에 테스트 결과가 표시됩니다
+
-
여기에 테스트 결과가 표시됩니다
-
-
- - - - - + + + - - - + + async function updateDiscussionVoteUI(discussionId) { + const res = await fetch(`/api/problems/${window.problemId}/discussions/${discussionId}/votes`, { + headers: { + 'Accept': 'application/json', + ...tokenHeader() + } + }); + if (!res.ok) return; + const data = await res.json(); + if (!data.success) return; + const { voteType, upvoteCount, downvoteCount } = data.result; + // 버튼/카운트 갱신 + const item = document.querySelector(`.discussion-item[data-discussion-id="${discussionId}"]`); + if (!item) return; + const upBtn = item.querySelector('.upvote-btn'); + const downBtn = item.querySelector('.downvote-btn'); + const upCount = item.querySelector('.vote-count'); + upBtn.dataset.votestatus = voteType; + downBtn.dataset.votestatus = voteType; + upCount.textContent = upvoteCount; + // 아이콘도 갱신 + upBtn.innerHTML = getUpvoteIcon(voteType === 'UP'); + downBtn.innerHTML = getDownvoteIcon(voteType === 'DOWN'); + } + + async function voteReply(discussionId, replyId, voteType) { + const res = await fetch(`/api/problems/${window.problemId}/discussions/${discussionId}/replies/${replyId}/votes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...tokenHeader() + }, + body: JSON.stringify({ voteType }) + }); + if (res.ok) { + updateReplyVoteUI(discussionId, replyId); + } else { + alert('추천/비추천에 실패했습니다.'); + } + } + + async function updateReplyVoteUI(discussionId, replyId) { + const res = await fetch(`/api/problems/${window.problemId}/discussions/${discussionId}/replies/${replyId}/votes`, { + headers: { + 'Accept': 'application/json', + ...tokenHeader() + } + }); + if (!res.ok) return; + const data = await res.json(); + if (!data.success) return; + const { voteType, upvoteCount, downvoteCount } = data.result; + const item = document.querySelector(`.reply-item[data-reply-id="${replyId}"]`); + if (!item) return; + const upBtn = item.querySelector('.upvote-btn'); + const downBtn = item.querySelector('.downvote-btn'); + const upCount = item.querySelector('.vote-count'); + upBtn.dataset.votestatus = voteType; + downBtn.dataset.votestatus = voteType; + upCount.textContent = upvoteCount; + upBtn.innerHTML = getUpvoteIcon(voteType === 'UP'); + downBtn.innerHTML = getDownvoteIcon(voteType === 'DOWN'); + } + + function showReplyInput(discussionId, parentReplyId, btnEl) { + // 이미 열려있으면 return + const next = btnEl.closest('.comment-actions').nextElementSibling; + if (next && next.classList.contains('reply-input-box')) return; + + const box = document.createElement('div'); + box.className = 'reply-input-box'; + box.innerHTML = ` + +
+ + +
+ `; + btnEl.closest('.comment-actions').after(box); + + // 취소 + box.querySelector('.reply-cancel-btn').onclick = () => box.remove(); + + // 작성 + box.querySelector('.reply-submit-btn').onclick = async () => { + const content = box.querySelector('.reply-input-text').value.trim(); + if (!content) return alert('내용을 입력하세요.'); + const payload = parentReplyId ? { parentReplyId: Number(parentReplyId), content } : { content }; + const token = sessionStorage.getItem('accessToken'); + const res = await fetch(`/api/problems/${window.problemId}/discussions/${discussionId}/replies`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...tokenHeader() + }, + body: JSON.stringify(payload) + }); + if (res.ok) { + if (parentReplyId) { + // 대댓글: 해당 댓글의 대댓글만 새로고침 + const childContainer = document.getElementById(`child-replies-for-${parentReplyId}`); + if (childContainer) { + childContainer.dataset.loaded = 'true'; + const discussionId = btnEl.closest('.discussion-item').dataset.discussionId; + const childReplies = await fetchChildReplies(discussionId, parentReplyId); + renderReplies(childContainer, childReplies, discussionId, 1); + // 펼쳐진 상태로 유지 + childContainer.style.display = 'block'; + // 버튼 텍스트/상태 갱신 + const toggleBtn = btnEl.closest('.reply-item').querySelector('.toggle-child-replies-btn'); + if (toggleBtn) { + toggleBtn.textContent = '대댓글 접기'; + toggleBtn.dataset.open = 'true'; + } + } + } else { + // 댓글: 전체 댓글 새로고침 + const container = document.getElementById(`replies-for-${discussionId}`); + if (container) { + container.dataset.loaded = 'true'; + const replies = await fetchReplies(discussionId); + renderReplies(container, replies, discussionId, 0); + // 펼쳐진 상태로 유지 + container.style.display = 'block'; + // 버튼 텍스트/상태 갱신 + const toggleBtn = btnEl.closest('.discussion-item').querySelector('.toggle-replies-btn'); + if (toggleBtn) { + toggleBtn.textContent = '댓글 접기'; + toggleBtn.dataset.open = 'true'; + } + } + } + box.remove(); + } else { + alert('작성에 실패했습니다.'); + } + }; + } + + function renderDiscussionCreateBox() { + const box = document.getElementById('discussion-create-box'); + box.innerHTML = ` + +
+ + +
+ `; + // 취소: 입력 내용만 비움 + box.querySelector('#discussion-create-cancel').onclick = () => { + box.querySelector('#discussion-create-text').value = ''; + }; + // 작성 + box.querySelector('#discussion-create-submit').onclick = async () => { + const content = box.querySelector('#discussion-create-text').value.trim(); + if (!content) return alert('내용을 입력하세요.'); + const token = sessionStorage.getItem('accessToken'); + const res = await fetch(`/api/problems/${window.problemId}/discussions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...tokenHeader() + }, + body: JSON.stringify({ languageId: 1, content }) + }); + if (res.ok) { + // 성공: 입력창 비우고 목록 새로고침 + box.querySelector('#discussion-create-text').value = ''; + loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best'); + } else { + alert('토론글 작성에 실패했습니다.'); + } + }; + } + + // 페이지 진입 시 항상 렌더링 + document.addEventListener('DOMContentLoaded', () => { + renderDiscussionCreateBox(); + }); + + + + + - + + \ No newline at end of file