2442 lines
100 KiB
JavaScript
2442 lines
100 KiB
JavaScript
/**
|
|
* =================================================================================
|
|
* 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'는 클릭된 <div> 요소를 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 = serverData.title || '';
|
|
baseData.content = 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(); // 위치 정보 가져오기 시작
|
|
|
|
const manualDateInput = document.getElementById('manual_date');
|
|
const manualLatInput = document.getElementById('manual_lat');
|
|
const manualLonInput = document.getElementById('manual_lon');
|
|
|
|
if (manualDateInput) {
|
|
const timeToDisplay = baseData.writeTime > 0 ? baseData.writeTime : new Date().getTime();
|
|
manualDateInput.value = formatTimestampForInput(timeToDisplay);
|
|
}
|
|
if (manualLatInput && manualLonInput) {
|
|
// 수정 좌표가 있으면 그것을, 없으면 최초 좌표를, 둘 다 없으면 빈 칸으로 설정
|
|
manualLatInput.value = baseData.modifyLat || baseData.firstPostLat || '';
|
|
manualLonInput.value = baseData.modifyLon || baseData.firstPostLon || '';
|
|
}
|
|
|
|
try {
|
|
|
|
const ImageBlot = Quill.import('formats/image');
|
|
|
|
class StyledImage extends ImageBlot {
|
|
static formats(domNode) {
|
|
// 기존 속성(alt, height, width) 외에 style 속성을 추가로 허용합니다.
|
|
const formats = super.formats(domNode);
|
|
if (domNode.hasAttribute('style')) {
|
|
formats.style = domNode.getAttribute('style');
|
|
}
|
|
return formats;
|
|
}
|
|
|
|
format(name, value) {
|
|
if (name === 'style') {
|
|
if (value) {
|
|
this.domNode.setAttribute(name, value);
|
|
} else {
|
|
this.domNode.removeAttribute(name);
|
|
}
|
|
} else {
|
|
super.format(name, value);
|
|
}
|
|
}
|
|
}
|
|
// Quill 에디터 옵션 및 모듈 설정 (편집/읽기 모드 전환)
|
|
var Font = Quill.import('formats/font');
|
|
|
|
Font.whitelist = ['monospace','sans-serif', 'serif', 'arial', 'georgia', 'comic-sans-ms', 'courier-new', 'roboto', 'playfair-display'];
|
|
Quill.register(Font, true);
|
|
Quill.register({ 'modules/table-better': QuillTableBetter }, true);
|
|
// Quill.register({'modules/imageResize': ImageResize}, true);
|
|
Quill.register('modules/imageResize', QuillResizeModule);
|
|
Quill.register(StyledImage, true);
|
|
const quillOptions = {
|
|
theme: 'snow',
|
|
modules: useEditor ? {
|
|
imageResize: {
|
|
displaySize: true
|
|
},
|
|
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 }
|
|
} : {imageResize: {
|
|
displaySize: true
|
|
},
|
|
toolbar: false // 읽기 모드(useEditor: false)일 경우 툴바 숨김
|
|
},
|
|
readOnly: !useEditor // 읽기 전용 모드 설정
|
|
};
|
|
|
|
quill = new Quill(editorContainer, quillOptions); // Quill 인스턴스 생성
|
|
|
|
if (useEditor) { // 편집 모드일 때만 스티키 로직을 활성화합니다.
|
|
const toolbar = document.querySelector('.ql-toolbar');
|
|
const editorTop = editorContainer.offsetTop;
|
|
|
|
window.addEventListener('scroll', () => {
|
|
if (window.scrollY > editorTop) {
|
|
toolbar.classList.add('sticky');
|
|
editorContainer.classList.add('has-sticky-toolbar');
|
|
} else {
|
|
toolbar.classList.remove('sticky');
|
|
editorContainer.classList.remove('has-sticky-toolbar');
|
|
}
|
|
});
|
|
}
|
|
|
|
quill.root.addEventListener('paste', (event) => {
|
|
// 1. 클립보드에서 텍스트 데이터 가져오기
|
|
let pasteText = (event.clipboardData || window.clipboardData).getData('text');
|
|
|
|
// 간단하게 마크다운인지 확인 (예: #, *, -, > 등의 문자로 시작하는지)
|
|
// 좀 더 정교한 확인 로직을 추가할 수 있습니다.
|
|
const isMarkdown = /^(#|\*|-|>|`)/.test(pasteText.trim());
|
|
|
|
if (isMarkdown) {
|
|
// 2. 기본 붙여넣기 동작을 막습니다.
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
// 3. 마크다운을 HTML로 변환합니다.
|
|
const html = marked.parse(pasteText, { gfm: true , breaks: true});
|
|
|
|
// 4. 현재 커서 위치에 변환된 HTML을 삽입합니다.
|
|
const range = quill.getSelection(true);
|
|
quill.clipboard.dangerouslyPasteHTML(range.index, html);
|
|
}
|
|
// 마크다운이 아니면 Quill의 기본 붙여넣기 로직이 실행됩니다.
|
|
}, true);
|
|
if (baseData.content) {
|
|
loadContent(baseData.content); // DB에서 불러온 콘텐츠를 에디터에 로드
|
|
}
|
|
if (useEditor) {
|
|
quill.format('font', Font.whitelist[0], 'silent');
|
|
}
|
|
// 읽기 모드/편집 모드에 따라 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 토큰을 <meta> 태그에서 직접 읽어옵니다. (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');
|
|
|
|
// [수정] 카테고리 데이터를 링크(`<a>`)를 포함한 HTML로 렌더링
|
|
if (baseData.category && baseData.category !== 'none') {
|
|
const categoryLink = `${getMainPath()}/blog/posts?category=${encodeURIComponent(baseData.category)}`;
|
|
const categoryContent = `<a href="${categoryLink}" style="border-bottom: none;"><span class="tag-item">${baseData.category}</span></a>`;
|
|
categoryBox.innerHTML = `<span class="tag-title">CATEGORY</span><div class="tag-content-wrapper">${categoryContent}</div>`;
|
|
} else {
|
|
categoryBox.innerHTML = `<span class="tag-title">CATEGORY</span><div class="tag-content-wrapper"><span>지정되지 않음</span></div>`;
|
|
}
|
|
|
|
// [수정] 해시태그 데이터를 링크(`<a>`)를 포함한 HTML로 렌더링
|
|
let hashtagContent = '';
|
|
if (baseData.tags && baseData.tags.length > 0) {
|
|
hashtagContent = baseData.tags.split(',')
|
|
.map(tag => {
|
|
const trimmedTag = tag.trim();
|
|
if (trimmedTag) {
|
|
const tagLink = `${getMainPath()}/blog/posts?tag=${encodeURIComponent(trimmedTag)}`;
|
|
return `<a href="${tagLink}" style="border-bottom: none;"><span class="tag-item">#${trimmedTag}</span></a>`;
|
|
}
|
|
return '';
|
|
})
|
|
.join(' ');
|
|
} else {
|
|
hashtagContent = '<span>없음</span>';
|
|
}
|
|
hashtagBox.innerHTML = `<span class="tag-title">HASHTAGS</span><div class="tag-content-wrapper">${hashtagContent}</div>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 팝업 목록에 채울 데이터를 서버에서 가져옴 (바닐라 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. [신규] 수동 입력 필드에서 값 읽기
|
|
const manualDateInput = document.getElementById('manual_date');
|
|
const manualLatInput = document.getElementById('manual_lat');
|
|
const manualLonInput = document.getElementById('manual_lon');
|
|
|
|
// 2.1. 수동 날짜 처리
|
|
if (manualDateInput && manualDateInput.value) {
|
|
const manualTimestamp = new Date(manualDateInput.value).getTime();
|
|
if (!isNaN(manualTimestamp)) {
|
|
dataToSend.writeTime = manualTimestamp;
|
|
if (!dataToSend.id) {
|
|
dataToSend.modifyTime = manualTimestamp;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2.2. 수동 좌표 또는 자동 GPS 위치 처리
|
|
const manualLat = parseFloat(manualLatInput.value);
|
|
const manualLon = parseFloat(manualLonInput.value);
|
|
|
|
if (!isNaN(manualLat) && !isNaN(manualLon)) {
|
|
// 수동 좌표가 유효한 숫자로 입력된 경우
|
|
dataToSend.modifyLat = manualLat;
|
|
dataToSend.modifyLon = manualLon;
|
|
// 좌표를 직접 입력했으므로, 주소 정보는 초기화
|
|
dataToSend.modifyAddress = '';
|
|
|
|
if (!dataToSend.id) { // 새 글일 경우
|
|
dataToSend.firstPostLat = manualLat;
|
|
dataToSend.firstPostLon = manualLon;
|
|
dataToSend.firstAddress = '';
|
|
}
|
|
} else {
|
|
// 수동 좌표가 없으면, 자동 GPS 위치를 사용
|
|
dataToSend.modifyLat = currentLat;
|
|
dataToSend.modifyLon = currentLon;
|
|
if (!dataToSend.id) { // 새 글일 경우
|
|
dataToSend.firstPostLat = currentLat;
|
|
dataToSend.firstPostLon = currentLon;
|
|
dataToSend.firstAddress = '';
|
|
dataToSend.modifyAddress = '';
|
|
}
|
|
}
|
|
|
|
|
|
// 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
|
|
|
|
dataToSend.firstAddress = encodeURIComponent(dataToSend.firstAddress || '');
|
|
// dataToSend.modifyAddress = encodeURIComponent(dataToSend.modifyAddress || '');
|
|
//
|
|
//
|
|
// // 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. 표시할 좌표 결정: 수정 좌표(modifyLat) -> 최초 좌표(firstPostLat) 순으로 확인
|
|
const latToDisplay = (baseData.modifyLat !== 0.0) ? baseData.modifyLat : baseData.firstPostLat;
|
|
const lonToDisplay = (baseData.modifyLon !== 0.0) ? baseData.modifyLon : baseData.firstPostLon;
|
|
|
|
const locationField = document.getElementById('location_field');
|
|
if (!locationField) return; // locationField 요소가 없으면 함수 종료
|
|
|
|
// 2. 저장된 좌표가 있는 경우 (가장 일반적인 경우)
|
|
if (latToDisplay != 0.0 || lonToDisplay != 0.0) {
|
|
try {
|
|
var requestOptions = { method: 'GET' };
|
|
fetch("https://api.geoapify.com/v1/geocode/reverse?lat="+latToDisplay+"&lon="+lonToDisplay+"&apiKey=2b37a75bb0754086b5a1c4a7c3173ee8", requestOptions)
|
|
.then(response => response.json())
|
|
.then(function(result) {
|
|
const locationField = document.getElementById('location_field');
|
|
try {
|
|
var inh = `<span class="tag-title">LOCATION</span>`;
|
|
inh += `<div class="tag-content-wrapper">`;
|
|
inh += `<div class="tag-item">${result.features[0].properties.formatted}</div>`; // 주소
|
|
inh += `<div class="tag-item">Lat: ${latToDisplay.toFixed(2)}</div>`;
|
|
inh += `<div class="tag-item">Lon: ${lonToDisplay.toFixed(2)}</div></div>`;
|
|
locationField.innerHTML = inh;
|
|
} catch (e) {
|
|
// 주소 변환 실패 시 좌표만 표시
|
|
locationField.innerHTML = `<span class="tag-title">LOCATION</span>` +
|
|
`<div class="tag-content-wrapper"><div class="tag-item">Lat: ${latToDisplay.toFixed(2)}</div><div class="tag-item">Lon: ${lonToDisplay.toFixed(2)}</div></div>`;
|
|
}
|
|
})
|
|
.catch(error => console.log('error', error));
|
|
}catch (e) { }
|
|
}
|
|
// 3. 저장된 좌표는 없지만, 브라우저가 GPS를 지원하는 경우 (새 글 작성 시)
|
|
else if (navigator.geolocation) {
|
|
// 현재 위치를 GPS로 가져옵니다.
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => { // 성공 콜백
|
|
currentLat = pos.coords.latitude;
|
|
currentLon = pos.coords.longitude;
|
|
|
|
// 가져온 GPS 좌표를 location-field에 표시
|
|
const inh = `<span class="tag-title">LOCATION</span>
|
|
<div class="tag-content-wrapper">
|
|
<div class="tag-item">Lat: ${currentLat.toFixed(4)}</div>
|
|
<div class="tag-item">Lon: ${currentLon.toFixed(4)}</div>
|
|
</div>`;
|
|
locationField.innerHTML = inh;
|
|
|
|
// [개선] 가져온 좌표를 수동 입력 필드의 기본값으로도 설정해 줍니다.
|
|
const manualLatInput = document.getElementById('manual_lat');
|
|
const manualLonInput = document.getElementById('manual_lon');
|
|
if (manualLatInput) manualLatInput.value = currentLat.toFixed(6);
|
|
if (manualLonInput) manualLonInput.value = currentLon.toFixed(6);
|
|
},
|
|
() => { // 실패 콜백
|
|
locationField.innerHTML = `<span class="tag-title">LOCATION</span><div class="tag-content-wrapper"><div class="tag-item">GPS를 가져올 수 없습니다.</div></div>`;
|
|
}
|
|
);
|
|
}
|
|
// 4. 브라우저가 GPS를 지원하지 않는 경우
|
|
else {
|
|
locationField.innerHTML = `<span class="tag-title">LOCATION</span><div class="tag-content-wrapper"><div class="tag-item">좌표 지원 안함</div></div>`;
|
|
}
|
|
}
|
|
|
|
/* ============================================= */
|
|
/* --- 팝업 제어 함수들 --- */
|
|
/* ============================================= */
|
|
|
|
/**
|
|
* 팝업 레이어를 엽니다. (바닐라 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 += `<li><a href="${getMainPath()}/blog/viewer/${item.id}">${item.title}<br>[${formattedDate}]</a></li>`;
|
|
});
|
|
}
|
|
}).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 += `<li><a href="${getMainPath()}/blog/viewer/${item.id}">${item.title}<br>[${formattedDate}]</a></li>`;
|
|
});
|
|
}
|
|
}).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 토큰을 <meta> 태그에서 읽어옴 (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')
|
|
? `<span class="tag-item">${baseData.category}</span>`
|
|
: '<i>카테고리 설정</i>';
|
|
categoryBox.innerHTML = `<span class="tag-title">CATEGORY</span><div class="tag-content-wrapper">${categoryContent}</div>`;
|
|
}
|
|
|
|
if (hashtagBox) {
|
|
let hashtagContent = '';
|
|
if (baseData.tags && baseData.tags.length > 0) {
|
|
hashtagContent = baseData.tags.split(',')
|
|
.map(t => `<span class="tag-item">#${t.trim()}</span>`)
|
|
.join(' ');
|
|
} else {
|
|
hashtagContent = '<i>해시태그 편집</i>';
|
|
}
|
|
hashtagBox.innerHTML = `<span class="tag-title">HASHTAGS</span><div class="tag-content-wrapper">${hashtagContent}</div>`;
|
|
}
|
|
}
|
|
|
|
/** 1. Staging Category 렌더링: 선택된 카테고리(임시 변수)를 팝업 UI에 표시 */
|
|
function renderStagedCategory() {
|
|
const area = document.getElementById('selected-category-area');
|
|
if (area) {
|
|
if (stagedCategory && stagedCategory !== 'none') {
|
|
area.innerHTML = `<span class="tag-item">${stagedCategory}
|
|
<span class="remove-tag" onclick="removeStagedCategory()">X</span>
|
|
</span>`;
|
|
} else {
|
|
area.innerHTML = '<i>No category selected.</i>';
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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 += `<span class="tag-item">#${tag}
|
|
<span class="remove-tag" onclick="removeStagedHashtag(${index})">X</span>
|
|
</span>`;
|
|
});
|
|
} else {
|
|
area.innerHTML = '<i>No tags selected.</i>';
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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 = '<p style="text-align: center; color: #888;">댓글 목록을 불러오는 중...</p>';
|
|
|
|
try {
|
|
// 1. 최상위 댓글 목록 (ParentId = null)을 먼저 가져옵니다.
|
|
const parentResponse = await fetch(`${getMainPath()}/blog/posts/${postId}/comments.bjx`);
|
|
const parentData = await parentResponse.json();
|
|
|
|
if (parentData.resultCode !== 0 || !parentData.comments) {
|
|
throw new Error(parentData.resultMsg || 'Failed to load parent comments');
|
|
}
|
|
|
|
if (parentData.comments.length === 0) {
|
|
commentsListContainer.innerHTML = '<p style="text-align: center; color: #888;">아직 댓글이 없습니다.</p>';
|
|
return;
|
|
}
|
|
|
|
commentsListContainer.innerHTML = ''; // 컨테이너 비우기
|
|
|
|
// 2. (중요) 각 최상위 댓글을 순회합니다 (비동기 처리를 위해 for...of 루프 사용)
|
|
for (const parentComment of parentData.comments) {
|
|
// 2-1. 최상위 댓글 HTML 생성 및 DOM에 추가
|
|
const parentElement = document.createElement('article');
|
|
parentElement.className = 'comment-item';
|
|
parentElement.id = `comment-${parentComment.id}`;
|
|
parentElement.innerHTML = createCommentHTML(parentComment); // 헬퍼 함수 사용
|
|
commentsListContainer.appendChild(parentElement);
|
|
|
|
// 2-2. 해당 댓글의 "답글 API"를 (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 = '<p>댓글 로딩 중 오류가 발생했습니다.</p>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 댓글 객체를 받아 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(/>/g, ">").replace(/\n/g, "<br>");
|
|
const writerNameForReply = String(writerName).replace(/'/g, "\\'"); // ' 문자가 JS를 깨트리는 것을 방지
|
|
|
|
// 대댓글에는 "답글달기" 버튼을 생성하지 않음 (3단계 이상 미지원)
|
|
const replyButtonHTML = !isReply
|
|
? `<button class="btn-reply" onclick="setReplyTarget('${comment.id}', '${writerNameForReply}')">답글</button>`
|
|
: '';
|
|
|
|
return `
|
|
<div class="comment-header">
|
|
<div class="comment-author-info">
|
|
<strong>${writerName}</strong>
|
|
<span class="comment-date">${formattedDate}</span>
|
|
</div>
|
|
${replyButtonHTML}
|
|
</div>
|
|
<p style="padding: 5px 0 15px 5px; min-height: 2em;">${safeContent}</p>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* "답글 달기" 버튼 클릭 시 호출 (바닐라 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<Object>} 저장된 랭킹 데이터 (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<Array>} 랭킹 배열 (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 = '<li>로딩 중...</li>';
|
|
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 = `<span>${index + 1}. ${rank.playerName}</span> <strong>${formattedScore}</strong>`;
|
|
rankingListEl.appendChild(li);
|
|
});
|
|
} else {
|
|
rankingListEl.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
|
|
}
|
|
} catch (e) {
|
|
rankingListEl.innerHTML = '<li>랭킹을 불러오는데 실패했습니다.</li>';
|
|
}
|
|
|
|
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 = '<p style="color: red;">랭킹 자동 등록에 실패했습니다.</p>';
|
|
}
|
|
} 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<boolean>} 사용자가 '확인'을 누르면 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 - 클릭된 <a> 요소
|
|
*/
|
|
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 ? `<span class="tag-item">${stagedBookmarkCategory} <span class="remove-tag" onclick="stagedBookmarkCategory=''; renderStagedBookmarkCategory();">X</span></span>` : '<i>선택된 카테고리 없음</i>';
|
|
}
|
|
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 = `<span class="tag-item">${stagedBookmarkCategory}</span>`;
|
|
} else {
|
|
// 선택한 카테고리가 없으면 기본 텍스트로 복원
|
|
displayEl.innerHTML = `<span>카테고리 선택</span>`;
|
|
}
|
|
}
|
|
|
|
// 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) => `<span class="tag-item">#${tag} <span class="remove-tag" onclick="removeStagedBookmarkTag(${i})">X</span></span>`).join(' ') || '<i>선택된 태그 없음</i>';
|
|
}
|
|
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 => `<span class="tag-item">#${tag}</span>`).join(' ');
|
|
} else {
|
|
// 선택한 태그가 없으면 기본 텍스트로 복원
|
|
displayEl.innerHTML = `<span>태그 선택</span>`;
|
|
}
|
|
}
|
|
|
|
// 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 ? `<span class="tag-item">${category}</span>` : '<span>카테고리 선택</span>';
|
|
|
|
const tags = bookmark.tags || [];
|
|
document.getElementById('edit-bookmark-tags').value = tags.join(',');
|
|
document.getElementById('edit-bookmark-tags-display').innerHTML = tags.map(t => `<span class="tag-item">#${t}</span>`).join(' ') || '<span>태그 선택</span>';
|
|
|
|
// 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 = `
|
|
<img src="${serverData.apiBaseUrl + image.url}" alt="Bookmark Image">
|
|
<button class="toggle-visibility-btn" onclick="toggleBookmarkImageVisibility(this, '${bookmark.id}', '${image.url}')">
|
|
${image.isVisible ? '👁️' : '🚫'}
|
|
</button>
|
|
`;
|
|
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('삭제 중 오류가 발생했습니다.');
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 밀리초 타임스탬프를 'YYYY-MM-DDTHH:mm' 형식의 문자열로 변환합니다.
|
|
* @param {number} ms - 변환할 타임스탬프 (밀리초)
|
|
* @returns {string} datetime-local input에 사용할 수 있는 형식의 문자열
|
|
*/
|
|
function formatTimestampForInput(ms) {
|
|
if (!ms || ms === 0) return '';
|
|
const date = new Date(ms);
|
|
// 사용자의 로컬 시간대에 맞게 표시하기 위해 타임존 오프셋을 계산하여 적용합니다.
|
|
const timezoneOffset = date.getTimezoneOffset() * 60000;
|
|
const localDate = new Date(date.getTime() - timezoneOffset);
|
|
return localDate.toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm"
|
|
}
|