/** * ================================================================================= * 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) { alert('로그인이 필요합니다.'); 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(); }); } }); /* --- (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/')) { 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); }); } /** * 에디터에 이미지 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() 헬퍼 함수를 쓰므로 수정 불필요) */ 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 (confirm("해당 내용으로 저장하시겠습니까?")) { console.log("Data being sent to server:", dataToSend); // 5. 서버로 전송 (바닐라 XHR 헬퍼 함수 사용) post(uploadUrl, serverData.enc, JSON.stringify(dataToSend), serverData.keyword, function(resultData) { try { const response = JSON.parse(resultData); if (response.resultCode === 0 && response.data && response.data.postId) { // 6. 저장 성공 시: 서버가 돌려준 새 ID를 이용해 뷰어 페이지로 이동 alert("저장되었습니다. 게시물 보기 페이지로 이동합니다."); location.href = getMainPath() + "/blog/viewer/" + response.data.postId; } else { alert("저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error")); } } catch (e) { console.error("Failed to parse save response:", e, resultData); alert("저장에 성공했으나 서버 응답을 처리할 수 없습니다."); } }); } } /** * 사용자의 현재 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 { alert(`로그인 실패: ${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 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`); } 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 alert("id를 확인 해보슈."); } break; case user_pw : if ( korean.test(text) || false === numbers.test(text) || false === eng.test(text) || false === spPattern.test(text) ) { hasValues = false alert("pw 한글 노노 영문 숫자 특문(~!@#$%<>^&*) 섞으셈."); } break case user_email : if(false === email.test(field.value)) { hasValues = false alert("email를 확인 해보슈."); } break } } else if (hasValues) { hasValues = false switch (field) { case user_id : alert("id를 확인 해보슈.");break case user_pw : alert("pw를 확인 해보슈.");break case user_pw_check : alert("pw를 확인 해보슈.");break case user_name : alert("name를 확인 해보슈.");break case user_email : alert("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(confirm(JSON.stringify(data) + "\n해당 내용으로\n유저 등록 하실??")) { post("joinUser.bjx",type,JSON.stringify(data),keyword, function (resultData) { alert(resultData) }) } else { } } else { alert("비번이 다름요") } } } /** * 로그아웃을 처리합니다. (바닐라 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 alert('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 { 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(""); } } /* ============================================= */ /* --- 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); alert('투표 중 오류가 발생했습니다.'); 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) { alert('댓글 내용을 입력하세요.'); commentInput.focus(); return; } // 전역 변수(currentReplyParentId)를 읽어 대댓글 여부 결정 const commentData = { content: content, writer: null, parentId: 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 { // 서버로 전송 (바닐라 XHR 헬퍼 함수 사용) 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); } }); } 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 new Error('랭킹 등록에 실패했습니다.'); } 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(); }