...
This commit is contained in:
parent
51b97e2422
commit
d62a4a3c15
@ -75,17 +75,22 @@ class SecurityConfig(
|
|||||||
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
|
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
|
||||||
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
|
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
|
||||||
// "/blog/post/images/**", // WebSecurityCustomizer에서 이미 ignoring 처리됨
|
// "/blog/post/images/**", // WebSecurityCustomizer에서 이미 ignoring 처리됨
|
||||||
"/puzzle/**","/puzzle/play/**",
|
"/puzzle/**","/puzzle/play/**","/bums/save/**",
|
||||||
"/rank/**","/spider/**",
|
"/rank/**","/spider/**",
|
||||||
"/sudoku/**",
|
"/sudoku/**",
|
||||||
)
|
)
|
||||||
}.authorizeHttpRequests { auth ->
|
}.authorizeHttpRequests { auth ->
|
||||||
auth
|
auth
|
||||||
// [정상 유지] 이 두 엔드포인트는 인증(로그인)이 필요하며 CSRF 보호를 받아야 합니다.
|
// [정상 유지] 이 두 엔드포인트는 인증(로그인)이 필요하며 CSRF 보호를 받아야 합니다.
|
||||||
|
.requestMatchers(HttpMethod.GET, "/blog/comments/{commentId}/replies.bjx").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/blog/posts/{postId}/comments.bjx").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/blog/posts/{postId}/comments.bjx").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/like.bjx").permitAll()
|
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/like.bjx").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/unlike.bjx").permitAll()
|
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/unlike.bjx").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/bums/save/loc.api").permitAll()
|
||||||
// permitAll() 목록
|
// permitAll() 목록
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
|
|
||||||
"/",
|
"/",
|
||||||
"/home.bs",
|
"/home.bs",
|
||||||
"/bums/where.bs" ,
|
"/bums/where.bs" ,
|
||||||
|
|||||||
@ -118,6 +118,7 @@ class BlogController(private val commentService : CommentService) {
|
|||||||
try {
|
try {
|
||||||
val authentication = SecurityContextHolder.getContext().authentication
|
val authentication = SecurityContextHolder.getContext().authentication
|
||||||
val username = (authentication.principal as? UserDetails)?.username ?: authentication.name
|
val username = (authentication.principal as? UserDetails)?.username ?: authentication.name
|
||||||
|
logService.log(username)
|
||||||
target.writer = username
|
target.writer = username
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
target.writer = "Anonymous" // 인증 정보 가져오기 실패 시
|
target.writer = "Anonymous" // 인증 정보 가져오기 실패 시
|
||||||
@ -144,12 +145,23 @@ class BlogController(private val commentService : CommentService) {
|
|||||||
} else {
|
} else {
|
||||||
// === B. 기존 게시물 수정 (새 버전 생성) 로직 ===
|
// === B. 기존 게시물 수정 (새 버전 생성) 로직 ===
|
||||||
// (글쓴이는 client-sent 'target' 객체에 이미 포함되어 있으므로 별도 설정 필요 없음)
|
// (글쓴이는 client-sent 'target' 객체에 이미 포함되어 있으므로 별도 설정 필요 없음)
|
||||||
|
if (target.writer.isNullOrEmpty()) {
|
||||||
|
try {
|
||||||
|
val authentication = SecurityContextHolder.getContext().authentication
|
||||||
|
val username = (authentication.principal as? UserDetails)?.username ?: authentication.name
|
||||||
|
logService.log(username)
|
||||||
|
target.writer = username
|
||||||
|
} catch (e: Exception) {
|
||||||
|
target.writer = "Anonymous" // 인증 정보 가져오기 실패 시
|
||||||
|
}
|
||||||
|
}
|
||||||
// 3. (정상 로직) 이 객체에 "수정 시간"을 설정합니다.
|
// 3. (정상 로직) 이 객체에 "수정 시간"을 설정합니다.
|
||||||
target.modifyTime = System.currentTimeMillis()
|
target.modifyTime = System.currentTimeMillis()
|
||||||
|
|
||||||
// 4. 이 게시물이 "버전 기록용 사본"임을 설정합니다.
|
// 4. 이 게시물이 "버전 기록용 사본"임을 설정합니다.
|
||||||
target.originId = target.id // 원본 마스터 ID를 originId 필드에 저장
|
if (target.originId == null) {
|
||||||
|
target.originId = target.id
|
||||||
|
}
|
||||||
target.id = null // Mongo가 새 ID를 생성하도록 ID를 null로 변경
|
target.id = null // Mongo가 새 ID를 생성하도록 ID를 null로 변경
|
||||||
|
|
||||||
// 5. 모든 데이터(새 카테고리, 태그, modifyTime 포함)가 담긴 "새 버전 문서"를 저장합니다.
|
// 5. 모든 데이터(새 카테고리, 태그, modifyTime 포함)가 담긴 "새 버전 문서"를 저장합니다.
|
||||||
@ -613,30 +625,66 @@ class BlogController(private val commentService : CommentService) {
|
|||||||
@PostMapping("posts/{postId}/comments.bjx")
|
@PostMapping("posts/{postId}/comments.bjx")
|
||||||
fun addComment(@PathVariable postId: String, @RequestBody jsonString: String): Mono<ResponseEntity<ResponceResult>> {
|
fun addComment(@PathVariable postId: String, @RequestBody jsonString: String): Mono<ResponseEntity<ResponceResult>> {
|
||||||
try {
|
try {
|
||||||
|
// 1. Decode the Base64 string to get the envelope JSON
|
||||||
val decodedBytes: ByteArray = Base64.getDecoder().decode(jsonString)
|
val decodedBytes: ByteArray = Base64.getDecoder().decode(jsonString)
|
||||||
val requestData = String(decodedBytes)
|
|
||||||
val comment = Gson().fromJson(requestData, Comment::class.java).apply {
|
|
||||||
this.postId = postId
|
|
||||||
this.writeTime = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
|
|
||||||
return commentService.addComment(comment)
|
// 2. Parse the ENVELOPE (RequestModel), just like in your "post.bjx" endpoint
|
||||||
.map {
|
Gson().fromJson(String(decodedBytes), RequestModel::class.java)?.let { model ->
|
||||||
ResponseEntity.ok().body(ResponceResult().apply {
|
model.data?.let { innerJsonString ->
|
||||||
resultCode = 0
|
try {
|
||||||
resultMsg = "Comment submitted successfully"
|
// 3. COPY the EXACT decryption/un-formatting logic from "post.bjx"
|
||||||
})
|
val reqString = innerJsonString.split(GlobalEnvironment.padding(model.getKeyword()))
|
||||||
|
val nb = arrayListOf<String>()
|
||||||
|
val na = arrayListOf<String>()
|
||||||
|
reqString[0].replace(GlobalEnvironment.padding(model.getKeyword()),"").split("").toList().let { na.addAll(it) }
|
||||||
|
reqString[1].replace(GlobalEnvironment.padding(model.getKeyword()),"").split("").toList().let { nb.addAll(it) }
|
||||||
|
var max = nb.size + na.size
|
||||||
|
var fullData = arrayListOf<String>()
|
||||||
|
for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } }
|
||||||
|
|
||||||
|
val jsonFromClient = fullData.joinToString("") // This is the REAL comment JSON
|
||||||
|
|
||||||
|
// 4. NOW, parse the decrypted JSON into the Comment object
|
||||||
|
val comment = Gson().fromJson(jsonFromClient, Comment::class.java).apply {
|
||||||
|
this.postId = postId
|
||||||
|
this.writeTime = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Save the valid comment object
|
||||||
|
return commentService.addComment(comment)
|
||||||
|
.map {
|
||||||
|
ResponseEntity.ok().body(ResponceResult().apply {
|
||||||
|
resultCode = 0
|
||||||
|
resultMsg = "Comment submitted successfully"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.onErrorResume { e ->
|
||||||
|
Mono.just(ResponseEntity.status(500).body(ResponceResult().apply {
|
||||||
|
resultCode = 500
|
||||||
|
resultMsg = "Error submitting comment: ${e.message}"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// This catches decryption errors
|
||||||
|
return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply {
|
||||||
|
resultCode = 400
|
||||||
|
resultMsg = "Invalid request data (decryption fail)"
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onErrorResume { e ->
|
}
|
||||||
Mono.just(ResponseEntity.status(500).body(ResponceResult().apply {
|
// This catches envelope parsing errors
|
||||||
resultCode = 500
|
|
||||||
resultMsg = "Error submitting comment: ${e.message}"
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply {
|
return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply {
|
||||||
resultCode = 400
|
resultCode = 400
|
||||||
resultMsg = "Invalid request data"
|
resultMsg = "Invalid request data (model fail)"
|
||||||
|
}))
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// This catches Base64 decoding errors
|
||||||
|
return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply {
|
||||||
|
resultCode = 400
|
||||||
|
resultMsg = "Invalid request data (base64 fail)"
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -463,3 +463,76 @@ a.btn_layerClose:hover {
|
|||||||
#selected-category-area i, #selected-hashtags-area i {
|
#selected-category-area i, #selected-hashtags-area i {
|
||||||
color: #999;
|
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;
|
||||||
|
}
|
||||||
@ -10,23 +10,31 @@
|
|||||||
|
|
||||||
var stagedCategory = 'none';
|
var stagedCategory = 'none';
|
||||||
var stagedHashtags = []; // 해시태그는 배열로 관리
|
var stagedHashtags = []; // 해시태그는 배열로 관리
|
||||||
|
var currentReplyParentId = null;
|
||||||
// 전역 변수: Quill 에디터 인스턴스와 게시물 기본 데이터를 저장합니다.
|
// 전역 변수: Quill 에디터 인스턴스와 게시물 기본 데이터를 저장합니다.
|
||||||
var quill = null;
|
var quill = null;
|
||||||
var currentLat = 0.0;
|
var currentLat = 0.0;
|
||||||
var currentLon = 0.0;
|
var currentLon = 0.0;
|
||||||
var baseData = {
|
var baseData = {
|
||||||
'id': "",
|
'id': "",
|
||||||
|
'originId': "",
|
||||||
'title': "",
|
'title': "",
|
||||||
'content': "",
|
'content': "",
|
||||||
'category': "none",
|
'category': "none",
|
||||||
'tags': "",
|
'tags': "",
|
||||||
|
'writeTime': 0,
|
||||||
|
'modifyTime': 0,
|
||||||
'firstPostLat': 0.0,
|
'firstPostLat': 0.0,
|
||||||
'firstPostLon': 0.0,
|
'firstPostLon': 0.0,
|
||||||
|
'firstAddress': "",
|
||||||
'modifyLat': 0.0,
|
'modifyLat': 0.0,
|
||||||
'modifyLon': 0.0,
|
'modifyLon': 0.0,
|
||||||
'originId': "",
|
'modifyAddress': "",
|
||||||
'writeTime': 0,
|
'writer': "",
|
||||||
|
'posting': false,
|
||||||
|
'readCount': 0,
|
||||||
|
'voteCount': 0,
|
||||||
|
'unlikeCount': 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// jQuery를 사용하여 문서가 완전히 로드된 후에 함수를 실행합니다.
|
// jQuery를 사용하여 문서가 완전히 로드된 후에 함수를 실행합니다.
|
||||||
@ -59,7 +67,11 @@ $(document).ready(function() {
|
|||||||
openPopup(this);
|
openPopup(this);
|
||||||
});
|
});
|
||||||
/* === (대규모 수정) 팝업 입력/적용/취소 버튼 로직 === */
|
/* === (대규모 수정) 팝업 입력/적용/취소 버튼 로직 === */
|
||||||
|
/* === (신규 추가) 댓글 등록 버튼 이벤트 리스너 === */
|
||||||
|
$('#submit-comment').on('click', function(e) {
|
||||||
|
e.preventDefault(); // 기본 버튼 동작 방지
|
||||||
|
submitComment(); // 아래에 정의된 새 함수 호출
|
||||||
|
});
|
||||||
// --- 1. Category Popup Logic ---
|
// --- 1. Category Popup Logic ---
|
||||||
const categoryInput = document.getElementById('category-input');
|
const categoryInput = document.getElementById('category-input');
|
||||||
const addCategoryBtn = document.getElementById('add-category-btn');
|
const addCategoryBtn = document.getElementById('add-category-btn');
|
||||||
@ -159,6 +171,19 @@ function initEditor(useEditor = false) {
|
|||||||
baseData.firstPostLon = serverData.firstPostLon;
|
baseData.firstPostLon = serverData.firstPostLon;
|
||||||
baseData.writeTime = serverData.writeTime;
|
baseData.writeTime = serverData.writeTime;
|
||||||
baseData.originId = serverData.originId;
|
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();
|
getLocation();
|
||||||
@ -718,9 +743,26 @@ function post(target, type, data, key, callBackResult) {
|
|||||||
};
|
};
|
||||||
httpRequest.open('POST', target, true);
|
httpRequest.open('POST', target, true);
|
||||||
httpRequest.setRequestHeader("Content-Type", "text/plain");
|
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,
|
'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) {
|
function postLogin(target, type, data, key, callBackResult) {
|
||||||
@ -924,4 +966,189 @@ function handleVote(buttonElement, voteType) {
|
|||||||
controls.querySelectorAll('button').forEach(btn => btn.disabled = false);
|
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, "<").replace(/>/g, ">").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" />
|
<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@2/dist/quill.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.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>
|
||||||
|
|
||||||
<th:block layout:fragment="content" id="content">
|
<th:block layout:fragment="content" id="content">
|
||||||
@ -85,6 +88,10 @@
|
|||||||
<section class="comment-section">
|
<section class="comment-section">
|
||||||
<h2>Comments</h2>
|
<h2>Comments</h2>
|
||||||
<div id="comment-form-container">
|
<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>
|
<textarea id="comment-input" placeholder="댓글을 입력하세요..."></textarea>
|
||||||
<button id="submit-comment" class="button">등록</button>
|
<button id="submit-comment" class="button">등록</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user