/** * ================================================================================= * 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 `
    ${writerName} ${formattedDate}
    ${replyButtonHTML}

    ${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'; // 상태바 숨기기 } } /* ============================================= */