/** * ================================================================================= * common.js - 블로그 공통 스크립트 (Vanilla JS 버전) * * 이 파일은 jQuery($) 의존성을 완전히 제거하고 순수 JavaScript(ES6+) 문법으로 재작성되었습니다. * 템플릿의 jQuery(main.js 등)와 충돌하지 않고 독립적으로 작동합니다. * ================================================================================= */ /* ============================================= */ /* --- 전역 변수 선언 --- */ /* ============================================= */ var stagedCategory = 'none'; // 카테고리 팝업에서 '적용' 전 임시 저장하는 변수 var stagedHashtags = []; // 해시태그 팝업에서 '적용' 전 임시 저장하는 배열 var currentReplyParentId = null; // 현재 답글을 다는 대상(부모 댓글)의 ID (null이면 최상위 댓글) var quill = null; // Quill 에디터 인스턴스를 저장할 전역 변수 var currentLat = 0.0; // 현재 위도 var currentLon = 0.0; // 현재 경도 // 게시물 기본 데이터를 저장하는 객체. serverData로부터 초기화됨. 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()를 바닐라 JS의 DOMContentLoaded 이벤트로 대체합니다. * HTML 문서를 모두 읽고 DOM 트리가 완성되었을 때 이 안의 코드가 실행됩니다. */ window.addEventListener('DOMContentLoaded', () => { const urlParams = new URLSearchParams(window.location.search); const action = urlParams.get('action'); if (action === 'login') { const loginPopup = document.getElementById('loginPopup'); if (loginPopup) { // openPopup 함수는 특정 버튼(element)을 필요로 하므로, // 팝업 div 자체에 임시로 'to' 속성을 부여하여 재사용합니다. loginPopup.setAttribute('to', '#loginPopup'); openPopup(loginPopup); } } else if (action === 'signup') { const signupPopup = document.getElementById('signupPopup'); if (signupPopup) { signupPopup.setAttribute('to', '#signupPopup'); openPopup(signupPopup); } } const openSignupBtn = document.getElementById('openSignupBtnFromLogin'); if (openSignupBtn) { openSignupBtn.addEventListener('click', () => { // 1. 현재 열려있는 로그인 팝업을 닫습니다. closePopup(); // 2. 회원가입 팝업을 찾아서 엽니다. const signupPopup = document.getElementById('signupPopup'); if (signupPopup) { // openPopup 함수를 재사용하기 위해 'to' 속성을 설정합니다. signupPopup.setAttribute('to', '#signupPopup'); openPopup(signupPopup); } }); } console.log("DOM Loaded: Attaching Vanilla JS event listeners."); const logoutButton = document.querySelector('a[href="javascript:logout()"]'); const isLoggedIn = !!logoutButton; // true 또는 false const commentForm = document.querySelector('.comment-form-wrapper'); if (commentForm) { if (!isLoggedIn) { // 비로그인 상태면, 입력 필드와 버튼을 비활성화합니다. const commentInput = commentForm.querySelector('#comment-input'); const commentSubmitBtn = commentForm.querySelector('#submit-comment'); if(commentInput) { commentInput.disabled = true; commentInput.placeholder = '댓글을 작성하려면 로그인이 필요합니다.'; } if(commentSubmitBtn) { commentSubmitBtn.disabled = true; } } } // --- 1. 사이드바 목록 가져오기 (이 기능은 이미 바닐라 JS였습니다) --- // if (document.querySelector(".rank_of_view")) { // fetchRankOfViews(); // } if (document.querySelector(".recent_posts")) { fetchRecentPosts(); } /** * --- 2. [수정] 팝업 닫기 버튼 이벤트 (모든 '.btn_layerClose' 요소) --- * 기존: $('.btn_layerClose').on('click', ...) * 변경: querySelectorAll로 모든 닫기 버튼을 찾아 각각 클릭 이벤트를 추가합니다. */ document.querySelectorAll('.btn_layerClose').forEach(button => { button.addEventListener('click', (e) => { e.preventDefault(); // a 태그의 기본 동작(페이지 이동) 방지 closePopup(); }); }); /** * --- 3. [수정] 로그인 폼 제출 이벤트 --- * 기존: $('#loginFormElement').on('submit', ...) * 변경: ID로 폼을 찾아 submit 이벤트를 추가합니다. * (참고: if (loginForm) 가드: 이 ID가 없는 페이지(뷰어 등)에서 JS 오류가 나는 것을 방지합니다.) */ const loginForm = document.getElementById('loginFormElement'); if (loginForm) { loginForm.addEventListener('submit', (e) => { e.preventDefault(); // 폼의 기본 제출 동작(새로고침) 방지 submitLoginForm(); // 우리가 정의한 로그인 함수 호출 }); } /** * --- 4. [수정] 로그인 팝업 열기 버튼 이벤트 --- * 기존: $('.open-login-popup').on('click', ...) * 변경: querySelectorAll로 모든 로그인 팝업 버튼(글쓰기 버튼 등)을 찾아 클릭 이벤트를 추가합니다. */ document.querySelectorAll('.open-login-popup').forEach(button => { // 'this'가 클릭된 요소(button) 자신을 가리키도록 arrow function( => ) 대신 function()을 사용합니다. button.addEventListener('click', function() { openPopup(this); // 'this'는 클릭된
요소를 openPopup 함수로 전달합니다. }); }); /** * --- 5. [수정] 댓글 등록 버튼 이벤트 --- * (참고: if (commentSubmitBtn) 가드: 댓글 폼이 없는 페이지(홈 등)에서 오류 방지) */ const commentSubmitBtn = document.getElementById('submit-comment'); if (commentSubmitBtn) { commentSubmitBtn.addEventListener('click', (e) => { e.preventDefault(); // isLoggedIn 변수를 사용하여 추가적인 클라이언트 측 방어 if (!isLoggedIn) { showAlert("알림",'로그인이 필요합니다.'); return; } submitComment(); }); } /* --- 6. 팝업 입력/적용/취소 버튼 로직 (이 코드는 이미 바닐라 JS였습니다) --- */ // --- Category Popup Logic --- const categoryInput = document.getElementById('category-input'); const addCategoryBtn = document.getElementById('add-category-btn'); const applyCategoryBtn = document.getElementById('apply-category-btn'); if (addCategoryBtn) { addCategoryBtn.addEventListener('click', function() { const newCategory = categoryInput.value.trim(); if (newCategory) { stagedCategory = newCategory; renderStagedCategory(); categoryInput.value = ''; } }); } if (categoryInput) { categoryInput.addEventListener('keyup', function(e) { if (e.key === 'Enter' || e.keyCode === 13) { addCategoryBtn.click(); } }); } if (applyCategoryBtn) { applyCategoryBtn.addEventListener('click', function(e) { e.preventDefault(); baseData.category = stagedCategory; updateControlBoxDisplay(); closePopup(); }); } // --- Hashtag Popup Logic --- const hashtagInput = document.getElementById('hashtag-input'); const addHashtagBtn = document.getElementById('add-hashtag-btn'); const applyHashtagBtn = document.getElementById('apply-hashtag-btn'); if (addHashtagBtn) { addHashtagBtn.addEventListener('click', function() { if (addTagToStaged(hashtagInput.value)) { renderStagedHashtags(); } hashtagInput.value = ''; }); } if (hashtagInput) { hashtagInput.addEventListener('keyup', function(e) { if (e.key === 'Enter' || e.keyCode === 13) { addHashtagBtn.click(); } }); } if (applyHashtagBtn) { applyHashtagBtn.addEventListener('click', function(e) { e.preventDefault(); baseData.tags = stagedHashtags.join(','); updateControlBoxDisplay(); closePopup(); }); } checkUnreadMessages(); // 함수 호출 추가 }); /* --- (DOMContentLoaded 끝) --- */ /* ============================================= */ /* --- Quill 에디터 초기화 관련 함수들 --- */ /* ============================================= */ /** * [핵심] Quill 에디터를 초기화하는 메인 함수입니다. * (이 함수는 바닐라 JS와 Quill API로만 작성되어 수정이 필요 없습니다.) */ function initEditor(useEditor = false) { console.log("### initEditor 함수 실행됨! 편집 모드:", useEditor, "###"); const editorContainer = document.querySelector('#editor'); if (!editorContainer) return; // 에디터 DOM이 없으면(홈화면 등) 즉시 종료 // serverData (includes.html에 정의됨)에서 baseData (JS 내부 변수)로 모든 값을 복사합니다. 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 { // Quill 에디터 옵션 및 모듈 설정 (편집/읽기 모드 전환) 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'], ], // [핵심] 툴바의 이미지 버튼 클릭 시 selectLocalImage 함수를 호출하도록 핸들러 지정 handlers: { image: function() { selectLocalImage(); }, video: function() { selectLocalVideo(); } } }, 'table-better': { language: 'en_US', toolbarTable: true }, keyboard: { bindings: QuillTableBetter.keyboardBindings } } : { toolbar: false // 읽기 모드(useEditor: false)일 경우 툴바 숨김 }, readOnly: !useEditor // 읽기 전용 모드 설정 }; quill = new Quill(editorContainer, quillOptions); // Quill 인스턴스 생성 if (baseData.content) { loadContent(baseData.content); // DB에서 불러온 콘텐츠를 에디터에 로드 } // 읽기 모드/편집 모드에 따라 CSS 클래스 및 제목 필드 처리 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'); // 카테고리/해시태그 박스 설정 } /** * 이미지 핸들러: 툴바의 이미지 버튼 클릭 시 호출됨 (바닐라 JS) */ function selectLocalImage() { // 1. URL 입력 받기 const url = prompt("이미지 URL을 입력하거나 빈칸으로 두시면 파일 업로드를 합니다."); if (url) { // 2. URL이 입력된 경우: 해당 URL을 에디터에 바로 삽입 const range = quill.getSelection(true); quill.insertEmbed(range.index, 'image', url); quill.setSelection(range.index + 1); } else { // 3. URL이 없는 경우(취소 또는 빈칸): 파일 탐색기 열기 const input = document.createElement('input'); input.setAttribute('type', 'file'); input.setAttribute('accept', 'image/*'); input.click(); // 파일 탐색기 열기 // 4. 파일이 선택되면 input.onchange = async () => { const file = input.files[0]; if (file) { console.log("on selectLocalImage File", file); if (!file || !file.type.startsWith('image/')) { console.warn('이미지 파일만 업로드 가능합니다.'); return; } // 5. 이미지 업로드 함수(수정된 fetch 버전) 호출 uploadImage(file); } }; } } /** * [수정] jQuery $.ajax를 바닐라 JS의 fetch API로 대체한 이미지 업로드 함수 */ async function uploadImage(blob) { const formData = new FormData(); formData.append('file', blob); let uploadUrl = getMainPath() + "/blog/post/imageUpload.bjx"; // let imageUrlBase = getMainPath() + '/blog/post/images/'; // [수정] 이미지 URL의 기본 경로를 새로운 API 경로로 변경합니다. let imageUrlBase = getMainPath() + '/api/images/'; // '/blog/post/images/' -> '/api/images/' try { // CSRF 토큰을 태그에서 직접 읽어옵니다. (includes.html에 정의되어 있음) const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content') || ''; // fetch API를 사용해 파일 업로드 요청 (POST) const response = await fetch(uploadUrl, { method: 'POST', body: formData, // FormData를 body로 전송 headers: { // 'Content-Type'은 FormData 사용 시 브라우저가 자동으로 'multipart/form-data'와 boundary를 설정하므로, 절대 수동으로 지정하지 않습니다. // Spring Security를 위한 X-CSRF-TOKEN 헤더만 추가합니다. 'X-CSRF-TOKEN': csrfToken } }); if (!response.ok) { // 서버가 200 OK 응답이 아닐 경우 에러를 발생시킵니다. throw new Error(`Server responded with status: ${response.status}`); } // 응답을 JSON으로 파싱합니다 (기존 $.ajax의 'dataType: json' 및 'success' 콜백 대체) const data = await response.json(); console.log(data); // 업로드 성공 데이터 (fileName 등) let imageUrl = imageUrlBase + data.fileName; insertToEditor(imageUrl); // 에디터에 이미지 삽입 } catch (e) { // 네트워크 오류나 JSON 파싱 오류 등을 처리합니다. (기존 $.ajax의 'error' 콜백 대체) console.error("Image upload failed:", e); } } /** * 비디오 업로드 함수 (기능 구현 필요) */ 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/')) { showAlert("알림",'동영상 파일만 업로드할 수 있습니다.'); 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); }); } /** * 에디터에 이미지 URL을 삽입하는 헬퍼 함수 */ function insertToEditor(url) { const range = quill.getSelection(true); quill.insertEmbed(range.index, 'image', url); quill.setSelection(range.index + 1); } /** * 컨트롤 박스(카테고리, 해시태그) 설정 (바닐라 JS) * 읽기 모드와 편집 모드에 따라 다르게 동작합니다. */ function setupControlBox(mode) { const categoryBox = document.querySelector('.controlbox-category'); const hashtagBox = document.querySelector('.controlbox-hashtag'); if (!categoryBox || !hashtagBox) return; // 컨트롤 박스가 없는 페이지면 종료 if (mode === 'edit') { // 편집 모드: 클릭하면 팝업을 열도록 onclick 속성 추가 categoryBox.setAttribute('onclick', 'openPopup(this)'); hashtagBox.setAttribute('onclick', 'openPopup(this)'); // 현재 baseData 기준으로 컨트롤 박스 UI 텍스트 업데이트 updateControlBoxDisplay(); // 팝업에 표시될 카테고리/해시태그 목록을 서버에서 미리 가져옴 fetchCategoriesAndHashtags(); } else { // 읽기 모드: 클릭 이벤트 제거 및 UI를 읽기 전용으로 설정 categoryBox.removeAttribute('onclick'); hashtagBox.removeAttribute('onclick'); categoryBox.classList.remove('btn-example'); hashtagBox.classList.remove('btn-example'); // 카테고리 데이터를 HTML로 렌더링 const categoryContent = `${baseData.category || '지정되지 않음'}`; categoryBox.innerHTML = `CATEGORY
${categoryContent}
`; // 해시태그 데이터를 HTML로 렌더링 (태그가 여러 개일 수 있으므로 split/map/join 사용) let hashtagContent = ''; if (baseData.tags && baseData.tags.length > 0) { hashtagContent = baseData.tags.split(',') .map(tag => `#${tag.trim()}`) .join(' '); // 각 태그를 공백으로 구분 } else { hashtagContent = '없음'; } hashtagBox.innerHTML = `HASHTAGS
${hashtagContent}
`; } } /** * 팝업 목록에 채울 데이터를 서버에서 가져옴 (바닐라 JS - fetch) */ function fetchCategoriesAndHashtags() { // 1. 카테고리 목록 가져오기 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; // 클릭 시 임시 변수(stagedCategory)에 값을 넣고 UI 갱신 el.onclick = function() { stagedCategory = tag; renderStagedCategory(); }; list.appendChild(el); }); } } }).catch(err => console.error('Error fetching categories:', err)); // 2. 해시태그 목록 가져오기 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}`; // 클릭 시 임시 배열(stagedHashtags)에 값을 추가하고 UI 갱신 el.onclick = function() { if (addTagToStaged(rawTag)) { renderStagedHashtags(); } }; list.appendChild(el); }); } } }).catch(err => console.error('Error fetching hashtags:', err)); } /** * Quill 에디터에 콘텐츠(Delta 또는 HTML)를 로드합니다. (바닐라 JS) */ function loadContent(content) { try { // 1. JSON (Quill Delta 형식)인지 확인 const delta = JSON.parse(content); if (delta && Array.isArray(delta.ops)) { quill.setContents(delta); // Delta 형식이면 setContents로 로드 return; } } catch (e) { // 2. JSON 파싱 실패 시: 일반 HTML 문자열로 간주하고 붙여넣기 quill.clipboard.dangerouslyPasteHTML(content); } } /* ============================================= */ /* --- 서버 통신 및 데이터 처리 --- */ /* ============================================= */ /** * 게시물 수정 페이지로 이동합니다. (바닐라 JS) */ function loadEditor() { if (baseData.id) { location.href = `${getMainPath()}/blog/edit/${baseData.id}`; } } /** * 작성된 게시물을 서버에 저장합니다. (바닐라 JS) * (이 함수는 이미 바닐라 XHR을 사용하는 post() 헬퍼 함수를 쓰므로 수정 불필요) */ async function save() { const titleField = document.getElementById('title_field'); // 1. 원본 baseData를 복사하여 전송용 객체(dataToSend) 생성 let dataToSend = JSON.parse(JSON.stringify(baseData)); // 2. 모든 텍스트 필드를 URL 전송을 위해 인코딩합니다. if (titleField) { dataToSend.title = encodeURIComponent(titleField.value); } else { dataToSend.title = encodeURIComponent(dataToSend.title || ''); } dataToSend.content = encodeURIComponent(JSON.stringify(quill.getContents())); // Quill 콘텐츠는 JSON 문자열로 변환 후 인코딩 dataToSend.category = encodeURIComponent(dataToSend.category || 'none'); dataToSend.tags = encodeURIComponent(dataToSend.tags || ''); dataToSend.posting = document.getElementById('post-published-switch').checked // 3. 현재 위치 좌표를 '수정 좌표'로 업데이트 dataToSend.modifyLat = currentLat; dataToSend.modifyLon = currentLon; // 4. 새 글일 경우 '최초 좌표'에도 기록 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 (await showConfirm("확인","해당 내용으로 저장하시겠습니까?")) { console.log("Data being sent to server:", dataToSend); // 5. 서버로 전송 (바닐라 XHR 헬퍼 함수 사용) post(uploadUrl, serverData.enc, JSON.stringify(dataToSend), serverData.keyword, async function (resultData) { try { const response = JSON.parse(resultData); if (response.resultCode === 0 && response.data && response.data.postId) { // 6. 저장 성공 시: 서버가 돌려준 새 ID를 이용해 뷰어 페이지로 이동 if (await showConfirm("알림", "저장되었습니다. 게시물 보기 페이지로 이동합니다.")) { location.href = getMainPath() + "/blog/viewer/" + response.data.postId; } } else { showAlert("알림", "저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error")); } } catch (e) { console.error("Failed to parse save response:", e, resultData); showAlert("알림", "저장에 성공했으나 서버 응답을 처리할 수 없습니다."); } }); } } /** * 사용자의 현재 GPS 위치를 가져옵니다. (바닐라 Browser API) */ function getLocation() { // 1. 이미 저장된 좌표가 있으면 해당 좌표로 주소 변환 API 호출 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 += `
${result.features[0].properties.formatted}
`; // 주소 inh += `
Lat: ${baseData.firstPostLat.toFixed(2)}
`; 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) { // 2. 저장된 좌표가 없으면 (새 글 작성 시) 브라우저에 GPS 요청 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; // UI에 좌표 표시 const locationField = document.getElementById('location_field'); if (locationField) { locationField.innerHTML = `LOCATION` + `
Lat: ${baseData.firstPostLat.toFixed(2)}
Lon: ${baseData.firstPostLon.toFixed(2)}
`; } }); } else { // 3. GPS 지원 안 하는 브라우저 const locationField = document.getElementById('location_field'); if (locationField) { locationField.innerHTML = `LOCATION` + `
좌표 지원 안함
`; } } } /* ============================================= */ /* --- 팝업 제어 함수들 --- */ /* ============================================= */ /** * 팝업 레이어를 엽니다. (바닐라 JS) */ function openPopup(element) { // 1. 클릭된 요소(element)의 'to' 속성 (예: "#loginPopup") 값을 읽어옴 const targetId = element.getAttribute('to'); const popup = document.querySelector(targetId); // 예: document.querySelector("#loginPopup") // 2. 오버레이(dim) 레이어를 찾음 (common.css의 .dim_layer 또는 main.css의 .login_overlay 둘 다 지원) const overlay = document.querySelector('.dim_layer') || document.querySelector('.login_overlay'); if (popup && overlay) { // 3. (신규) 팝업 열기 전, 임시 변수(staging)를 현재 baseData 값으로 초기화 if (targetId === '#popLayer1') { // 카테고리 팝업 stagedCategory = baseData.category || 'none'; renderStagedCategory(); } else if (targetId === '#popLayer2') { // 해시태그 팝업 stagedHashtags = baseData.tags ? baseData.tags.split(',').filter(t => t.trim() !== '') : []; renderStagedHashtags(); } // 4. 팝업과 오버레이를 화면에 표시 overlay.style.display = 'block'; popup.style.display = 'block'; } } /** * 팝업 레이어를 닫습니다. (바닐라 JS) */ function closePopup() { // 1. 오버레이(dim) 레이어를 찾아 숨김 const overlay = document.querySelector('.dim_layer') || document.querySelector('.login_overlay'); if(overlay) overlay.style.display = 'none'; // 2. 모든 팝업 레이어(.pop_layer)를 찾아 숨김 document.querySelectorAll('.pop_layer').forEach(p => p.style.display = 'none'); // 3. (안전장치) 혹시 main.css의 .login_popup 클래스를 직접 쓴 경우도 강제 숨김 document.querySelectorAll('.login_popup').forEach(p => p.style.display = 'none'); } /* ============================================= */ /* --- 사이드바 및 페이지 이동 --- */ /* ============================================= */ /** * 게시물 상세 보기 페이지로 이동합니다. (바닐라 JS) */ function goToViewer(element) { if (element && element.id) { location.href = `${getMainPath()}/blog/viewer/${element.id}`; } } /** * 인기글 목록을 가져와 UI에 표시합니다. (바닐라 JS - fetch) */ 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에 표시합니다. (바닐라 JS - fetch) */ 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)); } /** * [수정] 로그인 폼 데이터를 서버에 전송합니다. * jQuery의 .val() 및 .is()를 바닐라 JS의 .value 및 .checked로 변경합니다. */ function submitLoginForm() { // 1. 바닐라 JS로 폼 필드 값 읽기 const data = { 'user_id': document.getElementById('loginId').value, 'user_pw': document.getElementById('loginPassword').value, 'rememberMe': document.getElementById('rememberMe').checked, }; // 2. 서버로 전송 (이 함수는 이미 바닐라 XHR을 사용하므로 수정 불필요) postLogin(`${getMainPath()}/user/login.bjx`, serverData.enc, JSON.stringify(data), serverData.keyword, function(response) { if (response.isOk) { location.reload(); // 로그인 성공 시 페이지 새로고침 } else { showAlert(`로그인 실패`, `${response.resultMsg}`); } }); } // --- 페이지 이동(Navigation) 함수들 (바닐라 JS) --- function gotoHome() { document.location.replace(`${getMainPath()}/home.bs`); } function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); } function gotoModify() { document.location.replace(`${getMainPath()}/blog/posts`); } function gotoWhere() { document.location.replace(`${getMainPath()}/bums/where.bs`); } function gotoBUMSpace() { document.location.replace(`${getMainPath()}/bums/face.bs`); } function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`); } // [추가] 네모로직 업로드 페이지로 이동하는 함수 function gotoPuzzleUpload() { document.location.replace(`${getMainPath()}/puzzle/upload.bs`); } function gotoSudoKuGen() { document.location.replace(`${getMainPath()}/puzzle/sudoku_gen.bs`); } async function onclickJoin(type, keyword) { let user_id = document.getElementById('user_id') let user_pw = document.getElementById('user_pw') let user_pw_check = document.getElementById('user_pw_check') let user_name = document.getElementById('user_name') let user_email = document.getElementById('user_email') var fields = [user_id,user_pw, user_pw_check, user_name, user_email] var hasValues = true const spPattern = /[~!@#$%<>^&*]/; //특수문자 const korean = /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/; //한글 const eng = /[a-zA-Z]/; //영어 const numbers = /[0-9]/; //숫자 const email = /[A-za-z0-9\-][A-Za-z0-9_.\-]+@[A-za-z0-9\-][A-Za-z0-9\-]+\.[A-za-z0-9\-][A-za-z0-9\-]+/; fields.forEach(function (field , idx , all) { if ((field.value.length > 7 || (field===user_name && user_name.value.length > 2) || (field===user_id && user_id.value.length > 6)) && hasValues) { const text = field.value switch (field) { case user_id : if (korean.test(text)) { hasValues = false showAlert("알림","id를 확인 해보슈."); } break; case user_pw : if ( korean.test(text) || false === numbers.test(text) || false === eng.test(text) || false === spPattern.test(text) ) { hasValues = false showAlert("알림","pw 한글 노노 영문 숫자 특문(~!@#$%<>^&*) 섞으셈."); } break case user_email : if(false === email.test(field.value)) { hasValues = false showAlert("알림","email를 확인 해보슈."); } break } } else if (hasValues) { hasValues = false switch (field) { case user_id : showAlert("알림","id를 확인 해보슈.");break case user_pw : showAlert("알림","pw를 확인 해보슈.");break case user_pw_check : showAlert("알림","pw를 확인 해보슈.");break case user_name : showAlert("알림","name를 확인 해보슈.");break case user_email : showAlert("알림","email를 확인 해보슈.");break } } }) if (hasValues) { let data = { 'user_id': user_id.value, 'user_pw': user_pw.value, 'user_email': user_email.value, 'user_name': user_name.value } if (user_pw.value === user_pw_check.value) { if(await showConfirm("확인",JSON.stringify(data) + "\n해당 내용으로\n유저 등록 하실??")) { post("joinUser.bjx",type,JSON.stringify(data),keyword, function (resultData) { showAlert("알림",resultData) }) } else { } } else { showAlert("알림","비번이 다름요") } } } /** * 로그아웃을 처리합니다. (바닐라 JS) * (Spring Security의 CSRF 토큰을 읽어 form과 함께 전송합니다) */ function logout() { const form = document.createElement('form'); form.method = 'POST'; form.action = `${getMainPath()}/user/logout.bs`; // CSRF 토큰을 태그에서 읽어옴 (includes.html에 정의됨) 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(); // 로그아웃 요청 } /* ============================================= */ /* --- 서버 통신 유틸리티 (수정 불필요) --- */ /* ============================================= */ // 이 함수들은 이미 jQuery 없이 바닐라 JS (XMLHttpRequest)로 작성되어 있습니다. function getMainPath() { return location.protocol + "//" + location.hostname + (location.port ? ':' + location.port : ''); } /** * 커스텀 암호화 POST 전송 (바닐라 JS - XMLHttpRequest) */ 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 showAlert("알림",'Request Error!'); } }; httpRequest.open('POST', target, true); httpRequest.setRequestHeader("Content-Type", "text/plain"); // CSRF 토큰 추가 const csrfMeta = document.querySelector('meta[name="_csrf"]'); if (csrfMeta) { const csrfToken = csrfMeta.getAttribute('content'); if (csrfToken) { httpRequest.setRequestHeader("X-CSRF-TOKEN", csrfToken); } } const jsonPayloadString = JSON.stringify({ 'data': unformat(type, data, key), 'key': key, 'type': type, }); const utf8SafePayload = unescape(encodeURIComponent(jsonPayloadString)); httpRequest.send(btoa(utf8SafePayload)); // Base64 인코딩하여 전송 } /** * 커스텀 암호화 POST (로그인용) (바닐라 JS - XMLHttpRequest) */ 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 { showAlert("알림",'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 = ["|*-*|", key, "|*-*|"].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(""); } } /* ============================================= */ /* --- UI 헬퍼 및 팝업 내부 로직 --- */ /* ============================================= */ // (이 함수들은 모두 바닐라 JS이므로 수정 불필요) /** * (신규 추가) 중복 방지 태그 추가 (사용 안 함) */ 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) { const categoryContent = (baseData.category && baseData.category !== 'none') ? `${baseData.category}` : '카테고리 설정'; categoryBox.innerHTML = `CATEGORY
    ${categoryContent}
    `; } if (hashtagBox) { let hashtagContent = ''; if (baseData.tags && baseData.tags.length > 0) { hashtagContent = baseData.tags.split(',') .map(t => `#${t.trim()}`) .join(' '); } else { hashtagContent = '해시태그 편집'; } hashtagBox.innerHTML = `HASHTAGS
    ${hashtagContent}
    `; } } /** 1. Staging Category 렌더링: 선택된 카테고리(임시 변수)를 팝업 UI에 표시 */ function renderStagedCategory() { const area = document.getElementById('selected-category-area'); if (area) { if (stagedCategory && stagedCategory !== 'none') { 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) => { 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; } /* ============================================= */ /* --- 투표 및 댓글 기능 --- */ /* ============================================= */ // (이 함수들은 모두 바닐라 JS - fetch API로 작성되어 수정 불필요) /** * '좋아요' 또는 '싫어요' 투표 처리 (바닐라 JS - fetch) */ function handleVote(buttonElement, voteType) { const controls = buttonElement.closest('.vote-controls'); const postId = controls.dataset.postId; controls.querySelectorAll('button').forEach(btn => btn.disabled = true); // 중복 클릭 방지 let url = `${getMainPath()}/blog/post/${postId}/${voteType === 'like' ? 'like' : 'unlike'}.bjx`; // CSRF 토큰 준비 let headers = { 'Content-Type': 'application/json' }; const csrfMeta = document.querySelector('meta[name="_csrf"]'); if (csrfMeta) { const csrfToken = csrfMeta.getAttribute('content'); if (csrfToken) { headers['X-CSRF-TOKEN'] = csrfToken; } } // fetch API로 POST 요청 fetch(url, { method: 'POST', headers: headers }) .then(response => { if (!response.ok) { throw new Error('Network response was not ok'); } return response.json(); }) .then(data => { // 성공 시: 카운트 숫자 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; controls.querySelectorAll('button').forEach(btn => btn.disabled = false); // 버튼 다시 활성화 }) .catch(error => { // 실패 시 console.error('Error handling vote:', error); showAlert("알림",'투표 중 오류가 발생했습니다.'); controls.querySelectorAll('button').forEach(btn => btn.disabled = false); // 버튼 다시 활성화 }); } /** * 댓글 등록 처리 함수 (대댓글 지원) * (이 함수는 이미 바닐라 XHR을 사용하는 post() 헬퍼 함수를 쓰므로 수정 불필요) */ function submitComment() { const commentInput = document.getElementById('comment-input'); if (!commentInput) return; const content = commentInput.value.trim(); if (content.length === 0) { showAlert("알림",'댓글 내용을 입력하세요.'); commentInput.focus(); return; } // 전역 변수(currentReplyParentId)를 읽어 대댓글 여부 결정 const commentData = { content: content, writer: null, parentId: currentReplyParentId, // null 또는 부모 댓글 ID postId: null, id: null }; const postId = serverData.id; if (!postId) { showAlert("알림","게시물 ID를 찾을 수 없습니다."); return; } const uploadUrl = `${getMainPath()}/blog/posts/${postId}/comments.bjx`; const encType = serverData.enc; const keyword = serverData.keyword; try { // 서버로 전송 (바닐라 XHR 헬퍼 함수 사용) post(uploadUrl, encType, JSON.stringify(commentData), keyword, function(resultData) { try { const response = JSON.parse(resultData); if (response.resultCode === 0) { showAlert("알림",'댓글이 성공적으로 등록되었습니다.'); commentInput.value = ''; // 입력창 초기화 cancelReply(); // 답글 상태 초기화 fetchComments(postId); // 목록 새로고침 } else { showAlert("알림",'댓글 등록 실패: ' + (response.resultMsg || '알 수 없는 오류')); } } catch (e) { console.error('Failed to parse comment submission response:', e, resultData); } }); } catch (err) { console.error('Error during comment submission:', err); } } /** * 댓글 목록 및 대댓글 목록을 계층형으로 가져오는 함수 (바닐라 JS - fetch, async/await) */ 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"를 (await로) 호출합니다. 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 문자열을 생성하는 헬퍼 함수 (바닐라 JS) */ 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')}`; // XSS 방지를 위해 HTML 태그를 엔티티로 치환 (간단 버전) const safeContent = String(comment.content).replace(//g, ">").replace(/\n/g, "
    "); const writerNameForReply = String(writerName).replace(/'/g, "\\'"); // ' 문자가 JS를 깨트리는 것을 방지 // 대댓글에는 "답글달기" 버튼을 생성하지 않음 (3단계 이상 미지원) const replyButtonHTML = !isReply ? `` : ''; return `
    ${writerName} ${formattedDate}
    ${replyButtonHTML}

    ${safeContent}

    `; } /** * "답글 달기" 버튼 클릭 시 호출 (바닐라 JS) * 전역 변수에 부모 ID를 설정하고 UI(상태바)를 업데이트합니다. */ function setReplyTarget(commentId, writerName) { currentReplyParentId = commentId; // 전역 변수(상태) 설정 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(); // 입력창으로 포커스 이동 } /** * 답글 달기 "취소" 시 호출 (바닐라 JS) */ function cancelReply() { currentReplyParentId = null; // 상태 초기화 const statusBar = document.getElementById('reply-status-bar'); if (statusBar) { statusBar.style.display = 'none'; // 상태바 숨기기 } } /** * ============================================== * user.js (공통 API 및 유틸리티 모듈) * (모든 게임 페이지에서 공통으로 로드됨) * ============================================== */ /** * [신규] 통합 랭킹 API (POST /api/ranks/submit)를 호출하는 공통 함수 * 모든 게임(2048, 스도쿠, 스파이더, 노노그램)이 이 함수를 사용합니다. * * @param {string} gameType - (필수) GameType Enum (예: 'GAME_2048', 'SUDOKU', 'SPIDER', 'NONOGRAM') * @param {string | null} contextId - (선택) 게임의 세부 ID (예: 스도쿠/노노그램 퍼즐 ID) * @param {string} playerName - (필수) 사용자 이름 * @param {number} primaryScore - (필수) 주 점수 (게임별 의미 다름: 2048=점수, 스도쿠=시간) * @param {number | null} secondaryScore - (선택) 보조 점수 (예: 스파이더=시간, 노노그램=남은포인트) * @returns {Promise} 저장된 랭킹 데이터 (JSON) */ async function submitRank(gameType, contextId, playerName, primaryScore, secondaryScore = null) { const rankDto = { gameType: gameType, contextId: contextId, playerName: playerName, primaryScore: primaryScore, secondaryScore: secondaryScore }; console.log("Submitting Rank:", rankDto); const response = await fetch('/api/ranks/submit', { // ★ 통합 API 엔드포인트 method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rankDto), }); if (!response.ok) { // [수정] 서버가 에러 메시지를 본문에 보냈을 경우, 해당 메시지를 에러로 throw const errorMessage = await response.text(); throw new Error(errorMessage || '랭킹 등록에 실패했습니다.'); } return response.json(); } /** * [신규] 통합 랭킹 API (GET /api/ranks/list)를 호출하는 공통 함수 * * @param {string} gameType - (필수) GameType Enum * @param {string | null} contextId - (선택) 조회할 세부 ID * @returns {Promise} 랭킹 배열 (JSON) */ async function fetchRanks(gameType, contextId = null) { // contextId가 null이거나 undefined일 경우 "null" 문자열로 전송되는 것을 방지 const contextParam = (contextId !== null && contextId !== undefined) ? `&contextId=${contextId}` : ''; const response = await fetch(`/api/ranks/list?gameType=${gameType}${contextParam}`); // ★ 통합 API 엔드포인트 if (!response.ok) { throw new Error('랭킹 로드에 실패했습니다.'); } return response.json(); } /** * [핵심] 통합 게임 성공 모달을 표시하고 랭킹 관련 로직을 처리하는 함수 * @param {object} options - 게임 결과 정보 * @param {string} options.gameType - GameType Enum (예: 'SUDOKU') * @param {string|null} options.contextId - 게임 세부 ID (예: 퍼즐 ID) * @param {string} options.successMessage - 모달에 표시할 메시지 (예: "1분 20초만에 클리어!") * @param {number} options.primaryScore - 랭킹에 등록할 주 점수 * @param {number|null} options.secondaryScore - 랭킹에 등록할 보조 점수 */ async function showGameSuccessModal(options) { const { gameType, contextId, successMessage, primaryScore, secondaryScore } = options; // 1. 모달의 DOM 요소 가져오기 const modal = document.getElementById('unified-game-success-modal'); const messageEl = document.getElementById('ugsm-message'); const rankingListEl = document.getElementById('ugsm-ranking-list'); // ... (나머지 요소 가져오기는 기존과 동일) const guestArea = document.getElementById('ugsm-guest-ranking'); const userArea = document.getElementById('ugsm-user-ranking'); const playerNameInput = document.getElementById('ugsm-player-name'); const saveBtn = document.getElementById('ugsm-save-score-btn'); // 닫기 버튼은 공통 로직으로 처리되므로 여기서 제어할 필요가 없습니다. // 2. 성공 메시지 설정 messageEl.textContent = successMessage; // 3. 랭킹 목록 표시 (footer.html의 updateGameRanking과 유사) rankingListEl.innerHTML = '
  • 로딩 중...
  • '; try { const ranks = await fetchRanks(gameType, contextId); rankingListEl.innerHTML = ''; if (ranks.length > 0) { ranks.forEach((rank, index) => { const li = document.createElement('li'); // footer.html의 점수 포맷 함수 재사용 const formattedScore = formatScore(rank.primaryScore, rank.gameType); li.innerHTML = `${index + 1}. ${rank.playerName} ${formattedScore}`; rankingListEl.appendChild(li); }); } else { rankingListEl.innerHTML = '
  • 아직 등록된 랭킹이 없습니다.
  • '; } } catch (e) { rankingListEl.innerHTML = '
  • 랭킹을 불러오는데 실패했습니다.
  • '; } if (typeof currentUser !== 'undefined' && currentUser.isLoggedIn) { // 로그인 상태일 경우 guestArea.style.display = 'none'; userArea.style.display = 'block'; // 서버에 랭킹 즉시 자동 제출 try { await submitRank(gameType, contextId, currentUser.username, primaryScore, secondaryScore); // 성공 후 랭킹 목록 새로고침 const updatedRanks = await fetchRanks(gameType, contextId); // (랭킹 목록 업데이트 로직 추가...) } catch (error) { console.error('Auto rank submission failed:', error); userArea.innerHTML = '

    랭킹 자동 등록에 실패했습니다.

    '; } } else { // 비로그인 상태일 경우 guestArea.style.display = 'block'; userArea.style.display = 'none'; playerNameInput.value = ''; // '점수 저장' 버튼에 이벤트 리스너 할당 (중복 할당 방지를 위해 기존 리스너 제거) const newSaveBtn = saveBtn.cloneNode(true); saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); newSaveBtn.addEventListener('click', async () => { const playerName = playerNameInput.value.trim(); if (!playerName) { showAlert("알림",'이름을 입력해주세요.'); return; } newSaveBtn.disabled = true; newSaveBtn.textContent = '저장 중...'; try { await submitRank(gameType, contextId, playerName, primaryScore, secondaryScore); showAlert("알림",'랭킹이 등록되었습니다!'); // ▼▼▼ [핵심 수정] 이 부분을 바꿔주세요 ▼▼▼ // 기존 코드: modal.style.display = 'none'; closePopup(); // 배경(dim)과 팝업을 모두 닫는 공통 함수 호출 // ▲▲▲ 여기까지 수정 ▲▲▲ } catch (error) { showAlert("알림",'랭킹 등록에 실패했습니다: ' + error.message); newSaveBtn.disabled = false; newSaveBtn.textContent = '점수 저장'; } }); } // ▼▼▼ [핵심 수정] 모달을 직접 조작하는 대신, 공통 오버레이와 팝업을 표시합니다. ▼▼▼ const overlay = document.querySelector('.dim_layer'); if (modal && overlay) { overlay.style.display = 'block'; modal.style.display = 'block'; } // ▲▲▲ 여기까지 수정 ▲▲▲ } /** * 게임 타입에 따라 점수 표시 형식을 변경합니다. * SUDOKU, NONOGRAM처럼 시간 기반 게임은 mm:ss 형식으로, * 그 외에는 점수 형식으로 변환합니다. */ function formatScore(score, gameType) { if (['SUDOKU', 'NONOGRAM'].includes(gameType)) { const minutes = Math.floor(score / 60).toString().padStart(2, '0'); const seconds = (score % 60).toString().padStart(2, '0'); return `${minutes}:${seconds}`; } if (gameType === 'SPIDER') { return `${score} moves`; } return `${score} 점`; } /** * 사이트 공통 스타일을 적용한 커스텀 알림(Alert) 함수 * @param {string} title - 팝업의 제목 * @param {string} text - 팝업의 내용 * @param {string} icon - 'success', 'error', 'warning', 'info', 'question' 중 하나 */ function showAlert(title, text, icon = 'info') { Swal.fire({ title: title, text: text, icon: icon, confirmButtonColor: '#FFA500', // main.css의 --point-color confirmButtonText: '확인' }); } /** * 사이트 공통 스타일을 적용한 커스텀 확인(Confirm) 함수 * @param {string} title - 팝업의 제목 * @param {string} text - 팝업의 내용 * @returns {Promise} 사용자가 '확인'을 누르면 true, '취소'를 누르면 false를 반환 */ async function showConfirm(title, text) { const result = await Swal.fire({ title: title, text: text, icon: 'question', showCancelButton: true, confirmButtonColor: '#FFA500', cancelButtonColor: '#555555', // main.css의 --button-alt-default confirmButtonText: '확인', cancelButtonText: '취소' }); return result.isConfirmed; } function sendTlg(form, type,keyword) { console.log(form) let data = { 'name': form.querySelector("#name").value, 'email': form.querySelector("#email").value, 'message': form.querySelector("#message").value, } if (data.name != null && data.email != null && data.message != null && data.message.length > 0) { if(confirm(JSON.stringify(data) + "\n해당 내용으로\n메시지 보내쉴?")) { post(getMainPath()+"/tlg/repotToMe.bjx",type,JSON.stringify(data),keyword, function (resultData) { showAlert("서버에 전달됨.") }) } else { } } return false } async function checkUnreadMessages() { const isLoggedIn = !!document.querySelector('a[href="javascript:logout()"]'); if (!isLoggedIn) return; // 비로그인 상태면 실행 중단 try { const response = await fetch('/messages/unread-count'); if (response.ok) { const data = await response.json(); if (data.count > 0) { const icon = document.getElementById('message-icon'); if (icon) { icon.style.display = 'inline-block'; // 아이콘 표시 } } } } catch (error) { console.error('Failed to check for unread messages:', error); } } function handleBookmarkVote(buttonElement, voteType) { const controls = buttonElement.closest('.vote-controls'); const bookmarkId = controls.dataset.bookmarkId; controls.querySelectorAll('button').forEach(btn => btn.disabled = true); // 중복 클릭 방지 // [수정] 북마크용 API 엔드포인트 사용 const url = `${getMainPath()}/bookmarks/${bookmarkId}/${voteType === 'like' ? 'like' : 'unlike'}`; // CSRF 토큰 준비 const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content'); const headers = { 'X-CSRF-TOKEN': csrfToken }; fetch(url, { method: 'POST', headers: headers }) .then(res => res.json()) .then(data => { controls.querySelector('.like-count').innerText = data.voteCount; controls.querySelector('.unlike-count').innerText = data.unlikeCount; }) .catch(error => console.error('Error handling bookmark vote:', error)) .finally(() => { controls.querySelectorAll('button').forEach(btn => btn.disabled = false); }); } /** * 특정 북마크의 댓글 섹션을 열거나 닫습니다. */ function toggleCommentSection(bookmarkId) { const section = document.getElementById(`comment-section-${bookmarkId}`); if (section.style.display === 'none') { section.style.display = 'block'; fetchBookmarkComments(bookmarkId); // 처음 열 때 댓글 로드 } else { section.style.display = 'none'; } } /** * 특정 북마크의 댓글 목록을 불러옵니다. */ async function fetchBookmarkComments(bookmarkId) { const listContainer = document.getElementById(`comments-list-${bookmarkId}`); listContainer.innerHTML = '댓글 로딩 중...'; const response = await fetch(`${getMainPath()}/bookmarks/${bookmarkId}/comments`); const data = await response.json(); listContainer.innerHTML = ''; if (data.resultCode === 0 && data.comments.length > 0) { data.comments.forEach(comment => { // 기존 블로그 댓글 HTML 생성 함수 재사용 listContainer.innerHTML += createCommentHTML(comment); }); } else { listContainer.innerHTML = '아직 댓글이 없습니다.'; } } /** * 북마크에 댓글을 등록합니다. */ function submitBookmarkComment(bookmarkId) { const input = document.getElementById(`comment-input-${bookmarkId}`); const content = input.value.trim(); if (!content) { showAlert('알림', '댓글 내용을 입력하세요.'); return; } // 블로그 댓글과 동일한 DTO 및 암호화 방식 사용 const commentData = { content: content, parentId: null }; const uploadUrl = `${getMainPath()}/bookmarks/${bookmarkId}/comments`; // 기존 `post` 유틸리티 함수를 재사용하여 서버에 전송 post(uploadUrl, serverData.enc, JSON.stringify(commentData), serverData.keyword, (resultData) => { const response = JSON.parse(resultData); if (response.resultCode === 0) { input.value = ''; fetchBookmarkComments(bookmarkId); // 댓글 목록 새로고침 } else { showAlert('오류', '댓글 등록에 실패했습니다: ' + response.resultMsg); } }); } /** * 북마크 클릭 시 사용자에게 선택지를 보여주는 함수 * @param {HTMLElement} element - 클릭된 요소 */ async function showBookmarkOptions(element) { const url = element.dataset.url; const title = element.dataset.title; const result = await Swal.fire({ title: '어떻게 보시겠어요?', text: title, icon: 'question', showDenyButton: true, confirmButtonText: '새 탭에서 열기', denyButtonText: '여기서 보기 (Iframe)', confirmButtonColor: '#3085d6', denyButtonColor: '#555', }); if (result.isConfirmed) { // '새 탭에서 열기' 선택 시 window.open(url, '_blank'); } else if (result.isDenied) { // '여기서 보기 (Iframe)' 선택 시 openBookmarkInIframe(url, title); } } /** * iframe 로드 실패 시 일관된 처리를 위한 헬퍼 함수 * @param {string} title - 북마크 제목 * @param {string} url - 북마크 URL */ function handleIframeLoadFailure(title, url) { closePopup(); // 팝업 닫기 if (confirm(`'${title}' 페이지를 내부에서 여는 데 실패했습니다.\n\n새 탭에서 여시겠습니까?`)) { window.open(url, '_blank'); } } /** * 지정된 URL을 Iframe 팝업으로 여는 함수 (try-catch 로직 적용) * @param {string} url - 표시할 URL * @param {string} title - 표시할 제목 */ function openBookmarkInIframe(url, title) { const popup = document.getElementById('iframe-viewer-popup'); const titleElement = document.getElementById('iframe-viewer-title'); const iframe = document.getElementById('bookmark-iframe'); const overlay = document.querySelector('.dim_layer'); const newTabLink = document.getElementById('iframe-open-new-tab-link'); if (!popup || !titleElement || !iframe || !overlay || !newTabLink) { console.error('Iframe viewer elements not found!'); return; } // iframe의 로딩을 시작하기 전에 src를 초기화하여 이전 상태를 지웁니다. iframe.src = 'about:blank'; // iframe의 onload 이벤트 핸들러 iframe.onload = () => { console.log("iframe onload 이벤트 발생. 내부 문서 접근을 시도합니다..."); try { // 동일 출처 정책(Same-Origin Policy)을 위반하는 접근 시도 // 이 코드가 오류를 발생시키면, 다른 출처의 문서가 로드된 것 (성공 또는 오류 페이지) const dummyAccess = iframe.contentWindow.location.href; // 만약 위 코드에서 오류가 발생하지 않았다면, iframe이 동일 출처이거나 비어있다는 의미. // 외부 사이트 로드는 실패한 것으로 간주합니다. console.warn("iframe 접근이 차단되지 않았습니다. 로드 실패로 간주합니다."); handleIframeLoadFailure(title, url); } catch (e) { // SecurityError가 발생! 다른 출처의 문서가 성공적으로 로드되었다고 간주합니다. // (이것이 실제 콘텐츠일 수도, 브라우저의 오류 페이지일 수도 있습니다) console.log("iframe 접근이 보안 정책에 의해 차단되었습니다. 일단 성공으로 간주합니다.", e); // 팝업을 그대로 유지 } }; // 네트워크 오류 등으로 iframe 로드 자체가 실패했을 때를 위한 핸들러 iframe.onerror = () => { console.error("iframe onerror 이벤트 발생. 로드 실패로 처리합니다."); handleIframeLoadFailure(title, url); }; // 제목과 새 탭 링크 설정 titleElement.textContent = title; newTabLink.href = url; // 실제 URL로 로딩 시작 iframe.src = url; // 팝업과 오버레이 표시 overlay.style.display = 'block'; popup.style.display = 'block'; } // 팝업과 폼 필드를 연결하기 위한 전역 변수 let bookmarkPopupTargets = { displayId: null, inputId: null }; let stagedBookmarkCategory = ''; let stagedBookmarkTags = []; /** * 북마크 카테고리 팝업을 여는 함수 * @param {string} displayId - 선택된 카테고리를 보여줄 div의 ID * @param {string} inputId - 실제 값을 저장할 hidden input의 ID */ async function openBookmarkCategoryPopup(displayId, inputId) { bookmarkPopupTargets = { displayId, inputId }; // 현재 작업 대상 필드를 저장 const currentCategory = document.getElementById(inputId).value; stagedBookmarkCategory = currentCategory || ''; renderStagedBookmarkCategory(); // 기존 카테고리 목록 불러오기 const listEl = document.getElementById('bookmark-category-list'); listEl.innerHTML = '로딩...'; try { const response = await fetch('/api/bookmarks/categories',{ headers: { 'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가 }, }); const categories = await response.json(); listEl.innerHTML = ''; categories.forEach(cat => { const tagEl = document.createElement('span'); tagEl.className = 'tag-item'; tagEl.textContent = cat; tagEl.onclick = () => { stagedBookmarkCategory = cat; renderStagedBookmarkCategory(); }; listEl.appendChild(tagEl); }); } catch (e) { listEl.innerHTML = '카테고리를 불러오는데 실패했습니다.'; } const dummyEl = document.createElement('div'); dummyEl.setAttribute('to', '#bookmark-category-popup'); openPopup(dummyEl); } document.getElementById('new-bookmark-category-input')?.addEventListener('keyup', e => { if (e.key === 'Enter') { stagedBookmarkCategory = e.target.value.trim(); renderStagedBookmarkCategory(); e.target.value = ''; } }); function renderStagedBookmarkCategory() { const area = document.getElementById('selected-bookmark-category-area'); area.innerHTML = stagedBookmarkCategory ? `${stagedBookmarkCategory} X` : '선택된 카테고리 없음'; } function applyBookmarkCategory() { // 1. 숨겨진 input 필드에 선택한 카테고리 값 저장 const inputEl = document.getElementById(bookmarkPopupTargets.inputId); if (inputEl) { inputEl.value = stagedBookmarkCategory; } // 2. 메인 수정 팝업의 표시 영역(display)을 업데이트 const displayEl = document.getElementById(bookmarkPopupTargets.displayId); if (displayEl) { if (stagedBookmarkCategory) { // 선택한 카테고리가 있으면 태그 아이템으로 표시 displayEl.innerHTML = `${stagedBookmarkCategory}`; } else { // 선택한 카테고리가 없으면 기본 텍스트로 복원 displayEl.innerHTML = `카테고리 선택`; } } // 3. 카테고리 선택 팝업만 닫기 document.getElementById('bookmark-category-popup').style.display = 'none'; } /** * 북마크 태그 팝업을 여는 함수 * @param {string} displayId - 선택된 태그를 보여줄 div의 ID * @param {string} inputId - 실제 값을 저장할 hidden input의 ID */ async function openBookmarkTagPopup(displayId, inputId) { bookmarkPopupTargets = { displayId, inputId }; const currentTags = document.getElementById(inputId).value; stagedBookmarkTags = currentTags ? currentTags.split(',').map(t => t.trim()) : []; renderStagedBookmarkTags(); // 기존 태그 목록 불러오기 const listEl = document.getElementById('bookmark-tag-list'); listEl.innerHTML = '로딩...'; try { const response = await fetch('/api/bookmarks/tags',{ headers: { 'Authorization': `Bearer ${serverData.token}` // 헤더에 토큰 추가 }, }); const tags = await response.json(); listEl.innerHTML = ''; tags.forEach(tag => { const tagEl = document.createElement('span'); tagEl.className = 'tag-item'; tagEl.textContent = '#' + tag; tagEl.onclick = () => addStagedBookmarkTag(tag); listEl.appendChild(tagEl); }); } catch (e) { listEl.innerHTML = '태그를 불러오는데 실패했습니다.'; } const dummyEl = document.createElement('div'); dummyEl.setAttribute('to', '#bookmark-tag-popup'); openPopup(dummyEl); } document.getElementById('new-bookmark-tag-input')?.addEventListener('keyup', e => { if (e.key === 'Enter') { addStagedBookmarkTag(e.target.value.trim()); e.target.value = ''; } }); function addStagedBookmarkTag(tag) { if (tag && !stagedBookmarkTags.includes(tag)) { stagedBookmarkTags.push(tag); renderStagedBookmarkTags(); } } function removeStagedBookmarkTag(index) { stagedBookmarkTags.splice(index, 1); renderStagedBookmarkTags(); } function renderStagedBookmarkTags() { const area = document.getElementById('selected-bookmark-tags-area'); area.innerHTML = stagedBookmarkTags.map((tag, i) => `#${tag} X`).join(' ') || '선택된 태그 없음'; } function applyBookmarkTags() { const tagsString = stagedBookmarkTags.join(','); // 1. 숨겨진 input 필드에 선택한 태그 값들 저장 const inputEl = document.getElementById(bookmarkPopupTargets.inputId); if (inputEl) { inputEl.value = tagsString; } // 2. 메인 수정 팝업의 표시 영역(display)을 업데이트 const displayEl = document.getElementById(bookmarkPopupTargets.displayId); if (displayEl) { if (stagedBookmarkTags && stagedBookmarkTags.length > 0) { // 선택한 태그가 있으면 각 태그를 아이템으로 만들어 표시 displayEl.innerHTML = stagedBookmarkTags.map(tag => `#${tag}`).join(' '); } else { // 선택한 태그가 없으면 기본 텍스트로 복원 displayEl.innerHTML = `태그 선택`; } } // 3. 태그 선택 팝업만 닫기 document.getElementById('bookmark-tag-popup').style.display = 'none'; } /** * [수정된 최종 함수] '수정' 버튼 클릭 시 팝업을 열고 기존 북마크 데이터를 불러오는 함수 * @param {HTMLElement} buttonElement - 클릭된 버튼 요소 ('this') */ async function openBookmarkEditPopup(buttonElement) { // 1. [핵심 수정] 버튼 요소에서 실제 bookmarkId 값을 추출합니다. const bookmarkId = buttonElement.getAttribute('data-bookmark-id'); try { const response = await fetch(`/api/bookmarks/${bookmarkId}`, { headers: { 'Authorization': `Bearer ${serverData.token}` } }); if (!response.ok) { throw new Error('북마크 정보를 불러오는 데 실패했습니다.'); } const bookmark = await response.json(); // 2. 팝업창의 각 필드에 데이터 채우기 document.getElementById('edit-bookmark-id').value = bookmark.id; document.getElementById('edit-bookmark-title').value = bookmark.title || ''; document.getElementById('edit-bookmark-comment').value = bookmark.userComment || ''; document.getElementById('edit-bookmark-visibility').value = bookmark.visibility; const category = bookmark.category || ''; document.getElementById('edit-bookmark-category').value = category; document.getElementById('edit-bookmark-category-display').innerHTML = category ? `${category}` : '카테고리 선택'; const tags = bookmark.tags || []; document.getElementById('edit-bookmark-tags').value = tags.join(','); document.getElementById('edit-bookmark-tags-display').innerHTML = tags.map(t => `#${t}`).join(' ') || '태그 선택'; // 3. 이미지 목록 표시하기 (숨김/복구 기능 포함) const imagesListDiv = document.getElementById('edit-bookmark-images-list'); imagesListDiv.innerHTML = ''; let imageList = bookmark.images || []; if (imageList.length === 0 && bookmark.contentUrls && bookmark.contentUrls.length > 0) { imageList = bookmark.contentUrls.map(url => ({ url: url, isVisible: true })); } imageList.forEach(image => { const imageItem = document.createElement('div'); imageItem.className = `image-preview-item ${!image.isVisible ? 'is-hidden' : ''}`; imageItem.innerHTML = ` Bookmark Image `; imagesListDiv.appendChild(imageItem); }); // 4. 파일 추가 input에 이벤트 리스너 연결 const imageInput = document.getElementById('add-bookmark-image-input'); const newImageInput = imageInput.cloneNode(true); imageInput.parentNode.replaceChild(newImageInput, imageInput); newImageInput.addEventListener('change', (event) => { uploadBookmarkImages(bookmark.id, event.target.files); }); // 5. 팝업 열기 const dummyEl = document.createElement('div'); dummyEl.setAttribute('to', '#bookmark-edit-popup'); openPopup(dummyEl); } catch (error) { showAlert('오류', error.message, 'error'); } } async function submitGibberish() { const content = document.getElementById('gibberish-content').value; if (!content || content.trim().length === 0) { showAlert('알림', '내용을 입력해주세요.'); return; } if (content.length > 100) { showAlert('알림', '내용은 100자를 넘을 수 없습니다.'); return; } try { const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content') || ''; const response = await fetch('/gibberish', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ content: content }) }); if (response.ok) { showAlert('성공', '성공적으로 등록되었습니다!', 'success'); document.getElementById('gibberish-content').value = ''; // 필요하다면 페이지를 새로고침하여 새 Gibberish를 볼 수 있게 함 // location.reload(); } else { const errorData = await response.json(); showAlert('오류', `등록에 실패했습니다: ${errorData.message}`, 'error'); } } catch (error) { showAlert('오류', '네트워크 오류가 발생했습니다.', 'error'); } } /** * [수정된 함수] 이미지의 '보임/숨김' 상태를 서버에 업데이트하고 UI를 즉시 변경합니다. * @param {HTMLElement} button - 클릭된 버튼 요소 (this) * @param {string} bookmarkId - 북마크 ID * @param {string} imageUrl - 상태를 변경할 이미지의 URL */ async function toggleBookmarkImageVisibility(button, bookmarkId, imageUrl) { try { const response = await fetch(`/api/bookmarks/${bookmarkId}/images/visibility`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${serverData.token}`, 'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content') }, body: JSON.stringify({ imageUrl: imageUrl }) }); if (!response.ok) { throw new Error('이미지 상태 변경에 실패했습니다.'); } const updatedBookmark = await response.json(); // [핵심 수정] 더 이상 페이지 전체를 새로고침하지 않고, // 전달받은 'button' 요소를 기준으로 직접 UI를 변경합니다. if (button) { const imageItem = button.closest('.image-preview-item'); const imageInfo = updatedBookmark.images.find(img => img.url === imageUrl); if (imageInfo && imageItem) { // 버튼 아이콘과 부모 div의 'is-hidden' 클래스를 직접 제어합니다. button.innerHTML = imageInfo.isVisible ? '👁️' : '🚫'; imageItem.classList.toggle('is-hidden', !imageInfo.isVisible); } } } catch (error) { showAlert('오류', error.message, 'error'); } } /** * 북마크의 텍스트 정보(메타데이터)를 서버에 저장하는 함수 */ async function submitBookmarkUpdate() { const bookmarkId = document.getElementById('edit-bookmark-id').value; const dataToUpdate = { title: document.getElementById('edit-bookmark-title').value, userComment: document.getElementById('edit-bookmark-comment').value, visibility: document.getElementById('edit-bookmark-visibility').value, category: document.getElementById('edit-bookmark-category').value, tags: document.getElementById('edit-bookmark-tags').value.split(',').filter(t => t) // 빈 태그 제거 }; try { const response = await fetch(`/api/bookmarks/${bookmarkId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${serverData.token}`, 'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content') }, body: JSON.stringify(dataToUpdate) }); if (!response.ok) { throw new Error('북마크 업데이트에 실패했습니다.'); } showAlert('성공', '북마크가 성공적으로 업데이트되었습니다.', 'success'); closePopup(); location.reload(); // 페이지 새로고침하여 변경사항 확인 } catch (error) { showAlert('오류', error.message, 'error'); } } /** * 새로운 이미지를 서버에 업로드하고 북마크에 추가하는 함수 * @param {string} bookmarkId - 북마크 ID * @param {FileList} files - 사용자가 선택한 파일 목록 */ async function uploadBookmarkImages(bookmarkId, files) { if (!files.length) return; const formData = new FormData(); for (const file of files) { formData.append('files', file); } try { const response = await fetch(`/api/bookmarks/${bookmarkId}/images`, { // 새 API 엔드포인트 method: 'POST', headers: { 'Authorization': `Bearer ${serverData.token}`, 'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content') }, body: formData }); if (!response.ok) { throw new Error('이미지 업로드에 실패했습니다.'); } // 업로드 성공 후, 팝업 내용을 최신 정보로 다시 로드 showAlert('성공', '이미지가 추가되었습니다.', 'success'); openBookmarkEditPopup(bookmarkId); } catch (error) { showAlert('오류', error.message, 'error'); } } /** * 기존 이미지를 북마크에서 삭제하는 함수 * @param {string} bookmarkId - 북마크 ID * @param {string} imageUrl - 삭제할 이미지의 URL * @param {HTMLElement} buttonElement - 클릭된 삭제 버튼 */ async function deleteBookmarkImage(bookmarkId, imageUrl, buttonElement) { if (!await showConfirm('확인', '이 이미지를 정말 삭제하시겠습니까?')) { return; } try { const response = await fetch(`/api/bookmarks/${bookmarkId}/images`, { // 새 API 엔드포인트 method: 'DELETE', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${serverData.token}`, 'X-CSRF-TOKEN': document.querySelector('meta[name="_csrf"]').getAttribute('content') }, body: JSON.stringify({ imageUrl: imageUrl }) }); if (!response.ok) { throw new Error('이미지 삭제에 실패했습니다.'); } // 화면에서 즉시 이미지 제거 buttonElement.parentElement.remove(); showAlert('성공', '이미지가 삭제되었습니다.', 'success'); } catch (error) { showAlert('오류', error.message, 'error'); } } function handleDeletePost(postId) { const cleanPostId = postId.replace(/^"|"$/g, ''); if (confirm(`'${cleanPostId}' 게시물을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) { fetch(`/blog/post/${cleanPostId}`, { method: 'DELETE', headers: { [csrfHeader]: csrfToken } }) .then(response => { if (response.ok) { alert('게시물이 성공적으로 삭제되었습니다.'); // UI에서 해당 게시물 행을 즉시 제거 document.getElementById(`post-row-${cleanPostId}`).remove(); } else { return response.json().then(err => { throw new Error(err.message) }); } }) .catch(error => { console.error('Error:', error); alert('삭제 처리 중 오류가 발생했습니다: ' + error.message); }); } } /** * [신규 추가] 현재 수정 중인 게시물을 삭제하는 함수 * @param {string} postId 삭제할 게시물의 ID */ function deleteCurrentPost(buttonElement) { const postId = buttonElement.getAttribute('data-post-id'); // data-post-id 속성에서 ID를 읽어옵니다. if (!postId) { alert('삭제할 수 없는 게시물입니다.'); return; } if (confirm('정말로 이 게시물을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) { fetch(`/blog/post/${postId}`, { method: 'DELETE', headers: { [csrfHeader]: csrfToken } }) .then(response => response.json().then(data => ({ok: response.ok, data}))) .then(({ok, data}) => { if (ok) { alert('게시물이 삭제되었습니다.'); // 삭제 성공 후 게시물 목록 페이지로 이동 window.location.href = '/blog/posts'; } else { alert('삭제에 실패했습니다: ' + data.message); } }) .catch(error => { console.error('Error:', error); alert('삭제 중 오류가 발생했습니다.'); }); } }