/**
* =================================================================================
* common.js - 블로그 공통 스크립트 (최종 수정본)
* - Quill 에디터 초기화 및 제어 (편집/읽기 모드)
* - 게시물 데이터 관리 (baseData) 및 서버 통신 (save, post)
* - UI 제어 (팝업, 컨트롤 박스 동적 설정)
* - 페이지 이동 및 로그인/로그아웃, 유틸리티 함수
* =================================================================================
*/
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,
'modifyAddress': "",
'writer': "",
'posting': false,
'readCount': 0,
'voteCount': 0,
'unlikeCount': 0
};
// jQuery를 사용하여 문서가 완전히 로드된 후에 함수를 실행합니다.
$(document).ready(function() {
// 뷰어/에디터 페이지가 아닐 수 있으므로, #editor 요소가 있을 때만 initEditor를 호출하도록 방어 코드를 추가하는 것이 좋습니다.
// 현재는 각 페이지에서 직접 호출하므로 이 코드는 참고용입니다.
// 사이드바의 인기글/최신글 목록을 가져옵니다.
if (document.querySelector(".rank_of_view")) {
fetchRankOfViews();
}
if (document.querySelector(".recent_posts")) {
fetchRecentPosts();
}
// 팝업 닫기 버튼 이벤트
$('.btn_layerClose').on('click', function(e) {
e.preventDefault();
closePopup();
});
// 로그인 폼 제출 이벤트
$('#loginFormElement').on('submit', function(e) {
e.preventDefault();
submitLoginForm();
});
// 로그인 팝업 열기 버튼 이벤트
$('.open-login-popup').on('click', 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');
const applyCategoryBtn = document.getElementById('apply-category-btn'); // (신규) 적용 버튼 선택
// "Add" 버튼 (입력창에서 추가) 로직 (수정됨)
if (addCategoryBtn) {
addCategoryBtn.addEventListener('click', function() {
const newCategory = categoryInput.value.trim();
if (newCategory) {
stagedCategory = newCategory; // 1. 임시 변수(stagedCategory) 업데이트
renderStagedCategory(); // 2. 스테이징 UI 새로고침
categoryInput.value = ''; // 3. 입력창 비우기 (팝업 유지)
}
});
}
// "Enter" 키 지원 (기존과 동일)
if (categoryInput) {
categoryInput.addEventListener('keyup', function(e) {
if (e.key === 'Enter' || e.keyCode === 13) {
addCategoryBtn.click();
}
});
}
// (신규) "Apply" 버튼 로직
if (applyCategoryBtn) {
applyCategoryBtn.addEventListener('click', function(e) {
e.preventDefault(); // A태그 기본 동작(새로고침/이동) 방지
// "적용" 시점에만 실제 baseData를 임시 변수 값으로 덮어쓰기
baseData.category = stagedCategory;
updateControlBoxDisplay(); // 본문 에디터 UI 갱신
closePopup(); // 팝업 닫기
});
}
// --- 2. Hashtag Popup Logic ---
const hashtagInput = document.getElementById('hashtag-input');
const addHashtagBtn = document.getElementById('add-hashtag-btn');
const applyHashtagBtn = document.getElementById('apply-hashtag-btn'); // (신규) 적용 버튼 선택
// "Add" 버튼 (입력창에서 추가) 로직 (수정됨)
if (addHashtagBtn) {
addHashtagBtn.addEventListener('click', function() {
// 1. 임시 배열(stagedHashtags)에 추가 (신규 헬퍼 함수 사용)
if (addTagToStaged(hashtagInput.value)) {
renderStagedHashtags(); // 2. 추가 성공 시 스테이징 UI 새로고침
}
hashtagInput.value = ''; // 3. 입력창은 항상 비움
});
}
// "Enter" 키 지원 (기존과 동일)
if (hashtagInput) {
hashtagInput.addEventListener('keyup', function(e) {
if (e.key === 'Enter' || e.keyCode === 13) {
addHashtagBtn.click();
}
});
}
// (신규) "Apply" 버튼 로직
if (applyHashtagBtn) {
applyHashtagBtn.addEventListener('click', function(e) {
e.preventDefault(); // A태그 기본 동작 방지
// "적용" 시점에 임시 배열을 쉼표(,)로 구분된 문자열로 변환하여 실제 baseData에 저장
baseData.tags = stagedHashtags.join(',');
updateControlBoxDisplay(); // 본문 에디터 UI 갱신
closePopup(); // 팝업 닫기
});
}
/* =============================================================== */
});
/**
* [핵심] Quill 에디터를 초기화하는 메인 함수입니다.
* useEditor 파라미터 값에 따라 '편집 모드'와 '읽기 모드'를 동적으로 전환합니다.
* @param {boolean} useEditor - true: 편집기 활성화, false: 읽기 전용 뷰어 활성화
*/
function initEditor(useEditor = false) {
console.log("### initEditor 함수 실행됨! 편집 모드:", useEditor, "###"); // 이 줄을 추가!
const editorContainer = document.querySelector('#editor');
if (!editorContainer) return;
if (typeof serverData !== 'undefined') {
baseData.id = serverData.id;
baseData.title = decodeURIComponent(serverData.title || '');
baseData.content = decodeURIComponent(serverData.content || '');
baseData.category = serverData.category;
baseData.tags = serverData.tags;
baseData.firstPostLat = serverData.firstPostLat;
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();
try {
var Font = Quill.import('formats/font');
Font.whitelist = ['sans-serif', 'serif', 'monospace', 'arial', 'georgia', 'comic-sans-ms', 'courier-new', 'roboto', 'playfair-display'];
Quill.register(Font, true);
Quill.register({ 'modules/table-better': QuillTableBetter }, true);
const quillOptions = {
theme: 'snow',
modules: useEditor ? {
toolbar: {
container: [
[{ font: Font.whitelist }], [{ 'size': ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline', 'strike'], [{ 'color': [] }, { 'background': [] }],
[{ 'header': 1 }, { 'header': 2 }, 'blockquote', 'code-block'],
[{ 'script': 'sub'}, { 'script': 'super' }], [{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'indent': '-1'}, { 'indent': '+1' }], [{ 'align': [] }],
['table-better'], [{ 'direction': 'rtl' }], ['clean'], ['link', 'image', 'video'],
],
handlers: { image: function() { selectLocalImage(); }, video: function() { selectLocalVideo(); } }
},
'table-better': { language: 'en_US', toolbarTable: true },
keyboard: { bindings: QuillTableBetter.keyboardBindings }
} : {
toolbar: false
},
readOnly: !useEditor
};
quill = new Quill(editorContainer, quillOptions);
if (baseData.content) {
loadContent(baseData.content);
}
if (!useEditor) {
editorContainer.classList.add('readonly-mode');
} else {
editorContainer.classList.remove('readonly-mode');
const titleField = document.querySelector("#title_field");
if (titleField) {
titleField.value = baseData.title;
}
}
} catch (e) {
console.error("Quill initialization failed:", e);
}
setupControlBox(useEditor ? 'edit' : 'view');
}
function selectLocalImage() {
// 이미지 URL 입력 받기
const url = prompt("이미지 URL을 입력하거나 빈칸으로 두시면 파일 업로드를 합니다.");
if (url) {
// URL이 입력된 경우 이미지 삽입
const range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', url);
quill.setSelection(range.index + 1);
} else {
// URL이 없거나 취소한 경우 파일 업로드 처리
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = async () => {
const file = input.files[0];
if (file) {
const file = input.files[0];
console.log("on selectLocalImage File", file);
if (!file || !file.type.startsWith('image/')) {
console.warn('이미지 파일만 업로드 가능합니다.');
return;
}
uploadImage(file);
}
};
}
}
function uploadImage(blob) {
const formData = new FormData();
formData.append('file', blob);
let uploadUrl = getMainPath() + "/blog/post/imageUpload.bjx";
let imageUrl = getMainPath() + '/blog/post/images/';
$.ajax({
type: 'POST',
enctype: 'multipart/form-data',
url: uploadUrl,
data: formData,
dataType: 'json',
processData: false,
contentType: false,
cache: false,
timeout: 600000,
success: function (data) {
console.log(data);
imageUrl += data.fileName;
insertToEditor(imageUrl);
},
error: function (e) {
console.error(e);
// callback('image_load_fail');
}
});
}
function selectLocalVideo() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'video/*');
input.click();
input.onchange = () => {
const file = input.files[0];
if (!file || !file.type.startsWith('video/')) {
alert('동영상 파일만 업로드할 수 있습니다.');
return;
}
uploadVideo(file);
};
}
function uploadVideo(file) {
const formData = new FormData();
formData.append('video', file);
fetch('/api/upload/video', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(result => {
if (result.url) {
const range = quill.getSelection(true);
quill.insertEmbed(range.index, 'video', result.url);
quill.setSelection(range.index + 1);
} else {
console.error('동영상 업로드 실패', result);
}
})
.catch(err => {
console.error('업로드 중 오류', err);
});
}
function insertToEditor(url) {
const range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', url);
quill.setSelection(range.index + 1);
}
/**
* 에디터 모드('edit' 또는 'view')에 따라 컨트롤 박스를 설정합니다.
*/
function setupControlBox(mode) {
const categoryBox = document.querySelector('.controlbox-category');
const hashtagBox = document.querySelector('.controlbox-hashtag');
if (!categoryBox || !hashtagBox) return;
if (mode === 'edit') {
categoryBox.setAttribute('onclick', 'openPopup(this)');
hashtagBox.setAttribute('onclick', 'openPopup(this)');
// === 수정된 부분 ===
// 기존 "innerText" 설정 줄을 삭제하고,
// 페이지 로드 시 현재 데이터로 UI를 업데이트하는 함수를 호출합니다.
updateControlBoxDisplay();
// ==================
fetchCategoriesAndHashtags(); // 팝업 목록 채우는 로직은 그대로 실행
} else {
// (읽기 모드 'else' 블록 수정)
categoryBox.removeAttribute('onclick');
hashtagBox.removeAttribute('onclick');
categoryBox.classList.remove('btn-example');
hashtagBox.classList.remove('btn-example');
// [수정] 카테고리를 새 구조(제목 + 래퍼)로 변경
const categoryContent = `${baseData.category || '지정되지 않음'}`;
categoryBox.innerHTML = `CATEGORY
${categoryContent}
`;
// [수정] 해시태그를 새 구조(제목 + 래퍼)로 변경
let hashtagContent = '';
if (baseData.tags && baseData.tags.length > 0) {
hashtagContent = baseData.tags.split(',').map(tag => {
return `#${tag.trim()}`;
}).join(' '); // join으로 하나의 문자열로 만듭니다.
} else {
hashtagContent = '없음';
}
hashtagBox.innerHTML = `HASHTAGS${hashtagContent}
`;
}
}
/**
* 백엔드 API를 호출하여 카테고리와 해시태그 목록을 가져와 팝업을 채웁니다.
*/
/**
* 백엔드 API를 호출하여 카테고리와 해시태그 목록을 가져와 팝업을 채웁니다.
* (수정됨: 클릭 시 baseData 대신 Staging 변수를 업데이트하도록 변경)
*/
function fetchCategoriesAndHashtags() {
// Fetch Categories
fetch(`${getMainPath()}/blog/categories.bjx`).then(res => res.json()).then(data => {
if (data.resultCode === 0 && data.tags) {
const list = document.querySelector('#category-list');
if (list) {
list.innerHTML = '';
data.tags.forEach(tag => {
const el = document.createElement('span');
el.className = 'tag-item';
el.innerText = tag;
// (로직 변경) 클릭 시
el.onclick = function() {
stagedCategory = tag; // 1. 임시 변수(stagedCategory) 업데이트
renderStagedCategory(); // 2. 스테이징 UI만 새로고침 (팝업 안 닫음)
};
list.appendChild(el);
});
}
}
}).catch(err => console.error('Error fetching categories:', err));
// Fetch Hashtags
fetch(`${getMainPath()}/blog/hashtags.bjx`).then(res => res.json()).then(data => {
if (data.resultCode === 0 && data.tags) {
const list = document.querySelector('#hashtag-list');
if (list) {
list.innerHTML = '';
data.tags.forEach(tag => {
const rawTag = tag;
const el = document.createElement('span');
el.className = 'tag-item';
el.innerText = `#${rawTag}`;
// (로직 변경) 클릭 시
el.onclick = function() {
// 1. 임시 배열(stagedHashtags)에 추가 (중복 방지 헬퍼 사용)
if (addTagToStaged(rawTag)) {
// 2. 추가 성공 시에만 스테이징 UI 새로고침
renderStagedHashtags();
}
};
list.appendChild(el);
});
}
}
}).catch(err => console.error('Error fetching hashtags:', err));
}
/**
* 컨텐츠를 Quill 에디터에 로드합니다.
*/
function loadContent(content) {
try {
const delta = JSON.parse(content);
if (delta && Array.isArray(delta.ops)) {
quill.setContents(delta);
return;
}
} catch (e) { /* HTML 문자열일 경우 아래에서 처리 */ }
quill.clipboard.dangerouslyPasteHTML(content);
}
/**
* 게시물 수정 페이지로 이동합니다.
*/
function loadEditor() {
if (baseData.id) {
location.href = `${getMainPath()}/blog/edit/${baseData.id}`;
}
}
/**
* 작성된 게시물을 서버에 저장합니다.
*/
function save() {
const titleField = document.getElementById('title_field');
// --- (수정/신규 로직) ---
// 1. baseData의 복사본을 만들어 전송용 임시 객체(dataToSend)를 생성합니다.
// (원본 baseData를 직접 수정하면 팝업 UI가 인코딩된 문자로 깨집니다)
let dataToSend = JSON.parse(JSON.stringify(baseData));
// 2. dataToSend 객체의 모든 텍스트 필드를 encodeURIComponent로 인코딩합니다.
if (titleField) {
dataToSend.title = encodeURIComponent(titleField.value);
} else {
dataToSend.title = encodeURIComponent(dataToSend.title || '');
}
dataToSend.content = encodeURIComponent(JSON.stringify(quill.getContents()));
// (누락되었던 필드 추가)
dataToSend.category = encodeURIComponent(dataToSend.category || 'none');
dataToSend.tags = encodeURIComponent(dataToSend.tags || '');
// 3. 좌표 데이터를 임시 객체에 업데이트합니다.
dataToSend.modifyLat = currentLat;
dataToSend.modifyLon = currentLon;
// (신규 게시물일 경우 원본 위치 좌표 설정)
if (dataToSend.firstPostLat === 0.0 || dataToSend.firstPostLat === null) {
dataToSend.firstPostLat = currentLat;
}
if (dataToSend.firstPostLon === 0.0 || dataToSend.firstPostLon === null) {
dataToSend.firstPostLon = currentLon;
}
const uploadUrl = `${getMainPath()}/blog/post.bjx`;
if (confirm("해당 내용으로 저장하시겠습니까?")) {
console.log("Data being sent to server:", dataToSend);
console.log("JSON string being sent:", JSON.stringify(dataToSend));
post(uploadUrl, serverData.enc, JSON.stringify(dataToSend), serverData.keyword, function(resultData) {
// --- (수정된 콜백 로직) ---
try {
// 1. 서버로부터 받은 JSON 문자열을 객체로 파싱합니다.
const response = JSON.parse(resultData);
// 2. 서버 응답이 성공(resultCode === 0)이고,
// 서버가 postId를 (예: response.data.postId) 보내줬는지 확인합니다.
// (참고: 'response.data.postId'는 서버 응답 구조에 따라 변경해야 할 수 있습니다.)
if (response.resultCode === 0 && response.data && response.data.postId) {
// 3. 알림 후, 응답받은 ID를 사용해 뷰어 페이지로 리디렉션합니다.
alert("저장되었습니다. 게시물 보기 페이지로 이동합니다.");
location.href = getMainPath() + "/blog/viewer/" + response.data.postId;
} else {
// 저장은 성공했으나 ID를 받지 못한 경우 (또는 서버가 다른 에러 코드를 보낸 경우)
alert("저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error"));
}
} catch (e) {
// JSON 파싱 실패 등 예외 처리
console.error("Failed to parse save response:", e, resultData);
alert("저장에 성공했으나 서버 응답을 처리할 수 없습니다.");
}
// --- (여기까지 수정된 로직) ---
});
}
}
/**
* 사용자의 현재 위치(위도, 경도)를 가져옵니다.
*/
function getLocation() {
if (baseData.firstPostLat !== 0.0 || baseData.firstPostLon !== 0.0) {
try {
var requestOptions = {
method: 'GET',
};
fetch("https://api.geoapify.com/v1/geocode/reverse?lat="+baseData.firstPostLat+"&lon="+baseData.firstPostLon+"&apiKey=2b37a75bb0754086b5a1c4a7c3173ee8", requestOptions)
.then(response => response.json())
.then(function(result) {
const locationField = document.getElementById('location_field');
try {
var inh = `LOCATION`;
inh = inh + ``;
try{
inh = inh + `
${result.features[0].properties.formatted}
`;
}catch(err) {}
inh = inh + `
Lat: ${baseData.firstPostLat.toFixed(2)}
`;
inh = inh + `
Lon: ${baseData.firstPostLon.toFixed(2)}
`;
locationField.innerHTML = inh;
} catch (e) {
locationField.innerHTML = `LOCATION` +
`Lat: ${baseData.firstPostLat.toFixed(2)}
Lon: ${baseData.firstPostLon.toFixed(2)}
`;
}
})
.catch(error => console.log('error', error));
}catch (e) { }
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
currentLat = pos.coords.latitude;
currentLon = pos.coords.longitude;
if (baseData.firstPostLat === 0.0) baseData.firstPostLat = currentLat;
if (baseData.firstPostLon === 0.0) baseData.firstPostLon = currentLon;
baseData.modifyLat = currentLat;
baseData.modifyLon = currentLon;
const locationField = document.getElementById('location_field');
if (locationField) {
// [수정] 제목과 내용 래퍼 구조로 변경
locationField.innerHTML = `LOCATION` +
`Lat: ${baseData.firstPostLat.toFixed(2)}
Lon: ${baseData.firstPostLon.toFixed(2)}
`;
}
});
} else {
const locationField = document.getElementById('location_field');
if (locationField) {
// [수정] 제목과 내용 래퍼 구조로 변경
locationField.innerHTML = `LOCATION` +
`Lat: ${baseData.firstPostLat.toFixed(2)}
Lon: ${baseData.firstPostLon.toFixed(2)}
`;
}
}
}
/**
* 팝업 레이어를 엽니다.
*/
function openPopup(element) {
const targetId = element.getAttribute('to');
const popup = document.querySelector(targetId);
const overlay = document.querySelector('.dim_layer');
if (popup && overlay) {
// === (신규) Staging 변수 초기화 로직 ===
if (targetId === '#popLayer1') { // 카테고리 팝업
// 1. 실제 데이터(baseData)에서 임시 변수(stagedCategory)로 값을 복사
stagedCategory = baseData.category || 'none';
// 2. 임시 변수 기준으로 스테이징 UI 렌더링
renderStagedCategory();
}
else if (targetId === '#popLayer2') { // 해시태그 팝업
// 1. 실제 데이터(baseData)에서 임시 배열(stagedHashtags)로 값을 복사
// (문자열을 배열로 변환하고, 빈 문자열 필터링)
stagedHashtags = baseData.tags ? baseData.tags.split(',').filter(t => t.trim() !== '') : [];
// 2. 임시 배열 기준으로 스테이징 UI 렌더링
renderStagedHashtags();
}
// ===================================
overlay.style.display = 'block';
popup.style.display = 'block';
}
}
/**
* 팝업 레이어를 닫습니다.
*/
function closePopup() {
const overlay = document.querySelector('.dim_layer');
if(overlay) overlay.style.display = 'none';
document.querySelectorAll('.pop_layer').forEach(p => p.style.display = 'none');
}
/**
* 게시물 상세 보기 페이지로 이동합니다.
*/
function goToViewer(element) {
if (element && element.id) {
location.href = `${getMainPath()}/blog/viewer/${element.id}`;
}
}
// =================================================================================
// [복구] 이하 누락되었던 함수들
// =================================================================================
/**
* 인기글 목록을 가져와 UI에 표시합니다.
*/
function fetchRankOfViews() {
fetch(`${getMainPath()}/blog/rankOfViews.bjx`).then(res => res.json()).then(data => {
const ul = document.querySelector('.rank_of_view');
if (ul && data.posts) {
ul.innerHTML = '';
data.posts.forEach(item => {
const date = new Date(item.writeTime);
const formattedDate = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
ul.innerHTML += `${item.title}
[${formattedDate}]`;
});
}
}).catch(error => console.error('Failed to fetch rank of views:', error));
}
/**
* 최신글 목록을 가져와 UI에 표시합니다.
*/
function fetchRecentPosts() {
fetch(`${getMainPath()}/blog/recentOfPost.bjx`).then(res => res.json()).then(data => {
const ul = document.querySelector('.recent_posts');
if (ul && data.posts) {
ul.innerHTML = '';
data.posts.forEach(item => {
const date = new Date(item.writeTime);
const formattedDate = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
ul.innerHTML += `${item.title}
[${formattedDate}]`;
});
}
}).catch(error => console.error('Failed to fetch recent posts:', error));
}
/**
* 로그인 폼 데이터를 서버에 전송합니다.
*/
function submitLoginForm() {
const data = {
'user_id': $('#loginId').val(),
'user_pw': $('#loginPassword').val(),
'rememberMe': $('#rememberMe').is(':checked'),
};
postLogin(`${getMainPath()}/user/login.bjx`, serverData.enc, JSON.stringify(data), serverData.keyword, function(response) {
if (response.isOk) {
location.reload();
} else {
alert(`로그인 실패: ${response.resultMsg}`);
}
});
}
// --- 페이지 이동(Navigation) 함수들 ---
function gotoHome() { document.location.replace(`${getMainPath()}/home.bs`); }
function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); } // 수정된 URL
function gotoModify() { document.location.replace(`${getMainPath()}/blog/posts`); } // 수정된 URL
function gotoWhere() { document.location.replace(`${getMainPath()}/bums/where.bs`); }
function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`); }
/**
* 로그아웃을 처리합니다.
*/
function logout() {
const form = document.createElement('form');
form.method = 'POST';
form.action = `${getMainPath()}/user/logout.bs`;
// Spring Security CSRF 토큰 추가
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
const csrfParam = document.querySelector('meta[name="_csrf_parameter"]').getAttribute('content');
if (csrfToken && csrfParam) {
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = csrfParam;
csrfInput.value = csrfToken;
form.appendChild(csrfInput);
}
document.body.appendChild(form);
form.submit();
}
// =================================================================================
// 서버 통신 및 암호화 관련 유틸리티 함수들 (기존 코드 유지)
// =================================================================================
function getMainPath() {
return location.protocol + "//" + location.hostname + (location.port ? ':' + location.port : '');
}
function post(target, type, data, key, callBackResult) {
const httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = () => {
if (httpRequest.readyState === XMLHttpRequest.DONE) {
if (httpRequest.status === 200) callBackResult(httpRequest.response);
else alert('Request Error!');
}
};
httpRequest.open('POST', target, true);
httpRequest.setRequestHeader("Content-Type", "text/plain");
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) {
const httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = () => {
if (httpRequest.readyState === XMLHttpRequest.DONE) {
if (httpRequest.status === 200) {
try {
callBackResult(JSON.parse(httpRequest.response));
} catch (e) { console.error("Login response parse error:", e); }
} else { alert('Request Error!'); }
}
};
httpRequest.withCredentials = true;
httpRequest.open('POST', target, true);
httpRequest.setRequestHeader("Content-Type", "text/plain");
httpRequest.send(btoa(JSON.stringify({
'data': unformat(type, data, key), 'key': key, 'type': type,
})));
}
function unformat(type, data, key) {
var even = [], odd = [];
data.split("").forEach((v, idx) => (idx % 2 === 0 ? even.push(v) : odd.push(v)));
const dividerStr = ["%7C%2A-%2A%7C", key, "%7C%2A-%2A%7C"].join("");
switch (type) {
case "T0":
return [odd.join(""), dividerStr, even.join("")].join("");
case "T1":
return [odd.reverse().join(""), dividerStr, even.join("")].join("");
case "T2":
return [odd.join(""), dividerStr, even.reverse().join("")].join("");
default:
return [odd.reverse().join(""), dividerStr, even.reverse().join("")].join("");
}
}
/**
* (신규 추가)
* 중복을 방지하며 baseData.tags (문자열)에 새 태그를 안전하게 추가합니다.
*/
function addTagToData(newTag) {
if (!newTag || newTag.trim() === '') return; // 빈 태그 방지
// 현재 태그 문자열을 배열로 변환 (태그가 없으면 빈 배열)
let tags = baseData.tags ? baseData.tags.split(',') : [];
// 새 태그가 이미 존재하는지 확인 (공백 제거 및 대소문자 무시)
const tagExists = tags.some(t => t.trim().toLowerCase() === newTag.trim().toLowerCase());
if (!tagExists) {
tags.push(newTag.trim()); // 새 태그 추가
baseData.tags = tags.join(','); // 다시 쉼표로 구분된 문자열로 저장
}
}
/**
* (신규 추가)
* 편집기 컨트롤 박스의 텍스트를 현재 baseData 기준으로 새로 고칩니다.
*/
function updateControlBoxDisplay() {
const categoryBox = document.querySelector('.controlbox-category');
const hashtagBox = document.querySelector('.controlbox-hashtag');
if (categoryBox) {
// [수정] 편집 모드도 읽기 모드와 동일한 HTML 구조(제목 + 래퍼)로 생성
const categoryContent = (baseData.category && baseData.category !== 'none')
? `${baseData.category}`
: '카테고리 설정';
categoryBox.innerHTML = `CATEGORY${categoryContent}
`;
}
if (hashtagBox) {
// [수정] 편집 모드도 읽기 모드와 동일한 HTML 구조(제목 + 래퍼)로 생성
let hashtagContent = '';
if (baseData.tags && baseData.tags.length > 0) {
hashtagContent = baseData.tags.split(',')
.map(t => `#${t.trim()}`)
.join(' '); // 각 태그를 span으로 감싸고 공백으로 연결
} else {
hashtagContent = '해시태그 편집';
}
hashtagBox.innerHTML = `HASHTAGS${hashtagContent}
`;
}
}
/* === (신규 추가) POPUP STAGING 헬퍼 함수들 === */
/** 1. Staging Category 렌더링: 선택된 카테고리(임시 변수)를 팝업 UI에 표시 */
function renderStagedCategory() {
const area = document.getElementById('selected-category-area');
if (area) {
if (stagedCategory && stagedCategory !== 'none') {
// 선택된 아이템에 삭제(X) 버튼을 포함하여 렌더링
area.innerHTML = `${stagedCategory}
X
`;
} else {
area.innerHTML = 'No category selected.';
}
}
}
/** 2. Staging Hashtags 렌더링: 선택된 해시태그 목록(임시 배열)을 팝업 UI에 표시 */
function renderStagedHashtags() {
const area = document.getElementById('selected-hashtags-area');
if (area) {
area.innerHTML = ''; // 영역 초기화
if (stagedHashtags.length > 0) {
stagedHashtags.forEach((tag, index) => {
// 각 아이템에 삭제(X) 버튼과 올바른 index를 전달하는 onclick 이벤트 추가
area.innerHTML += `#${tag}
X
`;
});
} else {
area.innerHTML = 'No tags selected.';
}
}
}
/** 3. Staged Category 삭제: (X) 버튼 클릭 시 호출 */
function removeStagedCategory() {
stagedCategory = 'none';
renderStagedCategory(); // UI 새로고침
}
/** 4. Staged Hashtag 삭제: (X) 버튼 클릭 시 호출 */
function removeStagedHashtag(index) {
stagedHashtags.splice(index, 1); // 배열에서 해당 인덱스의 아이템 1개 제거
renderStagedHashtags(); // UI 새로고침
}
/** 5. Staged Hashtag 추가 (중복 방지 헬퍼): 임시 배열에 태그 추가 */
function addTagToStaged(newTag) {
if (!newTag || newTag.trim() === '') return false; // 빈 값 방지
const tagToAdd = newTag.trim().replace(/#/g, ''); // # 제거 및 공백 제거
// 임시 배열에 이미 존재하는지 확인 (대소문자 무시)
const tagExists = stagedHashtags.some(t => t.toLowerCase() === tagToAdd.toLowerCase());
if (!tagExists) {
stagedHashtags.push(tagToAdd); // 임시 배열에 추가
return true;
}
return false; // 중복이면 false 반환
}
function handleVote(buttonElement, voteType) {
// 1. 가장 가까운 .vote-controls 컨테이너를 찾음
const controls = buttonElement.closest('.vote-controls');
// 2. 컨테이너의 data-post-id 속성에서 postId를 가져옴
const postId = controls.dataset.postId; // (data-post-id="...") 값을 읽음
// 3. 모든 버튼 비활성화 (중복 클릭 방지)
controls.querySelectorAll('button').forEach(btn => btn.disabled = true);
// 4. 요청할 URL 생성
let url = `${getMainPath()}/blog/post/${postId}/${voteType === 'like' ? 'like' : 'unlike'}.bjx`;
// 5. CSRF 토큰 및 헤더 준비 (기본 헤더)
let headers = {
'Content-Type': 'application/json'
};
// [수정] CSRF 메타 태그가 존재하는지 (즉, 사용자가 로그인했는지) 확인
const csrfMeta = document.querySelector('meta[name="_csrf"]');
if (csrfMeta) {
const csrfToken = csrfMeta.getAttribute('content');
if (csrfToken) {
// 토큰이 존재할 경우에만 헤더에 추가
headers['X-CSRF-TOKEN'] = csrfToken;
}
}
// 6. Fetch API를 사용하여 POST 요청 전송
// (익명 사용자는 CSRF 헤더 없이 요청하고, 인증 사용자는 헤더와 함께 요청)
fetch(url, {
method: 'POST',
headers: headers
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// 7. 성공 시: UI 업데이트
const likeSpan = controls.querySelector('.like-count');
const unlikeSpan = controls.querySelector('.unlike-count');
if (likeSpan) likeSpan.innerText = data.voteCount;
if (unlikeSpan) unlikeSpan.innerText = data.unlikeCount;
})
.catch(error => {
// 8. 실패 시: 버튼 다시 활성화
console.error('Error handling vote:', error);
alert('투표 중 오류가 발생했습니다. 나중에 다시 시도해주세요.');
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 = '댓글 목록을 불러오는 중...
';
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 = '아직 댓글이 없습니다. 첫 댓글을 작성해보세요.
';
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 = '댓글 로딩 중 오류가 발생했습니다.
';
}
}
/**
* [신규 추가] 댓글 객체를 받아 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(/\n/g, "
");
const writerNameForReply = String(writerName).replace(/'/g, "\\'"); // ' 문자가 JS를 깨트리는 것을 방지
// 참고: 현재 로직은 대댓글의 대댓글(3단계)은 지원하지 않습니다. (isReply = true이면 답글 버튼 생성 안 함)
// 3단계 이상을 지원하려면 isReply 체크를 제거하고, 답글 API가 대댓글도 정상적으로 가져오는지 확인해야 합니다.
const replyButtonHTML = !isReply
? ``
: ''; // 대댓글에는 "답글" 버튼 표시 안 함
return `
${safeContent}
`;
}
/**
* [신규 추가] "답글 달기" 버튼 클릭 시 호출되는 헬퍼 함수
* @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'; // 상태바 숨기기
}
}
/* ============================================= */