This commit is contained in:
2025-09-08 18:21:57 +09:00
parent 51b97e2422
commit d62a4a3c15
5 changed files with 389 additions and 29 deletions
+73
View File
@@ -462,4 +462,77 @@ a.btn_layerClose:hover {
}
#selected-category-area i, #selected-hashtags-area i {
color: #999;
}
/* === 대댓글 기능 CSS (신규 추가) === */
/* 1. 대댓글 목록 컨테이너 (들여쓰기) */
.reply-list {
margin-left: 40px;
padding-left: 15px;
border-left: 2px solid #eee;
}
/* 2. 댓글 헤더 (작성자/날짜/답글 버튼) */
.comment-header {
border-bottom: 1px dotted #ddd;
padding-bottom: 5px;
margin-bottom: 10px;
/* Flexbox를 사용해 요소를 양쪽으로 정렬 */
display: flex;
justify-content: space-between;
align-items: center;
}
.comment-author-info {
font-size: 0.9em;
}
.comment-author-info strong {
margin-right: 8px;
}
.comment-author-info .comment-date {
font-size: 0.9em;
color: #888;
}
/* 3. 답글 달기 버튼 */
.btn-reply {
font-size: 0.8em;
padding: 3px 8px;
cursor: pointer;
border: 1px solid #ccc;
background: #f9f9f9;
border-radius: 4px;
color: #555;
}
.btn-reply:hover {
background: #eee;
}
/* 4. 답글 상태 표시줄 (숨겨진 UI) */
#reply-status-bar {
display: none; /* JS로 제어 */
background: #f0f8ff;
border: 1px solid #bde0ff;
padding: 8px 12px;
margin-bottom: 10px;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
#reply-status-text {
font-size: 0.9em;
color: #333;
font-weight: 500;
}
#btn-cancel-reply {
background: none;
border: none;
color: #E63946; /* 빨간색 계열 */
cursor: pointer;
font-size: 0.85em;
font-weight: bold;
}
+233 -6
View File
@@ -10,23 +10,31 @@
var stagedCategory = 'none';
var stagedHashtags = []; // 해시태그는 배열로 관리
var currentReplyParentId = null;
// 전역 변수: Quill 에디터 인스턴스와 게시물 기본 데이터를 저장합니다.
var quill = null;
var currentLat = 0.0;
var currentLon = 0.0;
var baseData = {
'id': "",
'originId': "",
'title': "",
'content': "",
'category': "none",
'tags': "",
'writeTime': 0,
'modifyTime': 0,
'firstPostLat': 0.0,
'firstPostLon': 0.0,
'firstAddress': "",
'modifyLat': 0.0,
'modifyLon': 0.0,
'originId': "",
'writeTime': 0,
'modifyAddress': "",
'writer': "",
'posting': false,
'readCount': 0,
'voteCount': 0,
'unlikeCount': 0
};
// jQuery를 사용하여 문서가 완전히 로드된 후에 함수를 실행합니다.
@@ -59,7 +67,11 @@ $(document).ready(function() {
openPopup(this);
});
/* === (대규모 수정) 팝업 입력/적용/취소 버튼 로직 === */
/* === (신규 추가) 댓글 등록 버튼 이벤트 리스너 === */
$('#submit-comment').on('click', function(e) {
e.preventDefault(); // 기본 버튼 동작 방지
submitComment(); // 아래에 정의된 새 함수 호출
});
// --- 1. Category Popup Logic ---
const categoryInput = document.getElementById('category-input');
const addCategoryBtn = document.getElementById('add-category-btn');
@@ -159,6 +171,19 @@ function initEditor(useEditor = false) {
baseData.firstPostLon = serverData.firstPostLon;
baseData.writeTime = serverData.writeTime;
baseData.originId = serverData.originId;
// === [버그 수정] 누락된 메타데이터 복사 로직 추가 ===
baseData.modifyTime = serverData.modifyTime;
baseData.firstAddress = serverData.firstAddress;
baseData.modifyLat = serverData.modifyLat;
baseData.modifyLon = serverData.modifyLon;
baseData.modifyAddress = serverData.modifyAddress;
baseData.writer = serverData.writer;
baseData.posting = serverData.posting;
baseData.readCount = serverData.readCount;
baseData.voteCount = serverData.voteCount;
baseData.unlikeCount = serverData.unlikeCount;
// ===============================================
}
getLocation();
@@ -718,9 +743,26 @@ function post(target, type, data, key, callBackResult) {
};
httpRequest.open('POST', target, true);
httpRequest.setRequestHeader("Content-Type", "text/plain");
httpRequest.send(btoa(JSON.stringify({
const csrfMeta = document.querySelector('meta[name="_csrf"]');
if (csrfMeta) {
const csrfToken = csrfMeta.getAttribute('content');
if (csrfToken) {
// Spring Security는 이 헤더를 확인합니다.
httpRequest.setRequestHeader("X-CSRF-TOKEN", csrfToken);
}
}
// 1. Create the complete JSON string payload
const jsonPayloadString = JSON.stringify({
'data': unformat(type, data, key), 'key': key, 'type': type,
})));
});
// 2. [FIX] Convert the Unicode JSON string (which may contain Korean) into
// a UTF-8 byte stream that btoa() can safely handle.
const utf8SafePayload = unescape(encodeURIComponent(jsonPayloadString));
// 3. Send the result after running btoa() on the safe string.
httpRequest.send(btoa(utf8SafePayload));
}
function postLogin(target, type, data, key, callBackResult) {
@@ -924,4 +966,189 @@ function handleVote(buttonElement, voteType) {
controls.querySelectorAll('button').forEach(btn => btn.disabled = false);
});
}
/**
* [수정됨] 댓글 등록 처리 함수 (대댓글 지원)
*/
function submitComment() {
const commentInput = document.getElementById('comment-input');
if (!commentInput) return;
const content = commentInput.value.trim();
if (content.length === 0) {
alert('댓글 내용을 입력하세요.');
commentInput.focus();
return;
}
// [수정] parentId를 하드코딩(null)하는 대신 전역 변수에서 읽어옴
const commentData = {
content: content,
writer: null,
parentId: currentReplyParentId, // currentReplyParentId 값 (null 또는 댓글ID) 사용
postId: null,
id: null
};
const postId = serverData.id;
if (!postId) {
alert("게시물 ID를 찾을 수 없어 댓글을 등록할 수 없습니다.");
return;
}
const uploadUrl = `${getMainPath()}/blog/posts/${postId}/comments.bjx`;
const encType = serverData.enc;
const keyword = serverData.keyword;
try {
post(uploadUrl, encType, JSON.stringify(commentData), keyword, function(resultData) {
try {
const response = JSON.parse(resultData);
if (response.resultCode === 0) {
alert('댓글이 성공적으로 등록되었습니다.');
commentInput.value = ''; // 입력창 초기화
cancelReply(); // [신규 추가] 답글 상태 초기화
fetchComments(postId); // 목록 새로고침
} else {
alert('댓글 등록 실패: ' + (response.resultMsg || '알 수 없는 오류'));
}
} catch (e) {
console.error('Failed to parse comment submission response:', e, resultData);
alert('댓글 등록 후 서버 응답을 처리하는 중 오류가 발생했습니다.');
}
});
} catch (err) {
console.error('Error during comment submission:', err);
alert('댓글 전송 중 예외가 발생했습니다.');
}
}
async function fetchComments(postId) {
if (!postId) return;
const commentsListContainer = document.getElementById('comments-list');
if (!commentsListContainer) return;
commentsListContainer.innerHTML = '<p style="text-align: center; color: #888;">댓글 목록을 불러오는 중...</p>';
try {
// 1. 최상위 댓글 목록 (ParentId = null)을 먼저 가져옵니다.
const parentResponse = await fetch(`${getMainPath()}/blog/posts/${postId}/comments.bjx`);
const parentData = await parentResponse.json();
if (parentData.resultCode !== 0 || !parentData.comments) {
throw new Error(parentData.resultMsg || 'Failed to load parent comments');
}
if (parentData.comments.length === 0) {
commentsListContainer.innerHTML = '<p style="text-align: center; color: #888;">아직 댓글이 없습니다. 첫 댓글을 작성해보세요.</p>';
return;
}
// 컨테이너 비우기
commentsListContainer.innerHTML = '';
// 2. 각 최상위 댓글을 순회합니다 (for...of 루프 사용 필수)
for (const parentComment of parentData.comments) {
// 2-1. 최상위 댓글 HTML 생성 및 DOM에 추가
const parentElement = document.createElement('article');
parentElement.className = 'comment-item';
parentElement.id = `comment-${parentComment.id}`;
parentElement.innerHTML = createCommentHTML(parentComment); // 헬퍼 함수 사용
commentsListContainer.appendChild(parentElement);
// 2-2. 해당 댓글의 "답글 API"를 호출합니다.
const replyResponse = await fetch(`${getMainPath()}/blog/comments/${parentComment.id}/replies.bjx`);
const replyData = await replyResponse.json();
if (replyData.resultCode === 0 && replyData.comments && replyData.comments.length > 0) {
// 2-3. 답글 컨테이너(div) 생성
const replyListContainer = document.createElement('div');
replyListContainer.className = 'reply-list'; // CSS 들여쓰기 적용
// 2-4. 모든 답글(대댓글)을 순회하며 HTML 추가
replyData.comments.forEach(childComment => {
replyListContainer.innerHTML += createCommentHTML(childComment, true); // 헬퍼 함수 사용 (isReply=true)
});
// 2-5. 답글 컨테이너를 부모 댓글(parentElement)의 자식으로 삽입
parentElement.appendChild(replyListContainer);
}
} // end for loop
} catch (err) {
console.error('Failed to fetch comments hierarchicaly:', err);
commentsListContainer.innerHTML = '<p>댓글 로딩 중 오류가 발생했습니다.</p>';
}
}
/**
* [신규 추가] 댓글 객체를 받아 HTML 문자열을 생성하는 헬퍼 함수
* @param {object} comment - 댓글 객체
* @param {boolean} isReply - 대댓글 여부 (대댓글에는 "답글달기" 버튼 숨김. 원한다면 true로 변경)
* @returns {string} - 완성된 HTML 문자열
*/
function createCommentHTML(comment, isReply = false) {
const writerName = comment.writer || 'Anonymous';
const date = new Date(comment.writeTime);
const formattedDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
// JS에서 XSS를 방지하기 위해 특수문자를 HTML 엔티티로 치환 (간단 버전)
const safeContent = String(comment.content).replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\n/g, "<br>");
const writerNameForReply = String(writerName).replace(/'/g, "\\'"); // ' 문자가 JS를 깨트리는 것을 방지
// 참고: 현재 로직은 대댓글의 대댓글(3단계)은 지원하지 않습니다. (isReply = true이면 답글 버튼 생성 안 함)
// 3단계 이상을 지원하려면 isReply 체크를 제거하고, 답글 API가 대댓글도 정상적으로 가져오는지 확인해야 합니다.
const replyButtonHTML = !isReply
? `<button class="btn-reply" onclick="setReplyTarget('${comment.id}', '${writerNameForReply}')">답글</button>`
: ''; // 대댓글에는 "답글" 버튼 표시 안 함
return `
<div class="comment-header">
<div class="comment-author-info">
<strong>${writerName}</strong>
<span class="comment-date">${formattedDate}</span>
</div>
${replyButtonHTML}
</div>
<p style="padding: 5px 0 15px 5px; min-height: 2em;">${safeContent}</p>
`;
}
/**
* [신규 추가] "답글 달기" 버튼 클릭 시 호출되는 헬퍼 함수
* @param {string} commentId - 부모가 될 댓글의 ID
* @param {string} writerName - 부모 댓글 작성자명
*/
function setReplyTarget(commentId, writerName) {
currentReplyParentId = commentId; // 전역 변수(상태) 설정
// UI 업데이트
const statusBar = document.getElementById('reply-status-bar');
const statusText = document.getElementById('reply-status-text');
const commentInput = document.getElementById('comment-input');
if (statusBar && statusText) {
statusText.innerText = `@${writerName} 님에게 답글 다는 중...`;
statusBar.style.display = 'flex'; // 숨겨둔 상태바 표시
}
commentInput.focus(); // 입력창으로 포커스 이동
}
/**
* [신규 추가] 답글 달기 "취소" 시 호출되는 헬퍼 함수
*/
function cancelReply() {
currentReplyParentId = null; // 상태 초기화
const statusBar = document.getElementById('reply-status-bar');
if (statusBar) {
statusBar.style.display = 'none'; // 상태바 숨기기
}
}
/* ============================================= */
@@ -9,7 +9,10 @@
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(false)});</script>
<script>document.addEventListener('DOMContentLoaded', function() {
initEditor(false)
fetchComments(serverData.id);
});</script>
</th:block>
<th:block layout:fragment="content" id="content">
@@ -85,6 +88,10 @@
<section class="comment-section">
<h2>Comments</h2>
<div id="comment-form-container">
<div id="reply-status-bar" style="display: none;">
<span id="reply-status-text"></span>
<button id="btn-cancel-reply" onclick="cancelReply()">X 취소</button>
</div>
<textarea id="comment-input" placeholder="댓글을 입력하세요..."></textarea>
<button id="submit-comment" class="button">등록</button>
</div>