From cf5827a93f86d19ae9e4a641c4491e9e9826e42d Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Mon, 7 Jul 2025 12:50:26 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat=20:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EC=A1=B0=ED=9A=8C=20=EB=B6=80=EB=B6=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/test-submit.html | 979 ++++++++++++++---- 1 file changed, 763 insertions(+), 216 deletions(-) diff --git a/src/main/resources/templates/test-submit.html b/src/main/resources/templates/test-submit.html index 684b75ee..ed2d8b14 100644 --- a/src/main/resources/templates/test-submit.html +++ b/src/main/resources/templates/test-submit.html @@ -1,18 +1,13 @@ + - + EzCode 코드 제출 - + - + @@ -20,10 +15,10 @@ - + + rel="stylesheet" /> + -
-
-
-
문제 제목
-
문제 설명이 여기에 표시됩니다.
-
    -
  • 시간 제한: -- ms
  • -
  • 메모리 제한: -- KB
  • -
  • 카테고리: --
  • -
-
-
-
-

코드 제출

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

채점 결과

-
-
+
+
+

코드 제출

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

채점 결과

+
+
+
+
여기에 테스트 결과가 표시됩니다
-
여기에 테스트 결과가 표시됩니다
-
- - - - - + + + - - - + function handleInit(slots) { + const judgeEl = document.getElementById('judgeResult'); + judgeEl.innerHTML = ''; + slots.forEach(s => { + const d = document.createElement('div'); + d.id = `slot-${s.seqId}`; + d.className = 'slot init'; + d.textContent = `[${s.seqId}] 상태: ${s.status}`; + judgeEl.appendChild(d); + }); + } + + function handleCase(data) { + const d = document.getElementById(`slot-${data.seqId}`); + if (!d) return; + d.className = data.isPassed ? 'slot passed' : 'slot failed'; + d.textContent = `[${data.seqId}] ${data.message} (${data.executionTime}ms, ${data.memoryUsage}KB)`; + } + + function handleFinal(res) { + const sum = document.createElement('div'); + sum.className = 'final-summary'; + sum.innerHTML = `최종: ${res.passedCount}/${res.totalCount} 통과 — ${res.message}`; + document.getElementById('judgeResult').appendChild(sum); + } + + async function loadDiscussions(pid, page = 0, size = 10, sortBy) { + sortBy = sortBy || (window.currentDiscussionSortBy || 'best'); + window.currentDiscussionSortBy = sortBy; + const res = await fetch(`/api/problems/${pid}/discussions?page=${page}&size=${size}&sortBy=${sortBy}`); + const data = await res.json(); + if (!data.success) { + document.getElementById('discussion-list').innerHTML = "토론글을 불러오지 못했습니다."; + return; + } + renderDiscussionList(data.result.content); + renderDiscussionPagination(data.result, pid); + } + + function renderDiscussionList(discussions) { + const listEl = document.getElementById('discussion-list'); + if (!discussions.length) { + listEl.innerHTML = "

토론글이 없습니다.

"; + return; + } + listEl.innerHTML = discussions.map(d => { + return ` +
+
+ profile + ${d.userInfo.nickname} +
+
${d.content}
+
+ + ${d.upvoteCount} + + +
+ ${d.replyCount > 0 ? `
` : ''} + +
+ `; + }).join(''); + // 댓글 펼치기 버튼 이벤트 바인딩 + document.querySelectorAll('.toggle-replies-btn').forEach(btn => { + btn.addEventListener('click', async function() { + const discussionId = this.dataset.id; + const container = document.getElementById(`replies-for-${discussionId}`); + const isOpen = this.dataset.open === 'true'; + if (isOpen) { + container.style.display = 'none'; + this.textContent = '댓글 펼치기'; + this.dataset.open = 'false'; + } else { + if (!container.dataset.loaded) { + const replies = await fetchReplies(discussionId); + renderReplies(container, replies, discussionId, 0); + container.dataset.loaded = 'true'; + } + container.style.display = 'block'; + this.textContent = '댓글 접기'; + this.dataset.open = 'true'; + } + }); + }); + } + + // 댓글(1차) 조회 + async function fetchReplies(discussionId) { + const url = `/api/problems/${window.problemId}/discussions/${discussionId}/replies`; + const res = await fetch(url); + const data = await res.json(); + return data.success ? data.result.content : []; + } + // 대댓글(2차) 조회 + async function fetchChildReplies(discussionId, replyId) { + const url = `/api/problems/${window.problemId}/discussions/${discussionId}/replies/${replyId}`; + const res = await fetch(url); + const data = await res.json(); + return data.success ? data.result.content : []; + } + + function renderReplies(container, replies, discussionId, depth) { + container.innerHTML = replies.map(r => { + return ` +
+
+ profile + ${r.userInfo.nickname} +
+
${r.content}
+
+ + ${r.upvoteCount} + + +
+ ${(r.childReplyCount > 0 && depth === 0) ? `
` : ''} + +
+ `; + }).join(''); + // 대댓글 펼치기 버튼 이벤트 바인딩 (대댓글의 대댓글은 없음) + if (depth === 0) { + container.querySelectorAll('.toggle-child-replies-btn').forEach(btn => { + btn.addEventListener('click', async function() { + const replyId = this.dataset.id; + const childContainer = container.querySelector(`#child-replies-for-${replyId}`); + const isOpen = this.dataset.open === 'true'; + if (isOpen) { + childContainer.style.display = 'none'; + this.textContent = '대댓글 펼치기'; + this.dataset.open = 'false'; + } else { + if (!childContainer.dataset.loaded) { + const childReplies = await fetchChildReplies(discussionId, replyId); + renderReplies(childContainer, childReplies, discussionId, 1); + childContainer.dataset.loaded = 'true'; + } + childContainer.style.display = 'block'; + this.textContent = '대댓글 접기'; + this.dataset.open = 'true'; + } + }); + }); + } + } + + function renderDiscussionPagination(pageData, pid) { + const pagEl = document.getElementById('discussion-pagination'); + let html = ''; + const sortBy = window.currentDiscussionSortBy || 'best'; + // 항상 페이지네이션을 보여주되, 이전/다음 버튼은 비활성화 + html += ``; + html += ` ${pageData.number+1} / ${pageData.totalPages > 0 ? pageData.totalPages : 1} `; + html += ``; + // 2페이지 이상이면 버튼 활성화 + if (pageData.totalPages > 1) { + html = ''; + html += ``; + html += ` ${pageData.number+1} / ${pageData.totalPages} `; + html += ``; + } + pagEl.innerHTML = html; + } + + // 추천/비추천 SVG 함수 + function getUpvoteIcon(filled) { + return filled + ? `` + : ``; + } + function getDownvoteIcon(filled) { + return filled + ? `` + : ``; + } + + fetch('/html/header.html') + .then(res => res.text()) + .then(data => { + document.getElementById('header-placeholder').innerHTML = data; + }); + + + + - + + \ No newline at end of file From c50ec4687dd04c87d44de36fc42ede380e4e918e Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Mon, 7 Jul 2025 15:27:20 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat=20:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=ED=86=A0=EB=A1=A0=EA=B8=80=20=EB=B0=8F=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=9E=91=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/config/SecurityConfig.java | 6 +- .../exception/CommunityExceptionCode.java | 3 +- .../community/service/ReplyDomainService.java | 9 + src/main/resources/templates/test-submit.html | 196 +++++++++++++++++- 4 files changed, 206 insertions(+), 8 deletions(-) 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..8271566e 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/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/resources/templates/test-submit.html b/src/main/resources/templates/test-submit.html index ed2d8b14..6c9d57d2 100644 --- a/src/main/resources/templates/test-submit.html +++ b/src/main/resources/templates/test-submit.html @@ -581,6 +581,66 @@ text-decoration: underline; color: #00e09e; } + + .reply-input-box { + margin-top: 12px; + margin-bottom: 4px; + background: #18191c; + border-radius: 6px; + padding: 14px 16px; + border: 1px solid #23272f; + width: 100%; + box-sizing: border-box; + display: block; + } + + .reply-input-text { + width: 100%; + min-height: 60px; + background: #23272f; + color: #eee; + border: 1px solid #333; + border-radius: 4px; + font-size: 15px; + padding: 10px 12px; + resize: vertical; + margin-bottom: 10px; + box-sizing: border-box; + display: block; + } + + .reply-input-box > div { + display: flex; + gap: 10px; + justify-content: flex-end; + } + + .reply-cancel-btn, .reply-submit-btn { + background: none; + border: 1px solid #333; + color: #aaa; + border-radius: 4px; + padding: 4px 14px; + font-size: 13px; + cursor: pointer; + transition: background 0.13s, color 0.13s; + } + .reply-submit-btn { + color: #00d084; + border-color: #00d084; + } + .reply-submit-btn:hover { + background: #00d084; + color: #18191c; + } + .reply-cancel-btn:hover { + background: #23272f; + color: #fff; + } + + .discussion-create-box { + margin-bottom: 18px; + } @@ -605,6 +665,7 @@
+
Sort by: +
+ + +
+ `; + 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(); + }); + fetch('/html/header.html') .then(res => res.text()) .then(data => { From 93d24d8fadc58c57dd37e17c3c9888198b402b54 Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Mon, 7 Jul 2025 18:01:41 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat=20:=20=ED=86=A0=EB=A1=A0=EA=B8=80/?= =?UTF-8?q?=EB=8C=93=EA=B8=80/=EB=8C=80=EB=8C=93=EA=B8=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=82=AD=EC=A0=9C,=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수정, 삭제 권한을 프론트 측에서 알기 위해서 isAuthor 필드 추가 --- .../dto/response/DiscussionResponse.java | 11 +- .../community/dto/response/ReplyResponse.java | 10 +- .../security/config/SecurityConfig.java | 2 +- .../community/dto/DiscussionQueryResult.java | 6 +- .../community/dto/ReplyQueryResult.java | 7 +- .../DiscussionQueryRepositoryImpl.java | 9 +- .../reply/ReplyQueryRepositoryImpl.java | 8 +- src/main/resources/templates/test-submit.html | 299 +++++++++++++++++- 8 files changed, 331 insertions(+), 21 deletions(-) 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 8271566e..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 @@ -36,7 +36,7 @@ @RequiredArgsConstructor @EnableMethodSecurity(prePostEnabled = true) public class SecurityConfig { - + private final JwtUtil jwtUtil; private final RedisTemplate redisTemplate; private final CustomOAuth2UserService customOAuth2UserService; //OAuth2.0 서비스 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/infrastructure/persistence/repository/community/discussion/DiscussionQueryRepositoryImpl.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/community/discussion/DiscussionQueryRepositoryImpl.java index 43f4c187..72e18720 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; @@ -44,7 +45,7 @@ public List findDiscussionIdsByProblemId(Long problemId, String sortBy, Pa .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 +66,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 +92,8 @@ public List findDiscussionsByIds(List discussionIds upvoteCount, downvoteCount, replyCount, - userVoteType + userVoteType, + isAuthor )) .from(discussion) .join(discussion.user, user) 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..c15bb844 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()) diff --git a/src/main/resources/templates/test-submit.html b/src/main/resources/templates/test-submit.html index 6c9d57d2..86c35ade 100644 --- a/src/main/resources/templates/test-submit.html +++ b/src/main/resources/templates/test-submit.html @@ -641,6 +641,53 @@ .discussion-create-box { margin-bottom: 18px; } + + .edit-input-box { + margin-top: 8px; + background: #18191c; + border-radius: 6px; + padding: 12px 14px; + border: 1px solid #23272f; + width: 100%; + box-sizing: border-box; + display: block; + } + .edit-input-text { + width: 100%; + min-height: 60px; + background: #23272f; + color: #eee; + border: 1px solid #333; + border-radius: 4px; + font-size: 15px; + padding: 10px 12px; + resize: vertical; + margin-bottom: 10px; + box-sizing: border-box; + display: block; + } + + .edit-btn, .delete-btn { + background: none; + border: none; + color: #888; + font-size: 13px; + font-weight: 500; + padding: 2px 8px; + border-radius: 4px; + cursor: pointer; + margin-left: 2px; + transition: color 0.13s, background 0.13s; + outline: none; + } + .edit-btn:hover { + color: #00d084; + background: #181f1a; + } + .delete-btn:hover { + color: #ff4d4d; + background: #2a1818; + } @@ -938,7 +985,12 @@

채점 결과

async function loadDiscussions(pid, page = 0, size = 10, sortBy) { sortBy = sortBy || (window.currentDiscussionSortBy || 'best'); window.currentDiscussionSortBy = sortBy; - const res = await fetch(`/api/problems/${pid}/discussions?page=${page}&size=${size}&sortBy=${sortBy}`); + const res = await fetch(`/api/problems/${pid}/discussions?page=${page}&size=${size}&sortBy=${sortBy}`, { + headers: { + 'Accept': 'application/json', + ...tokenHeader() + } + }); const data = await res.json(); if (!data.success) { document.getElementById('discussion-list').innerHTML = "토론글을 불러오지 못했습니다."; @@ -960,14 +1012,19 @@

채점 결과

profile ${d.userInfo.nickname} + + ${d.isAuthor ? ` + + + ` : ''}
${d.content}
- ${d.upvoteCount} - @@ -1004,20 +1061,31 @@

채점 결과

// 댓글(1차) 조회 async function fetchReplies(discussionId) { const url = `/api/problems/${window.problemId}/discussions/${discussionId}/replies`; - const res = await fetch(url); + const res = await fetch(url, { + headers: { + 'Accept': 'application/json', + ...tokenHeader() + } + }); const data = await res.json(); return data.success ? data.result.content : []; } // 대댓글(2차) 조회 async function fetchChildReplies(discussionId, replyId) { const url = `/api/problems/${window.problemId}/discussions/${discussionId}/replies/${replyId}`; - const res = await fetch(url); + const res = await fetch(url, { + headers: { + 'Accept': 'application/json', + ...tokenHeader() + } + }); const data = await res.json(); return data.success ? data.result.content : []; } function renderReplies(container, replies, discussionId, depth) { container.innerHTML = replies.map(r => { + console.log(r.replyId, r.voteStatus); return `
@@ -1026,14 +1094,19 @@

채점 결과

${r.content}
- ${r.upvoteCount} - ${depth === 0 ? `` : ''} + + ${r.isAuthor ? ` + + + ` : ''}
${(r.childReplyCount > 0 && depth === 0) ? `
` : ''} @@ -1097,7 +1170,39 @@

채점 결과

} // 댓글/대댓글 달기 버튼 이벤트 바인딩 - document.addEventListener('click', function(e) { + document.addEventListener('click', async function(e) { + // 댓글/대댓글 추천/비추천 (reply-item 먼저!) + if (e.target.closest('.upvote-btn') && e.target.closest('.reply-item')) { + const btn = e.target.closest('.upvote-btn'); + const replyId = btn.dataset.id; + const discussionId = btn.closest('.discussion-item').dataset.discussionId; + const voteStatus = btn.dataset.votestatus; + await voteReply(discussionId, replyId, voteStatus === 'UP' ? 'NONE' : 'UP'); + return; + } + if (e.target.closest('.downvote-btn') && e.target.closest('.reply-item')) { + const btn = e.target.closest('.downvote-btn'); + const replyId = btn.dataset.id; + const discussionId = btn.closest('.discussion-item').dataset.discussionId; + const voteStatus = btn.dataset.votestatus; + await voteReply(discussionId, replyId, voteStatus === 'DOWN' ? 'NONE' : 'DOWN'); + return; + } + // 토론글 추천/비추천 (discussion-item는 reply-item보다 나중에 체크) + if (e.target.closest('.upvote-btn') && e.target.closest('.discussion-item')) { + const btn = e.target.closest('.upvote-btn'); + const discussionId = btn.dataset.id; + const voteStatus = btn.dataset.votestatus; + await voteDiscussion(discussionId, voteStatus === 'UP' ? 'NONE' : 'UP'); + return; + } + if (e.target.closest('.downvote-btn') && e.target.closest('.discussion-item')) { + const btn = e.target.closest('.downvote-btn'); + const discussionId = btn.dataset.id; + const voteStatus = btn.dataset.votestatus; + await voteDiscussion(discussionId, voteStatus === 'DOWN' ? 'NONE' : 'DOWN'); + return; + } // 댓글 달기 if (e.target.classList.contains('reply-btn') && !e.target.closest('.reply-item')) { const discussionId = e.target.dataset.id; @@ -1109,8 +1214,186 @@

채점 결과

const discussionId = e.target.closest('.discussion-item').dataset.discussionId; showReplyInput(discussionId, replyId, e.target); } + // 토론글/댓글/대댓글 수정 + if (e.target.classList.contains('edit-btn')) { + const item = e.target.closest('.discussion-item, .reply-item'); + const contentEl = item.querySelector('.discussion-content'); + const oldContent = contentEl.textContent; + // 이미 수정창이 열려있으면 return + if (item.querySelector('.edit-input-box')) return; + // textarea + 취소/저장 버튼 + const box = document.createElement('div'); + box.className = 'edit-input-box'; + box.innerHTML = ` + +
+ + +
+ `; + contentEl.style.display = 'none'; + contentEl.after(box); + + // 취소 + box.querySelector('.edit-cancel-btn').onclick = () => { + box.remove(); + contentEl.style.display = ''; + }; + + // 저장 + box.querySelector('.edit-save-btn').onclick = async () => { + const newContent = box.querySelector('.edit-input-text').value.trim(); + if (!newContent) return alert('내용을 입력하세요.'); + let url, method = 'PUT', payload = { languageId: 1, content: newContent }; + if (item.classList.contains('discussion-item')) { + url = `/api/problems/${window.problemId}/discussions/${e.target.dataset.id}`; + } else { + const discussionId = item.closest('.discussion-item').dataset.discussionId; + url = `/api/problems/${window.problemId}/discussions/${discussionId}/replies/${e.target.dataset.id}`; + } + const res = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + ...tokenHeader() + }, + body: JSON.stringify(payload) + }); + if (res.ok) { + // 성공: 목록 새로고침 + if (item.classList.contains('discussion-item')) { + loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best'); + } else { + const discussionId = item.closest('.discussion-item').dataset.discussionId; + const container = document.getElementById(`replies-for-${discussionId}`); + if (container) { + container.dataset.loaded = ''; + const replies = await fetchReplies(discussionId); + renderReplies(container, replies, discussionId, 0); + } + } + } else { + alert('수정에 실패했습니다.'); + } + }; + } + // 삭제 + if (e.target.classList.contains('delete-btn')) { + if (!confirm('정말 삭제하시겠습니까?')) return; + const item = e.target.closest('.discussion-item, .reply-item'); + let url, method = 'DELETE'; + if (item.classList.contains('discussion-item')) { + url = `/api/problems/${window.problemId}/discussions/${e.target.dataset.id}`; + } else { + const discussionId = item.closest('.discussion-item').dataset.discussionId; + url = `/api/problems/${window.problemId}/discussions/${discussionId}/replies/${e.target.dataset.id}`; + } + (async () => { + const res = await fetch(url, { + method, + headers: { + ...tokenHeader() + } + }); + if (res.ok) { + if (item.classList.contains('discussion-item')) { + loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best'); + } else { + const discussionId = item.closest('.discussion-item').dataset.discussionId; + const container = document.getElementById(`replies-for-${discussionId}`); + if (container) { + container.dataset.loaded = ''; + const replies = await fetchReplies(discussionId); + renderReplies(container, replies, discussionId, 0); + } + } + } else { + alert('삭제에 실패했습니다.'); + } + })(); + } }); + async function voteDiscussion(discussionId, voteType) { + const res = await fetch(`/api/problems/${window.problemId}/discussions/${discussionId}/votes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...tokenHeader() + }, + body: JSON.stringify({ voteType }) + }); + if (res.ok) { + updateDiscussionVoteUI(discussionId); + } else { + alert('추천/비추천에 실패했습니다.'); + } + } + + 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 if (btnEl.parentNode.querySelector('.reply-input-box')) return; From fd7c02b695fa1a4bd1a41a928e019eff249e45b6 Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Mon, 7 Jul 2025 18:33:06 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat=20:=20=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84,=20=EB=8C=93=EA=B8=80=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95,=20=ED=8F=B0?= =?UTF-8?q?=ED=8A=B8=20=ED=81=AC=EA=B8=B0=20=EC=88=98=EC=A0=95=20=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DiscussionQueryRepositoryImpl.java | 6 +- .../reply/ReplyQueryRepositoryImpl.java | 4 +- src/main/resources/templates/test-submit.html | 75 +++++++++++++++---- 3 files changed, 69 insertions(+), 16 deletions(-) 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 72e18720..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 @@ -41,6 +41,10 @@ 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) @@ -119,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 c15bb844..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 @@ -113,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(); } @@ -122,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/resources/templates/test-submit.html b/src/main/resources/templates/test-submit.html index 86c35ade..b93714d4 100644 --- a/src/main/resources/templates/test-submit.html +++ b/src/main/resources/templates/test-submit.html @@ -277,7 +277,7 @@ /* 비활성 상태의 밑줄 */ color: #ccc; /* 기본 글자색 */ - font-size: 16px; + font-size: 20px; font-weight: 600; transition: all 0.2s ease-in-out; } @@ -688,6 +688,38 @@ color: #ff4d4d; background: #2a1818; } + + #discussion-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 2px; + margin: 18px 0 0 0; + flex-wrap: wrap; + } + #discussion-pagination button { + background: none; + color: #00d084; + border: 1px solid #222; + border-radius: 4px; + padding: 3px 10px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.13s, color 0.13s; + } + #discussion-pagination button:disabled { + color: #888; + border-color: #333; + background: #181818; + cursor: not-allowed; + } + #discussion-pagination .current { + background: #00d084; + color: #18191c; + border-color: #00d084; + font-weight: 700; + } @@ -1141,19 +1173,34 @@

채점 결과

function renderDiscussionPagination(pageData, pid) { const pagEl = document.getElementById('discussion-pagination'); + const totalPages = pageData.totalPages > 0 ? pageData.totalPages : 1; + const currentPage = pageData.number + 1; // 1-based let html = ''; - const sortBy = window.currentDiscussionSortBy || 'best'; - // 항상 페이지네이션을 보여주되, 이전/다음 버튼은 비활성화 - html += ``; - html += ` ${pageData.number+1} / ${pageData.totalPages > 0 ? pageData.totalPages : 1} `; - html += ``; - // 2페이지 이상이면 버튼 활성화 - if (pageData.totalPages > 1) { - html = ''; - html += ``; - html += ` ${pageData.number+1} / ${pageData.totalPages} `; - html += ``; + + // << (맨앞) + html += ` `; + + // < (이전) + html += ` `; + + // 페이지 숫자 (최대 10개씩) + let start = Math.max(1, currentPage - 4); + let end = Math.min(totalPages, start + 9); + if (end - start < 9) start = Math.max(1, end - 9); + for (let i = start; i <= end; i++) { + if (i === currentPage) { + html += ` `; + } else { + html += ` `; + } } + + // > (다음) + html += ` `; + + // >> (맨뒤) + html += ``; + pagEl.innerHTML = html; } @@ -1396,7 +1443,9 @@

채점 결과

function showReplyInput(discussionId, parentReplyId, btnEl) { // 이미 열려있으면 return - if (btnEl.parentNode.querySelector('.reply-input-box')) 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 = ` From a548e20096379c1235eb94febd107073c0902392 Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Mon, 7 Jul 2025 20:51:49 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat=20:=20=EC=95=8C=EB=A6=BC=20=EC=9B=B9?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/html/header.html | 154 ++++++++++++++---- .../resources/static/js/headerNotification.js | 112 +++++++++++++ src/main/resources/static/js/headerUtils.js | 4 + src/main/resources/templates/search-page.html | 1 + src/main/resources/templates/test-main.html | 3 + src/main/resources/templates/test-mypage.html | 1 + .../resources/templates/test-problems.html | 1 + .../resources/templates/test-ranking.html | 1 + src/main/resources/templates/test-submit.html | 5 + 9 files changed, 246 insertions(+), 36 deletions(-) create mode 100644 src/main/resources/static/js/headerNotification.js 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..1c50a42e --- /dev/null +++ b/src/main/resources/static/js/headerNotification.js @@ -0,0 +1,112 @@ +// 헤더 알림 웹소켓 및 알림 모달 관련 코드 + +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 = '/notifications'; + }); + } +} + +document.addEventListener('DOMContentLoaded', () => { + // 알림 버튼 클릭 이벤트 + const notifBtn = document.getElementById('notificationBtn'); + if (notifBtn) { + notifBtn.addEventListener('click', () => { + console.log('notifBtn : ' + notifBtn); + 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 = '/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 e6a0394a..0ae51a24 100644 --- a/src/main/resources/templates/search-page.html +++ b/src/main/resources/templates/search-page.html @@ -309,6 +309,7 @@

문제 검색

+ diff --git a/src/main/resources/templates/test-main.html b/src/main/resources/templates/test-main.html index 91081bac..612dbe44 100644 --- a/src/main/resources/templates/test-main.html +++ b/src/main/resources/templates/test-main.html @@ -7,6 +7,8 @@ + +
@@ -27,6 +29,7 @@ + + diff --git a/src/main/resources/templates/test-problems.html b/src/main/resources/templates/test-problems.html index 69871308..892bf252 100644 --- a/src/main/resources/templates/test-problems.html +++ b/src/main/resources/templates/test-problems.html @@ -473,6 +473,7 @@

문제 리스트

+ + + + + + + + +
+
+
알림 전체 보기
+
+ +
+ + + + + \ 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 892bf252..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
  • -
  • 카테고리: --
  • -
-
+
+ + +
-
-
-
- Sort by: - +
+
문제 제목
+
문제 설명이 여기에 표시됩니다.
+
    +
  • 시간 제한: -- ms
  • +
  • 메모리 제한: -- KB
  • +
  • 카테고리: --
  • +
-
-
-
-
-
-
-

코드 제출

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

채점 결과

-
-
+
+
+

코드 제출

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

채점 결과

+
+
+
+
여기에 테스트 결과가 표시됩니다
+
-
여기에 테스트 결과가 표시됩니다
-
-
- - - - - + + + - - - - + // 페이지 진입 시 항상 렌더링 + document.addEventListener('DOMContentLoaded', () => { + renderDiscussionCreateBox(); + }); + + + + + - + + \ No newline at end of file