/**
* =================================================================================
* 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', () => {
console.log("DOM Loaded: Attaching Vanilla JS event listeners.");
// --- 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(); // 기본 버튼 동작 방지
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/';
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 || '');
// 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` +
`